跳到主要内容

从 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); // 真实类型和所有权只能另作约定

第一种签名使调用实参先接受类型检查;第二种写法从签名中抹去了所指对象的具体类型,调用方和实现必须依赖额外约定来传递真实类型与所有权,而这类约定往往只写在注释中。签名本身并不能自动维护所有业务不变量,这仍是实现的责任,但参数、返回值和封装状态应尽可能说明调用者能提供什么、又能观察什么。

抽象不等于隐藏一切:公开最小、可验证的操作,私有地保持对象合法状态。

标准库优先的默认实践

标准容器、字符串和算法把常见边界、资源释放和接口约定集中到受审查的类型中。下面的完整程序同时展示了独立字符串值、随对象保存的资源状态,以及由容器管理的整数集合。

migration_report.cpp
#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 后保留原有设计。可以按下面的顺序重新明确关系与责任:

  1. 先把每个对象关系归为值、借用或拥有;共享身份和可空性也要成为显式的接口选择。
  2. 把手工资源管理收进拥有者类型,逐条检查正常返回和错误路径,避免只更换分配语法而保留原有的所有权歧义。
  3. 删除不必要的宏;需要一组具名标志时,用 enum class 代替无作用域整数标志。
  4. std::stringstd::vector 和明确接口承载字符串与可变长度数据,而不是继续暴露裸数组。例如 std::vector<int> ids; 会管理自己的元素,但它重分配后,旧引用、指针和迭代器仍可能失效。
  5. 打开警告并运行测试,验证迁移后的类型、生命周期和错误处理假设。

常见陷阱

从 C 进入 C++ 时,下面几种局部改法最容易留下错误假设:

  • 只给原有 C 结构加上 class,却仍按原方式管理资源。C++ 的对象、初始化、析构、重载和标准库容器共同构成规则系统,不能只挑一个语法特性使用。
  • 写出 const auto count = values.size(); 后仍假定 count 一定是 intauto 得到的类型由初始化器决定,容器的大小类型不需要等同于 int
  • 对移动后的对象内容作具体假设。除非该类型另有保证,否则不应依赖移动后仍保留某个值。
  • 把容器内部地址当作稳定标识。扩容等操作可能改变存储位置,也会使相应的引用、指针和迭代器失效。

遇到问题时,先问“对象是否仍活着、谁拥有资源、接口允许什么”,再决定是否需要更底层的工具。后续章节将分别展开初始化、引用和值类别、类和 RAII。