前言
C 和 C++ 的关系经常被两种极端说法描述:一种说"C++ 就是 C 加上了类",另一种说"C++ 是全新的语言,C 的写法都不作数"。两种都不准确。C++ 确实脱胎于 C(早期叫 "C with Classes"),但它一开始就引入了不同的抽象手段:引用、重载、模板、命名空间、构造与析构函数、异常安全(exception safety)。这些东西不是语法糖,它们改变了"写代码"的基本思路。
本文是上篇,只讲从 C 迁移到 C++ 时最先需要建立的四个核心观念:头文件与标准库的名字体系、引用(reference)与指针的分工、函数重载与名字修饰(name mangling)、类型系统上的几处补强(bool、nullptr、const)。类与对象、构造析构、资源管理留到后面。
一个贯穿全文的原则:C++ 的重点不是"C 语法加 class",而是"用类型和生命周期去管理资源"。想明白这一点,后面很多"为什么非得这么写"的规则就不用死记了。
另外提醒一句:本文所有 C 代码示例都以gcc编译为准,C++ 代码以 C++17 为准,GCC 13 / Clang 17 / MSVC 19.3x 都应能通过。
一、头文件与标准库:stdio.h 与 cstdio 的差别
C 的标准库头文件是stdio.h、stdlib.h、string.h这种带.h的名字。C++ 把标准库的名字收进了std命名空间,同时提供了两套头文件名字:
| C 名字 | C++ 名字 | 内容所在命名空间 | 说明 |
|---|---|---|---|
stdio.h | cstdio | std | 推荐用cstdio,函数在std里 |
stdlib.h | cstdlib | std | 含malloc、free、atoi等 |
string.h | cstring | std | 含strlen、memcpy等 |
math.h | cmath | std | 含sqrt、fabs等 |
stdbool.h | 无对应 | —— | C++ 自带bool,不需要它 |
这里有个常被忽略的细节,标准里写得很清楚:<cstdio>是否同时把名字放进全局命名空间,是实现定义的。也就是说,#include <cstdio>之后直接写printf(...),在 GCC 的 libstdc++ 上通常能编过(它同时也注入到全局),但标准并不保证这一点。想要可移植,就写std::printf(...),并且包含<cstdio>。
// 推荐写法:C++17 #include <cstdio> #include <cstring> #include <cmath> int main() { // 显式加 std::,可移植性最好 std::printf("%d\n", static_cast<int>(std::strlen("hello"))); std::printf("%.3f\n", std::sqrt(2.0)); return 0; }而<stdio.h>这套旧名字在 C++ 里也是合法的(标准保留了它),它保证把名字放进全局命名空间,可能也放进std。所以"C 头文件在 C++ 里能不能用"的答案是能,但新代码没有理由再选它。
C 里还有一件 C++ 完全不需要的东西:<stdbool.h>。C99 之前没有布尔类型,C99 才用宏凑出一个bool;C++ 从第一版起就有内建的bool,并且true、false是关键字而不是宏。
二、引用(reference):不是指针的语法糖
C 只有指针。想在函数里改调用方的变量,就得传地址、在函数里解引用。C++ 加了引用:
// C++17 #include <cstdio> void swap_c(int* a, int* b) { // C 风格:调用方要写 & int t = *a; *a = *b; *b = t; } void swap_cpp(int& a, int& b) { // C++ 风格:调用方写起来像传值 int t = a; a = b; b = t; } int main() { int x = 1, y = 2; swap_c(&x, &y); swap_cpp(x, y); // 编译器自动取地址 std::printf("%d %d\n", x, y); return 0; }引用和指针的关键差别,不只是"少写一个*":
| 特性 | 指针(pointer) | 引用(reference) |
|---|---|---|
| 能否为空 | 可以是空指针 | 标准要求必须绑定到对象,不存在空引用 |
| 能否重新绑定 | 可以改指向 | 一旦绑定,终身不变 |
| 是否占存储 | 占(通常一个机器字) | 语义上是别名;是否占存储由编译器决定 |
| 能否取地址 | &p得到指针的地址 | &r得到的是被引用对象的地址 |
| 算术运算 | 支持p+1 | 不支持 |
| 典型用途 | 可选的、可为空的关系 | 函数参数、函数返回值(必须保证有效) |
有一条容易记错的规则:引用本身不是对象,所以你不能有"引用的引用"(int&& r是右值引用,不是引用的引用),也不能有"引用的数组"。
关于"引用是否占存储":在语言层面它只是别名,但为了能作为函数参数传递,编译器通常会用地址实现它。所以sizeof(T&) == sizeof(T)并不代表它一定不占空间——这属于实现细节,取决于编译器如何降级(lower)。
返回引用的写法在 C++ 里很常见,因为它避免了拷贝:
// C++17 #include <string> class Config { public: // 返回引用,调用方拿到的是内部对象本身 std::string& name() { return name_; } // const 引用版本,供 const 对象调用 const std::string& name() const { return name_; } private: std::string name_ = "default"; };代价是调用方必须清楚:拿到的是"借来的东西",一旦被引用的对象生命周期结束,它就是悬垂引用(dangling reference),继续使用属于未定义行为(undefined behavior, UB),标准不保证任何行为。
三、函数重载与名字修饰(name mangling)
C 不支持同名函数,同一个翻译单元里出现两个print就是重定义错误。C++ 支持重载(overload),只要参数列表不同:
// C++17 #include <cstdio> void print(int v) { std::printf("int: %d\n", v); } void print(double v) { std::printf("double: %f\n", v); } void print(const char* v) { std::printf("str: %s\n", v); }编译器区分它们靠的是名字修饰:把函数名、参数类型(在某些 ABI 下还包括命名空间和返回类型参与编码的方式)编成一个唯一的符号名。GCC/Clang 在 Linux 上用的是 Itanium C++ ABI,上面三个函数编出来大致是_Z5printi、_Z5printd、_Z5printPKc。你可以自己验证:
g++ -c print.cpp -o print.o nm -C print.o # -C 会还原成可读形式 nm print.o # 不加 -C 就能看到修饰后的符号名而 C 编译器不做名字修饰,print的符号名就是print。这就是 C/C++ 混编问题的根源:C++ 编译器生成的符号名带了参数信息,链接器按名字找符号时对不上。
解决办法是链接规范(linkage specification)extern "C",它告诉 C++ 编译器:这个名字按 C 的规则修饰(也就是不修饰)。
// c_side.h —— 可以被 C 和 C++ 同时包含 #ifdef __cplusplus extern "C" { #endif void c_function(int x); #ifdef __cplusplus } #endif__cplusplus宏只在 C++ 编译器下定义,这是让同一个头文件被两种语言共享的标准做法。注意extern "C"只影响名字修饰和调用约定,不影响参数类型检查:它禁止的是重载,不是类型安全。
| 场景 | 符号名(Itanium ABI 示例) | 链接器能否找到 |
|---|---|---|
C 函数void f(int) | f | 是 |
C++ 函数void f(int) | _Z1fi | 只有 C++ 侧同意用 C 规范时才能 |
C++ 重载void f(double) | _Z1fd | 与上面不冲突,这正是重载的实现基础 |
四、类型系统的补强:bool、nullptr、const
bool:C++ 内建类型,取值为true/false。它参与整型提升(integral promotion),bool转int得 0 或 1,但反过来int转bool是"非零即真",不会帮你检查语义错误。C++ 里bool的sizeof由实现决定,通常为 1。
// C++17 bool b = 3; // 合法:值为 true(隐式转换) int i = true; // 合法:值为 1 // bool c = "abc"; // 非法,但历史上 C 里指针能隐式转 bool,C++ 里字符串字面量转 bool 是合法且为 true 的 —— 一个经典陷阱 bool d = "abc"; // 合法!值为 true,因为指针非空最后一行是真实存在的坑:bool d = "abc";能编译,但和你想表达的"字符串内容是否为真"完全无关,它只是"指针非空"。写出来编译器通常也不会警告。
nullptr(C++11 起):类型是std::nullptr_t,可以隐式转成任意指针类型和bool,但不会被转成整数。这解决了 C 里NULL的老问题——NULL通常就是0,于是在重载场景下会选错函数:
// C++17 #include <cstddef> // NULL #include <cstdio> void f(int) { std::printf("f(int)\n"); } void f(char*) { std::printf("f(char*)\n"); } int main() { f(0); // 调 f(int),因为 0 是 int f(NULL); // 同样是 f(int)(NULL 通常是 0)—— 大多数编译器会给出警告,但代码仍能编过 f(nullptr); // 调 f(char*),这才是"传空指针"的本意 return 0; }所以新代码一律用nullptr,把NULL只留给必须兼容 C 的头文件。
const:这个关键字在 C 和 C++ 里的能力并不一样。C 里const主要表达"我不想改",但const对象不能用作数组长度(C99 的变长数组是另一回事),也不能替代编译期常量。C++ 里const整数如果由常量表达式初始化,就是常量表达式(constant expression),可以用在数组维度、模板实参等场合;constexpr(C++11)把这件事说得更明确。
// C++17 const int n = 4; int arr[n]; // 合法,n 是常量表达式 const int m = some_func(); // m 是运行期常量 // int arr2[m]; // 非法:m 不是常量表达式 constexpr int k = 4; static_assert(k == 4, ""); // 合法,k 可在编译期求值顺便说一下:C++ 里关于const最容易被忽略的是const 正确性(const-correctness)会传染。一个成员函数如果没标const,const对象就调用不了它,于是调用方不得不去掉const,整条链子就散掉了。这是设计问题,不是语法问题。
实战:一个 C 与 C++ 混编的最小可编译例子
下面这个例子包含一个 C 源文件、一个 C++ 源文件和一个共享头文件,演示extern "C"的必要性。三个文件都能直接用gcc/g++编译。
/* c_lib.h:C 和 C++ 都能包含 */ #ifndef C_LIB_H #define C_LIB_H #ifdef __cplusplus extern "C" { #endif int c_add(int a, int b); #ifdef __cplusplus } #endif #endif /* C_LIB_H *//* c_lib.c:用 C 编译器编译 */ #include "c_lib.h" int c_add(int a, int b) { return a + b; }// main.cpp:用 C++ 编译器编译,C++17 #include <cstdio> #include <string> #include "c_lib.h" namespace demo { // 重载:C 里做不到 std::string describe(int v) { return "int " + std::to_string(v); } std::string describe(double v) { return "double " + std::to_string(v); } std::string describe(const std::string& v) { return "string " + v; } } int main() { int sum = c_add(2, 3); // 调 C 函数,靠 extern "C" 链接成功 std::printf("%d\n", sum); std::printf("%s\n", demo::describe(7).c_str()); std::printf("%s\n", demo::describe(1.5).c_str()); std::printf("%s\n", demo::describe(std::string("hi")).c_str()); return 0; }编译命令:
gcc -std=c11 -c c_lib.c -o c_lib.o g++ -std=c++17 -c main.cpp -o main.o g++ main.o c_lib.o -o demo如果把c_lib.h里的extern "C"去掉,main.o里会去找_Z5c_addii这个符号,而c_lib.o里只有c_add,链接阶段会报undefined reference to '_Z5c_addii'(GCC)或unresolved external symbol(MSVC)。这就是名字修饰造成的、最典型的 C/C++ 混编报错。
常见坑点
坑 1:以为extern "C"只写在一侧就够。
❌ 只在main.cpp里对函数指针写extern "C",头文件里不写。
✅ 在头文件里用#ifdef __cplusplus包起来写extern "C",保证声明和定义的链接规范一致。
坑 2:把NULL当空指针用。
❌void* p = NULL;或f(NULL);(重载场景下可能选到int版本)。
✅ 一律用nullptr(C++11 起)。C 代码里继续用NULL。
坑 3:const char*与char*混用。
❌char* p = "hello";——C++11 起字符串字面量是const char[],这会报错(GCC 下是 error,历史上是废弃警告)。
✅const char* p = "hello";。确实需要可写缓冲,就用char buf[] = "hello";(复制一份)或std::string。
坑 4:返回局部变量的引用。
❌
int& bad() { int x = 1; return x; } // 返回悬垂引用✅ 按值返回(int bad()),或返回静态/堆上对象的引用并明确所有权约定。使用悬垂引用是 UB,标准不保证任何行为。
坑 5:函数重载只改返回类型。
❌int f();与double f();同时出现——重定义错误,因为重载只看参数列表。
✅ 改参数列表,或者改函数名。返回类型不参与重载决议。
坑 6:以为sizeof(bool)一定是 1。
❌ 用sizeof(bool)当字符串序列化格式的假设常量。
✅ 标准说sizeof(bool)由实现定义,通常 1,但不要写进线协议。需要固定宽度用<cstdint>的std::uint8_t。
坑 7:在 C++ 里用malloc/free管理带构造函数的对象。
❌
std::string* s = (std::string*)malloc(sizeof(std::string)); // 没有构造,后续使用是 UB free(s);✅ 用new/delete,或者更好——用std::string本身、容器、智能指针,让析构自动发生。
坑 8:把#include <cstdio>后直接调printf当成可移植写法。
❌ 依赖"名字被注入全局命名空间"这个实现行为。
✅ 写std::printf。反过来,如果包含的是<stdio.h>,标准保证全局名字存在,此时能不能写std::printf反而是实现定义。
总结
| 主题 | C 的做法 | C++ 的做法 | 关键点 |
|---|---|---|---|
| 头文件 | stdio.h | cstdio,名字在std | 全局名字是否注入是实现定义 |
| 传参修改 | 指针 | 引用 | 引用不能为空、不能重绑定 |
| 同名函数 | 不允许 | 重载 | 靠名字修饰区分 |
| 空指针 | NULL(常为 0) | nullptr | nullptr不会误转成int |
| 布尔 | <stdbool.h>的宏 | 内建bool | 指针隐式转bool是陷阱 |
| 编译期常量 | 宏 /enum | const/constexpr | constexpr语义更明确 |
| 动态内存 | malloc/free | new/delete、RAII | 混用是 UB |
从 C 到 C++ 的过渡,本质上是从"手动管理一切"转向"让类型系统替你记住规则":引用让"必须存在"成为类型的一部分,重载让"同一抽象的不同实现"有了统一的调用形式,constexpr和bool让编译器能更早发现问题。上篇讲到这里,剩下的类、构造析构、RAII 会在后续展开。