从 C 进入现代 C++
本节目标
从 C 的已有知识迁移到现代 C++23 的值语义、生命周期、类型安全和标准库默认实践。
如果你已经会写 C,最重要的迁移不是把同一段代码换成 class,而是把“谁负责资源、何时结束、接口允许什么”写进对象和类型。本文以 C++23 为语言模式;它是一门与 C 有交集、但具有独立规则和保证的语言。
C 与 C++ 的语言边界
C 和 C++ 共享一部分词法与语法,但文件后缀和编译器驱动会先决定按哪门语言解释源码:.c 通常交给 cc 进入 C 模式,.cpp 则交给 c++ 进入 C++ 模式。它们不是可以随意互换的开关。
std::string name{"Ada"};
在 C++ 中,这行代码用花括号初始化一个标准库对象,初始化过程会按 C++ 的重载规则选择构造函数,name 也会按对象生命周期自动析构;它不是一个 C 字符数组。即使两门语言中的源码表面相近,初始化、重载和生命周期规则也可能赋予它不同含义。因此,不要把 C++ 源文件当作 C 编译,也不要把对 C 中未定义行为边界的判断原样套到 C++ 上。
需要回查 C 的基础语法时,可从入门导览与第一个程序开始;回到 C++ 时仍应以 C++ 的语言规则判断。
值语义作为默认建模方式
普通值对象拥有自己的状态,复制得到的副本也应能够独立生存:
std::string copy = original;
copy = "independent";
这里复制的是 std::string 的值。修改 copy 不会修改 original,即使原对象先结束生命周期,副本仍保存自己的字符串。移动则表达另一种明确的状态转移,不能与复制混为一谈。
相比之下,char* address_copy = original_buffer; 只复制了一个裸地址,并没有复制缓冲区或建立第二份所有权。若两个位置都把自己当作拥有者并分别释放它,就会破坏所有权约定。
只有在共享身份、可空性或借用确实是接口语义时,才选择指针、引用或之后章节介绍的智能指针。
资源管理与对象生命周期
资源拥有者在构造时取得有效状态,并把清理责任留给析构过程。作用域让这条路径直接可见:
{
const ResourceOwner owner{"owned"};
inspect(owner.state());
} // owner 的生命周期在这里结束
owner 构造成功后拥有自己的状态;离开花括号时,它会被析构,其中的资源随之清理。控制流正常到达作用域末尾、从函数提前返回,或因异常展开栈时,已经构造成功的局部拥有者都会进入相应的析构路径。析构过程不应再抛出异常。
这正是 RAII 与“记得调用清理函数”的区别。若在多个返回路径上手写 free,一个遗漏的错误分支就足以泄漏资源;把释放绑定到对象生命周期后,正常返回和异常离开作用域由同一责任覆盖。
类型安全与抽象边界
类型化接口先把可检查的输入形状写进签名:
void set_count(int count);
void set_count_untyped(void* data); // 真实类型和所有权只能另作约定
第一种签名使调用实参先接受类型检查;第二种写法从签名中抹去了所指对象的具体类型,调用方和实现必须依赖额外约定来传递真实类型与所有权,而这类约定往往只写在注释中。签名本身并不能自动维护所有业务不变量,这仍是实现的责任,但参数、返回值和封装状态应尽可能说明调用者能提供什么、又能观察什么。
抽象不等于隐藏一切:公开最小、可验证的操作,私有地保持对象合法状态。
标准库优先的默认实践
标准容器、字符串和算法把常见边界、资源释放和接口约定集中到受审查的类型中。下面的完整程序同时展示了独立字符串值、随对象保存的资源状态,以及由容器管理的整数集合。
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class ResourceOwner {
public:
explicit ResourceOwner(std::string state) : state_(std::move(state)) {}
[[nodiscard]] const std::string& state() const { return state_; }
private:
std::string state_;
};
int main() {
std::string original{"source"};
std::string copy = original;
copy = "independent";
const ResourceOwner owner{"owned"};
const std::vector<int> library{3, 5, 7};
std::cout << "value_semantics=" << copy << '\n';
std::cout << "resource_state=" << owner.state() << '\n';
std::cout << "library_size=" << library.size() << '\n';
std::cout << "migration=ready\n";
}
输出:
value_semantics=independent
resource_state=owned
library_size=3
migration=ready
程序中的 library.size() 报告元素个数;它回答的不是容器容量,也不承诺底层存储地址稳定。集合增长时,std::vector 可以重新分配存储,因此旧引用、指针和迭代器可能失效。若改用固定长度数组承载会增长的集合,调用方就不得不另外维护容量、扩容和释放路径。
这里的完整程序是正文展示和测试共用的唯一源码;短片段仅说明当前概念,不承诺可脱离上下文编译。
零开销抽象的含义
零开销抽象原则首先约束的是未使用的能力:程序不应仅因为某个抽象还提供了别的特性,就为那些未使用的特性付费。像 for (int value : values) { use(value); } 这样的范围循环,也给编译器保留了基于具体类型消除抽象层次的优化机会。不过,这不等于任意封装都会自动更快,更不是脱离实现就能成立的性能承诺。
实际成本必须放到具体实现、编译选项和工作负载中测量。因为听到“零开销”便跳过基准测试,或凭性能猜测退回不安全的手工内存管理,都混淆了原则与证据。先建立清晰而正确的值和所有权模型;发现瓶颈后,再用测量和工具决定是否调整。
错误表达方式概览
错误通道要与失败是否预期、当前位置能否处理相匹配。可恢复且预期出现的分支通常由返回值交给调用方;构造失败或当前层无法继续时,可以让异常向合适的边界传播。
| 方式 | 适用场景 | 调用方责任 |
|---|---|---|
| 状态值 | 调用者预期检查的简单失败,例如 if (!opened) return false; | 检查返回状态,并从接口获知每种状态对应的失败条件 |
| 类型化结果 | 成功值与预期失败都需要被明确表达,且错误信息属于接口的一部分 | 判断结果所处状态,再分别处理值或错误 |
| 异常 | 构造无法完成,或当前层无法合理恢复而需要向外传播 | 在能够恢复或报告的边界捕获;向该边界传播时依靠栈展开清理已构造对象 |
不要混用哨兵值、全局错误码和未说明的异常,否则调用者无法判断自己必须检查什么、又应在何处处理。
后续章节会展开异常安全和资源回滚;本章只要求先让失败路径与资源所有权一致。
源文件、语言模式与工具链
代表程序使用明确的 C++23 模式和一组严格警告编译:
c++ -std=c++23 -Wall -Wextra -Wpedantic -Werror migration_report.cpp -o migration_report
c++ 选择 C++ 编译器驱动,-std=c++23 请求本章采用的语言模式;-Wall -Wextra -Wpedantic 打开常用与标准一致性诊断,-Werror 则要求这份受测代表程序保持警告为零。该命令不依赖 GNU 方言、隐藏扩展或额外链接参数来让程序通过。若省略语言模式,再把某个编译器当前的默认行为当作可移植 C++,换工具链后就可能得到不同结果。
日常项目是否启用 -Werror 可以另行决定,但仍应先理解每条诊断的来源和可移植性。
从 C 迁移的检查清单
迁移应逐项替换手工资源管理和弱类型接口,而不是把 malloc 逐字换成 new 后保留原有设计。可以按下面的顺序重新明确关系与责任:
- 先把每个对象关系归为值、借用或拥有;共享身份和可空性也要成为显式的接口选择。
- 把手工资源管理收进拥有者类型,逐条检查正常返回和错误路径,避免只更换分配语法而保留原有的所有权歧义。
- 删除不必要的宏;需要一组具名标志时,用
enum class代替无作用域整数标志。 - 用
std::string、std::vector和明确接口承载字符串与可变长度数据,而不是继续暴露裸数组。例如std::vector<int> ids;会管理自己的元素,但它重分配后,旧引用、指针和迭代器仍可能失效。 - 打开警告并运行测试,验证迁移后的类型、生命周期和错误处理假设。
常见陷阱
从 C 进入 C++ 时,下面几种局部改法最容易留下错误假设:
- 只给原有 C 结构加上
class,却仍按原方式管理资源。C++ 的对象、初始化、析构、重载和标准库容器共同构成规则系统,不能只挑一个语法特性使用。 - 写出
const auto count = values.size();后仍假定count一定是int。auto得到的类型由初始化器决定,容器的大小类型不需要等同于int。 - 对移动后的对象内容作具体假设。除非该类型另有保证,否则不应依赖移动后仍保留某个值。
- 把容器内部地址当作稳定标识。扩容等操作可能改变存储位置,也会使相应的引用、指针和迭代器失效。
遇到问题时,先问“对象是否仍活着、谁拥有资源、接口允许什么”,再决定是否需要更底层的工具。后续章节将分别展开初始化、引用和值类别、类和 RAII。