)
前言这是一组「从 C 过渡到 C」笔记的中篇。本文独立成篇不需要读上篇也能看懂它挑的是 C 程序员转 C 时最容易「以为自己会了、其实没会」的那几个点。一个必须先说清的误解很多人以为「C 是 C 的超集所以我的 C 代码直接拿来当 C 编译就行」。这个说法在 1998 年之前勉强能糊弄今天已经完全站不住。C99 引入的变长数组VLA、指定初始化器、复合字面量C 至今没有而 C 引入的引用、重载、new/delete、模板、命名空间C 一个都没有。更麻烦的是同一段代码在两种语言里行为不同——比如malloc的返回值处理、字符串字面量的类型、字符数组的初始化规则。这些「看起来一样、实际不一样」的地方才是迁移路上真正的坑。本文覆盖五个核心过渡点引用、函数重载与名字修饰、const的地位变化、内存管理、字符串处理最后补一串零散但高频的小差异。代码以 C17 为基准。一、从指针到引用它不是语法糖引用reference最常见的心智模型是「自动解引用的常量指针」这个模型在 90% 的场合够用但在关键的地方会骗你。#include iostream void by_ref(int x) { x 42; } // 引用传参 void by_ptr(int* p) { if (p) *p 42; } // 指针传参 int main() { int a 0, b 0; by_ref(a); by_ptr(b); std::cout a b \n; }引用和指针的真实差异维度引用指针是否必须初始化必须可以不初始化能否重新绑定不能一生只绑一个对象可以随时改指向能否为空语言层面不能见下可以nullptr是否占存储通常不占被优化掉但作为类成员时占空间一定占算术运算无有「引用不能为空」这句话要小心你没法创建一个合法的空引用但可以通过解引用空指针来制造一个「绑定到空地址的引用」这属于 UB标准不保证任何行为// ❌ UB对空指针解引用形成引用 int* p nullptr; int r *p; // 这里就已经是 UB 了 int v r; // 后续任何使用同样 UB同样返回局部变量的引用是另一个经典 UB// ❌ 返回悬垂引用 int bad() { int local 10; return local; // 函数一返回local 生命周期结束 } // ✅ 返回对象本身或者返回静态/成员变量 int good() { return 10; }引用作为函数返回值时还能让左值语义变得自然运算符重载比如operator[]几乎都返回引用这样v[0] 1;才能工作。二、函数重载与名字修饰为什么 C 链接不了 C 的目标文件C 允许函数重名吗不允许。C 允许这叫函数重载overloading。#include iostream int square(int x) { return x * x; } double square(double x) { return x * x; } int main() { std::cout square(3) square(2.5) \n; }编译器靠参数列表类型、个数、顺序区分同名函数返回值不参与区分。这也意味着你无法只靠返回值不同来写两个重载——那是编译错误。那链接器怎么找到正确的那个函数答案是名字修饰name manglingC 编译器把函数名和参数类型编码成一个唯一符号。各家编译器的编码规则不同、不同版本也可能不同环境典型修饰结果示意GCC/Clangsquare(int)_Z6squareiGCC/Clangsquare(double)_Z6squaredMSVC形如?squareYAHHZ一类的编码上表中的具体字符串是实现细节跨编译器不通用不要据此写代码。要点是C 语言不做名字修饰某些平台只在符号前加下划线C做。所以 C 编译出来的目标文件里符号名和 C 编译出来的对不上直接链接会报「未定义引用」。解决办法就是链接规范linkage specification// 在 C 里声明一个用 C 方式编译的函数 extern C int c_function(int); // 或者用花括号包一批 extern C { int c_func_a(int); int c_func_b(double); }注意extern C只能修饰非成员函数和函数指针不能直接修饰 C 的类成员函数成员函数不能有 C 链接。跨语言调用的标准套路是C 侧提供自由函数C 侧用extern C声明内部再转调 C 实现。头文件要同时给 C 和 C 用惯用写法是#ifdef __cplusplus extern C { #endif int shared_api(int x); #ifdef __cplusplus } #endif另外提醒一点函数重载要求声明在同一个作用域内可见否则会变成名字隐藏而不是重载——派生类里同名函数会把基类的所有重载都藏起来除非加using。三、const 的地位升级从只读变量到契约在 C 里const基本等价于「只读变量」在 C 里const是一份可参与类型系统的契约能影响重载决议和成员函数的选择。第一个差异C 里const int n 5;定义的n是运行期常量int arr[n];是变长数组VLAC99 特性可选支持C 里它是编译期常量表达式可以直接当数组长度、模板实参。// C 合法n 是常量表达式 const int n 5; int arr[n]; static_assert(n 5, constant expression);第二个差异是const 成员函数。在参数列表后面加const表示「这个函数不会修改对象的可观察状态」const对象只能调用这类函数。#include string class Account { public: explicit Account(std::string owner) : owner_(std::move(owner)) {} const std::string owner() const { return owner_; } // const 成员函数 void deposit(double amount) { balance_ amount; } // 非 const private: std::string owner_; double balance_ 0.0; }; int main() { Account a(alice); a.deposit(100.0); const Account ca a; // ca.deposit(1.0); // ❌ 编译错误const 对象只能调用 const 成员函数 const std::string who ca.owner(); // ✅ (void)who; }第三个差异mutable关键字允许在const成员函数里修改某个成员用于缓存、互斥量这类「不影响逻辑状态」的字段。class CachedQuery { public: int value() const { if (!cached_) { result_ compute(); cached_ true; } // 需要 result_、cached_ 是 mutable return result_; } private: int compute() const { return 42; } mutable int result_ 0; mutable bool cached_ false; };如果去掉两个mutable这段代码无法编译——这正是mutable存在的理由。四、内存管理malloc/free 与 new/delete 的边界第一条铁律malloc配freenew配deletenew[]配delete[]绝不能交叉。交叉使用是 UB标准不保证任何行为。维度malloc/freenew/delete所属C 标准库函数C 运算符是否调用构造/析构否是返回类型void*具体类型指针失败行为返回NULL抛std::bad_alloc大小计算需自己算字节数由编译器算可否重载否可以类或全局一个 C 程序员的经典翻车点void*在 C 里可以隐式转换成任意指针在 C 里不行。// ❌ 在 C 里合法在 C 里是编译错误 // int* p malloc(sizeof(int) * 10); // ✅ C 写法一显式转换仍需配对 free #include cstdlib int* p1 static_castint*(std::malloc(sizeof(int) * 10)); std::free(p1); // ✅ C 写法二优先用 new会调用构造函数 int* p2 new int[10]; delete[] p2;更现代的 C 根本不该出现裸new/delete而应该用 RAIIResource Acquisition Is Initialization容器#include memory #include vector struct Widget { int id; explicit Widget(int i) : id(i) {} }; void demo() { std::vectorWidget v; v.emplace_back(1); // 容器管理内存与生命周期 auto p std::make_uniqueWidget(2); // 独占所有权智能指针 auto s std::make_sharedWidget(3); // 共享所有权智能指针 (void)p; (void)s; } // 退出作用域自动释放不会漏new失败抛异常而非返回空指针所以 C 里那套if (p NULL)的检查习惯要换成异常处理或nothrow形式。五、字符串char*与std::stringC 里字符串就是「以\0结尾的字符数组」长度靠strlen现算C 的std::string自己记录长度支持动态增长且自动管理内存。第一个「看起来一样其实不一样」的点字符串字面量的类型。在 C 里abc的类型是char[4]可以赋给char*在 C 里是const char[4]。把它赋给char*在 C11 起是 ill-formed编译器必须给诊断GCC 默认以警告形式给出// ❌ C11 起 ill-formed // char* s hello; // s[0] H; // 修改字面量同样是 UB // ✅ 只读就用 const char* const char* s1 hello; // ✅ 要修改就用 std::string #include string std::string s2 hello; s2[0] H;第二个点sizeof的含义变了。#include cstddef #include cstring #include string void demo() { const char* p hello; std::string s hello; std::size_t a sizeof(p); // 指针大小通常 8 std::size_t b std::strlen(p); // 5 std::size_t c sizeof(s); // std::string 对象大小实现定义 std::size_t d s.size(); // 5 (void)a; (void)b; (void)c; (void)d; }sizeof(std::string)是实现定义的libstdc 通常 32 字节MSVC STL 随版本在 32 或 40 字节间浮动不要写死。第三个点拼接与比较。C 里是strcat/strcmpC 里是/语义更直观而且不会缓冲区溢出。操作CC求长度strlen(s)s.size()拼接strcat(dst, src)dst src或dst src比较strcmp(a, b)a b取子串手动memcpy 补\0s.substr(pos, len)查找strstrs.find(...)找不到返回std::string::npos需要注意std::string::npos是size_type类型的极大值通常是size_t的最大值用int接它会被截断比较时会出问题。六、其他高频小差异主题CC布尔类型C99 起用_Bool/stdbool.h的bool宏内建关键字bool空指针常量NULL通常是((void*)0)或0优先nullptr类型是std::nullptr_t结构体名需写struct Foo或用typedefFoo直接可用枚举本质是整数枚举量泄漏到外层作用域是独立类型enum class更强C11类型转换(T)x一把梭static_cast/const_cast/reinterpret_cast/dynamic_cast默认参数不支持支持但必须从右往左连续给可变参数stdarg.h变参模板variadic template类型安全输入输出printf/scanfstd::cout/std::cin关于nullptr它是有类型的能参与重载决议避免了NULL被推导成int的尴尬。#include iostream void f(int) { std::cout int\n; } void f(void*) { std::cout ptr\n; } int main() { f(nullptr); // 输出 ptr // f(NULL); // 若 NULL 是 0会调用 f(int)容易踩坑 }关于默认参数只能从右往左连续提供且默认值在声明处给出后定义处不能再写一遍。// ✅ 声明给默认值 void draw(int x, int y, int color 0); // ✅ 定义处不再重复默认值 void draw(int x, int y, int color) { (void)x; (void)y; (void)color; }关于 C 风格转换(T)x会依次尝试const_cast、static_cast、reinterpret_cast的组合非常容易在你不注意的地方静默丢掉 const 或改变解释方式从而引入 UB。// ❌ C 风格转换会顺手把 const 丢掉埋下 UB const int ci 10; int* bad (int*)ci; // *bad 20; // UB修改 const 对象 // ✅ 用命名转换让意图显式真要改 const 得写 const_cast一眼能看出可疑 const int* p ci; // int* still_bad const_castint*(p); // 能编译但改它就是 UB常见坑点坑点 1把 C 代码直接改名成.cpp就以为完事。// ❌ 在 C 里合法C 报错void* 不能隐式转成 int* // int* buf malloc(n * sizeof(int));// ✅ 改用 new[]或者显式 static_cast int* buf new int[n]; delete[] buf;坑点 2new[]配了delete。// ❌ UB数组形式必须配对数组形式的 delete Widget* p new Widget[10]; delete p;// ✅ 配对使用 Widget* p new Widget[10]; delete[] p; // 更好的是根本别用裸 new[]用 std::vector坑点 3返回局部变量的引用或指针。// ❌ 悬垂引用UB const std::string name() { std::string s tmp; return s; }// ✅ 按值返回有移动/拷贝省略或者返回成员/静态对象 std::string name() { return tmp; }坑点 4字符串字面量赋给char*。// ❌ C11 起 ill-formed而且写进去是 UB // char* s abc; // s[0] A;// ✅ 只读用 const char*要改用 std::string const char* ro abc; std::string rw abc; rw[0] A;坑点 5const对象上调用非const成员函数。// ❌ 编译错误ca 是 const 引用 const Account ca a; // ca.deposit(1.0);// ✅ 要么去 const要么给类补上 const 版本的接口 Account ma a; ma.deposit(1.0);坑点 6extern C加在了定义之外的地方。// ❌ 加了 extern C 的函数不能重载而且不能是类成员 // extern C void handle(int); // extern C void handle(double); // 重定义错误// ✅ extern C 只用于给自由函数指定 C 链接重载逻辑放内部 extern C void handle_int(int x); void handle(double x); // C 侧正常重载坑点 7把std::string::npos塞进int。// ❌ npos 是 size_type 的极大值转 int 变成 -1比较逻辑出错 int pos s.find(x); if (pos std::string::npos) { /* ... */ }// ✅ 用同一个类型比较 auto pos s.find(x); if (pos std::string::npos) { /* ... */ }坑点 8malloc出来的内存上跑 C 对象。// ❌ UBmalloc 不会调用构造函数对象的生命周期从未开始 // Widget* w static_castWidget*(std::malloc(sizeof(Widget))); // w-id 1;// ✅ 用 new或者 placement new 手动开启生命周期 Widget* w new Widget(1); delete w;总结过渡点C 的做法C 的做法关键原因参数传递指针引用可读性更好引用不可空、不可重绑同名函数不允许重载 名字修饰链接规范决定符号名只读性const变量const类型契约参与重载、成员函数限定内存malloc/freenew/delete、RAII构造析构、异常安全字符串char*strlenstd::string自动管理长度与内存转换C 风格强转命名转换意图显式、避免误伤 const从 C 到 C 的迁移本质不是「多学了几个关键字」而是换一套看待资源的方式谁申请、谁释放、什么时候失效C 靠纪律C 靠类型系统。把引用、链接规范、const、RAII、std::string这五件事想透剩下的语法细节翻手册就够了。