)
前言从 C 转向 C 时很多人以为只要把.c后缀改成.cpp、把printf换成std::cout就算过渡完成了。真正让人翻车的往往不是语法而是语义层面的默认行为变了malloc失败返回空指针new失败默认抛异常C 的结构体是纯数据C 的结构体可以有构造析构C 的函数名就是函数名C 的函数名会被编译器按参数类型改写。这些差异如果不主动意识到写出来的代码会以能编译但行为诡异的方式爆炸。本文是从 C 到 C的下半部分聚焦五个高频过渡点动态内存管理、引用、函数重载与默认参数、命名空间与类型转换、以及标准输入输出的替换。上半部分讨论的头文件写法、bool、const增强等内容这里不再重复。所有示例以C17为基准用 GCC 13 / Clang 17 / MSVC 19.3x 都可以直接编译。文中会明确指出哪些是标准规定、哪些只是某家标准库的实现细节。一、内存管理malloc/free 和 new/delete 不是同一层东西C 程序员第一反应通常是用new只是malloc的语法糖。不是。两者在三件事上根本不同维度malloc/freeC 标准库new/deleteC 语言是否调用构造/析构函数不调用只给一块原始字节new先分配再构造delete先析构再释放失败时的表现返回空指针NULL默认抛出std::bad_alloc返回类型void*需要显式转换类型化的指针无需转换能否重载不能可以重载operator new/operator delete数组形式malloc(n * sizeof(T))手工算大小new T[n]由编译器记录元素个数关键点在于构造函数。对std::string、std::vector这类带有不变式invariant的类型用malloc拿到的内存里是随机字节里面的指针成员是野指针任何成员函数调用都是未定义行为。如果确实需要分配失败返回空指针的语义C 提供了不抛异常的版本声明在new中#include new int* p new (std::nothrow) int[1000]; // 需要 new if (p nullptr) { // 分配失败这里走的是空指针分支而不是异常分支 } delete[] p;std::nothrow是标准库里的一个std::nothrow_t常量对象new (std::nothrow) T这种形式称为 placement new 的一种重载形式。它失败时返回空指针成功时行为与普通new相同。还有一点容易忽略new分配内存和调用构造函数是两步。如果构造函数抛异常编译器会自动调用对应的operator delete把内存还回去不会泄漏。这是new T相比malloc 手动构造的一个实打实的好处。二、引用一个不会重新绑定的别名引用reference是 C 没有的东西。它最容易和指针混淆但两者的语义差别很大特性指针T*引用T能否为空可以nullptr不能必须绑定到对象能否重新指向可以随时赋值不可以一经绑定终身不变是否占存储通常占一个指针大小语义上是别名是否占存储由实现决定能否做算术可以p 1不能取地址的结果指针自己的地址所绑定对象的地址引用不占存储这句话要小心。标准只规定引用是别名没有规定它是否占用存储。当引用出现在类成员、数组元素、或作为参数传递需要传地址时编译器通常会用指针实现它。这是实现细节不同编译器可能不同不要依赖它。一个典型的 C 过渡到 C 的写法是交换函数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; }调用时swap_cpp(x, y)不需要写取地址符号读起来就是交换 x 和 y出错概率明显更低。引用还有一个重要用法是常量引用做参数避免拷贝void print(const std::string s); // 不拷贝也不允许修改const T还能绑定临时对象从而把字面量直接传进去print(hello); // 临时 std::string 绑定到 const 引用生命周期延长到函数返回三、函数重载、默认参数与名字修饰C 语言不允许同名函数因为 C 的目标文件符号表里只有裸函数名。C 允许重载靠的是名字修饰name mangling编译器把函数名和参数类型编码成唯一符号。例如int add(int, int); double add(double, double);在 GCC 13 上编译后可以用nm看到类似_Z3addii和_Z3adddd的符号加上-C参数则是反修饰后的可读形式。具体编码规则由 ABI 规定Itanium C ABI 在 Linux/macOS 上通用MSVC 有自己的编码方案不是标准规定的内容不同编译器之间不兼容所以不要试图去解析或手写这些符号。正因为有名字修饰C 代码调用 C 编译出来的目标文件时必须用extern C告诉编译器这个名字别修饰extern C int c_function(int); // 按 C 规则生成符号链接时才找得到关于默认参数有两条必须记住的规则默认参数在编译期由调用方填入等价于调用方自己写了那个实参。默认参数只能出现在声明里且从右往左连续指定。void log(const std::string msg, int level 1, bool flush false); // 合法默认参数从右往左 // void bad(int a 1, int b); // 非法右边没有默认值虚拟函数配合默认参数是个经典陷阱默认参数是静态绑定的虚函数是动态绑定的两者一叠加就会出现调用的是派生类函数体却用了基类的默认值这点在坑点里详述。四、命名空间、类型转换与布尔类型命名空间解决的是全局名字污染问题。C 里只能靠加前缀gtk_、pthread_来避免重名C 提供了语言级的方案namespace net { struct Socket { /* ... */ }; void connect(Socket); } // 使用时 net::connect(sock);注意不要在有多个头文件的工程里写using namespace std;尤其是头文件里。它会把你没预期的名字全引进当前作用域一旦标准库升级新增了名字就可能和你的代码撞车。类型转换方面C 用四个具名转换替换了 C 的强制转换转换用途检查时机static_cast相关类型间的转换、去void*编译期const_cast增删const限定编译期reinterpret_cast按位重新解释指针与整数互转编译期最危险dynamic_cast多态类型的安全向下转换运行期需要 RTTIdynamic_cast要求源类型是多态类型至少有一个虚函数转换引用失败时抛std::bad_cast转换指针失败时返回空指针。reinterpret_cast之后再解引用绝大多数情况下是 UB除非你确切知道目标平台的对齐与别名规则标准不保证任何行为。bool是 C 内建类型true/false是关键字。把int隐式转成bool是合法的非零即真但把bool转成别的方向的窄化在列表初始化里是被禁止的这在下一篇里会展开。五、从 stdio 到 iostreamC 的printf需要你把格式串和实参类型手工对齐类型错了就是 UB。C 的std::cout由重载决议选出正确的operator类型安全// C printf(%d %s\n, count, name.c_str()); // 类型写错编译器不报错 // C std::cout count name \n; // 类型由重载决定代价是流式输出更啰嗦且std::endl会强制刷新缓冲区\n不会在循环里用std::endl会显著拖慢输出——这是原理问题不是跑了多少毫秒的问题。输入方面std::getline与operator混用时会把换行符留在缓冲区里getline立刻读到空行。这是新手最常见的 IO 坑。下面是一个把本文所有知识点串起来的完整例子可直接保存为main.cpp用g -stdc17 -Wall -Wextra main.cpp -o demo编译#include iostream #include string #include new #include cstddef namespace demo { class Buffer { public: explicit Buffer(std::size_t n) : size_(n), data_(new int[n]()) {} ~Buffer() { delete[] data_; } Buffer(const Buffer) delete; // 禁止拷贝避免 double free Buffer operator(const Buffer) delete; std::size_t size() const { return size_; } int operator[](std::size_t i) { return data_[i]; } const int operator[](std::size_t i) const { return data_[i]; } private: std::size_t size_; int* data_; }; void fill(Buffer b, int value) { // 用引用避免拷贝 for (std::size_t i 0; i b.size(); i) { b[i] value; } } int sum(const Buffer b) { // 常量引用只读且不拷贝 int total 0; for (std::size_t i 0; i b.size(); i) { total b[i]; } return total; } } // namespace demo int main() { demo::Buffer buf(5); // 元素被值初始化为 0 demo::fill(buf, 3); std::cout size buf.size() sum demo::sum(buf) \n; int* fallback new (std::nothrow) int[8]; if (fallback nullptr) { std::cout allocation failed\n; return 1; } std::cout fallback[0] fallback[0] \n; delete[] fallback; return 0; }预期输出逻辑上确定与编译器无关size5 sum15 fallback[0]0new int[n]()带那对空括号表示值初始化int会被置零如果写成new int[n]不带括号就是默认初始化int元素的值不确定读取它们属于读取未初始化值。这个差别很多 C 程序员不知道。常见坑点new[]配delete、new配delete[]混用。❌int* p new int[10]; delete p;—— 这是 UB标准不保证任何行为堆管理器可能直接崩。 ✅int* p new int[10]; delete[] p;—— 数组形式必须配对。返回局部变量的引用。❌const std::string f() { std::string s x; return s; }—— 返回后s已析构这是 UB。 ✅std::string f() { std::string s x; return s; }—— 按值返回靠移动语义代价很低。std::cin n后面直接std::getline。❌int n; std::cin n; std::string line; std::getline(std::cin, line);——line拿到的是残留的空行。 ✅ 先丢弃换行std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n);再getline需limits。malloc的返回值在 C 里不隐式转换。❌int* p malloc(10 * sizeof(int));—— C 里void*不能隐式转到int*编译不过。 ✅ 直接用newint* p new int[10];或者配合 POD 类型时int* p static_castint*(std::malloc(10 * sizeof(int)));。默认参数的虚函数陷阱。❌ 基类virtual void f(int x 1)派生类重写成void f(int x 2)通过基类指针调用得到的是基类的默认值1配派生类的函数体。 ✅ 默认参数只写在基类里派生类不重复写或者干脆不用默认参数。用reinterpret_cast把结构体指针当另一种结构体用。❌auto* b reinterpret_castHeader*(buffer);然后访问成员 —— 违反严格别名规则时是 UB标准不保证任何行为。 ✅ 用std::memcpy拷贝到目标类型对象里编译器会优化掉这次拷贝。在头文件里写using namespace std;。❌ 头文件顶层using namespace std;所有包含它的翻译单元都被污染。 ✅ 头文件里写全限定名std::string源文件里再视情况using。new失败不处理。❌ 假设new一定成功不接异常也不检查 —— 内存耗尽时异常向上传播程序直接terminate。 ✅ 要么用try/catch (const std::bad_alloc)要么显式使用new (std::nothrow)并检查空指针需new。总结主题C 的做法C 的推荐做法关键差异动态内存malloc/freenew/delete是否调用构造析构、失败语义不同传参指针引用 /const引用引用不可为空、不可重绑定同名函数不允许重载 名字修饰符号名编码由 ABI 规定名字管理手动加前缀namespace语言级隔离类型转换强制转换(T)x四种具名转换意图明确、可检索输出printfstd::cout类型安全 vs 格式灵活从 C 到 C 的过渡本质是把编译器不管、出错全靠人的部分换成编译器替你检查的部分。凡是能交给类型系统和 RAII 的就不要留给自己手工管理。再补一句本文提到的引用是否占存储、名字修饰的具体编码、new底层用不用malloc全都属于实现细节不同编译器/标准库可以有不同做法写代码时不要依赖它们。