前言
从 C 转向 C++ 时,很多人以为只要把.c后缀改成.cpp、把printf换成std::cout就算过渡完成了。真正让人翻车的往往不是语法,而是语义层面的默认行为变了:malloc失败返回空指针,new失败默认抛异常;C 的结构体是纯数据,C++ 的结构体可以有构造析构;C 的函数名就是函数名,C++ 的函数名会被编译器按参数类型改写。这些差异如果不主动意识到,写出来的代码会以"能编译但行为诡异"的方式爆炸。
本文是"从 C 到 C++"的下半部分,聚焦五个高频过渡点:动态内存管理、引用、函数重载与默认参数、命名空间与类型转换、以及标准输入输出的替换。上半部分讨论的头文件写法、bool、const增强等内容这里不再重复。
所有示例以C++17为基准,用 GCC 13 / Clang 17 / MSVC 19.3x 都可以直接编译。文中会明确指出哪些是标准规定、哪些只是某家标准库的实现细节。
一、内存管理:malloc/free 和 new/delete 不是同一层东西
C 程序员第一反应通常是"用new只是malloc的语法糖"。不是。两者在三件事上根本不同:
| 维度 | malloc/free(C 标准库) | new/delete(C++ 语言) |
|---|---|---|
| 是否调用构造/析构函数 | 不调用,只给一块原始字节 | 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 | 多态类型的安全向下转换 | 运行期,需要 RTTI |
dynamic_cast要求源类型是多态类型(至少有一个虚函数),转换引用失败时抛std::bad_cast,转换指针失败时返回空指针。reinterpret_cast之后再解引用,绝大多数情况下是 UB(除非你确切知道目标平台的对齐与别名规则),标准不保证任何行为。
bool是 C++ 内建类型,true/false是关键字。把int隐式转成bool是合法的(非零即真),但把bool转成别的方向的窄化在列表初始化里是被禁止的,这在下一篇里会展开。
五、从 stdio 到 iostream
C 的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++ -std=c++17 -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; }预期输出(逻辑上确定,与编译器无关):
size=5 sum=15 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_limits<std::streamsize>::max(), '\n');再getline(需<limits>)。
malloc的返回值在 C++ 里不隐式转换。
❌int* p = malloc(10 * sizeof(int));—— C++ 里void*不能隐式转到int*,编译不过。 ✅ 直接用new:int* p = new int[10];,或者(配合 POD 类型时)int* p = static_cast<int*>(std::malloc(10 * sizeof(int)));。
- 默认参数的虚函数陷阱。
❌ 基类virtual void f(int x = 1),派生类重写成void f(int x = 2),通过基类指针调用得到的是基类的默认值1配派生类的函数体。 ✅ 默认参数只写在基类里,派生类不重复写;或者干脆不用默认参数。
- 用
reinterpret_cast把结构体指针当另一种结构体用。
❌auto* b = reinterpret_cast<Header*>(&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/free | new/delete | 是否调用构造析构、失败语义不同 |
| 传参 | 指针 | 引用 /const引用 | 引用不可为空、不可重绑定 |
| 同名函数 | 不允许 | 重载 + 名字修饰 | 符号名编码由 ABI 规定 |
| 名字管理 | 手动加前缀 | namespace | 语言级隔离 |
| 类型转换 | 强制转换(T)x | 四种具名转换 | 意图明确、可检索 |
| 输出 | printf | std::cout | 类型安全 vs 格式灵活 |
从 C 到 C++ 的过渡,本质是把"编译器不管、出错全靠人"的部分,换成"编译器替你检查"的部分。凡是能交给类型系统和 RAII 的,就不要留给自己手工管理。
再补一句:本文提到的引用是否占存储、名字修饰的具体编码、new底层用不用malloc,全都属于实现细节,不同编译器/标准库可以有不同做法,写代码时不要依赖它们。