0.1 C++ 标准二三事
所以「现代 C++」到底现代在哪里?
每本讲义开头,似乎总要来点历史课的,那不妨就依了这传统罢。
今天是昨天的明天
C++ 的前身是 Bjarne Stroustrup 在贝尔实验室开发的 “C with class
这段历史留下了 C++ 的一个基本特点:不断增加抽象、同时努力兼容旧写法、保留旧代码能力。今天的 C++ 仍然可以调用很多 C 接口,也仍然允许使用裸指针、手工内存管理和宏,C 与 C++ 的混合编译也十分成熟。
如今,C++ 标准由 ISO / IEC C++ 委员会维护。编译器和标准库实现根据标准提供语言功能与库功能,程序员则可以在编译时选择一个语言标准模式。
标准名称中的年份表示该版本完成技术工作的年份。例如 C++98 对应 1998 年、C++03 对应 2003 年。
| 版本 | 大事件 |
|---|---|
| C++98 | 第一版广泛使用的 ISO C++ 标准,确定了早期 C++ 的基本面貌 |
| C++03 | 以修正问题为主的维护版本 |
| C++11 | C++ 历史上的重大转折,加入移动语义、lambda、范围 for、auto、智能指针等 |
| C++14 | 对 C++11 的小幅完善,语言和标准库继续变得更易用 |
| C++17 | 结构化绑定、if constexpr、std::optional、文件系统等进入标准 |
| C++20 | concept、ranges、协程、模块等重要功能进入标准 |
| C++23 | 继续完善库和语言,例如 std::expected 等 |
截至撰稿,C++23 是已经发布的最新 ISO C++ 标准,C++26 仍在推进中。当前标准状态 和 标准简介 可以查看官方说明。
摩登时代
C++98 已经是一门功能丰富的语言,但它在很多日常任务上写起来笨重。一个简单的动态数组需要手工管理内存,算法需要显式书写迭代器,临时对象和资源的转移也缺少统一的表达方式。
通常,C++11 以前的代码称为「传统 C++
#include <algorithm>
#include <vector>
std::vector<int> scores{72, 85, 59};
const auto failed = std::count_if(scores.begin(), scores.end(),
[](int score) { return score < 60; });这段代码中,容器负责管理元素,auto 减少重复的类型书写,lambda 就地表达判断条件,标准库算法表达「计数」这一目的。它仍然可以生成高效的机器码,但程序员不需要把每个底层步骤都手工展开。
可以说,现代 C++ 的「现代」主要体现在资源安全、值语义、标准库优先、清晰表达和编译期检查。
从资源地址转向资源所有权
早期代码经常把指针、内存地址和数组下标当成主要抽象。现代代码让类型表达所有权和生命周期:
- 一组元素由
std::vector管理; - 一段文本由
std::string管理; - 独占资源通常由
std::unique_ptr或自定义 RAII 类型管理; - 只是借用连续数据时,可以使用
std::span; - 只是借用文本时,可以使用
std::string_view。
指针并没有从 C++ 中消失。裸指针通常仍然只表示一个地址,不直接说明是否拥有对象;变化在于现代代码会另外使用容器、RAII 类型和智能指针明确表达所有权,并在使用裸指针和引用时关注其指向对象的生命周期。
从步骤转向意图
现代标准库提供了容器、算法、ranges、时间、文件系统、线程和同步工具。优先使用这些抽象,通常比自己重复实现一套动态数组或排序循环更容易检查和维护。
好的现代 C++ 是让代码的意图清楚:简单循环就写简单循环,查找、排序、计数等已有明确语义的操作就使用对应的标准库设施。
从运行时约定转向编译期检查
enum class、constexpr、static_assert、concept 和更明确的函数参数类型,都可以把一部分约定交给编译器检查。
enum class Result {
passed,
failed,
};
constexpr int passing_score{60};
static_assert(passing_score > 0);如果一个值只能代表几种状态,使用枚举比散落的整数常量更清楚;如果规则在编译期就能验证,就不必等程序运行到某个分支才发现问题。
从「面向对象」转向合适的抽象
现代 C++ 并不要求把所有数据都包装成类,再通过继承组织成树。一个简单的数据聚合可以仍然是 struct,一个有明确不变量的类型才需要类,运行时多态也只是组合、模板和 std::variant 之外的一种选择。
标准、编译器和标准库
不过,标准作为指挥棒,它定义好了新内容并不意味着最终的实现里就可以用了。这其中至少涉及三层:
| 层次 | 负责什么 | 例子 |
|---|---|---|
| 语言标准 | 规定语法、类型系统和语言行为 | C++17、C++20、C++23 |
| 编译器 | 把源代码翻译成目标代码,实现语言功能 | GCC、Clang、MSVC |
| 标准库实现 | 提供 <vector>、<span>、算法等库设施 | libstdc++、libc++、MSVC STL |
以 std::span 为例,它是 C++20 标准库中的类型,不是一个只靠编译器关键字就能获得的功能。下面几种情况都可能导致它不可用:
- 编译器太老,不支持 C++20 语法或库配置;
- 编译器支持 C++20,但配套标准库实现太老;
- 编译器和标准库都正常,但编译时没有设置正确的标准模式,例如设成了 C++17。
本套笔记的主线使用 C++20,是因为它已经提供了足够完整的现代 C++ 基础,而且工具链支持相对普遍。C++23 的内容会在适合的位置单独标注;C++26 的草案功能不会成为正文的前置条件。
我们将在下一节更具体地介绍工具链与配置,并在后续的第三章中介绍更复杂的构建系统。
未定义行为
由于 C++ 是相当底层的语言,编译器和运行时不可能事无巨细地帮你检查所有行为。如果程序违反了 C++ 对运行时行为的要求,标准便不再规定结果。这称为未定义行为(undefined behavior,UB
- 数组越界;
- 读取未初始化对象;
- 使用已经结束生命周期的对象;
- 有符号整数溢出;
- 解引用空指针;
- 无副作用的死循环(在本套笔记使用的 C++20 模式下
) ; - 错误地管理动态内存。
在这种情况下,标准不再保证行为。程序可能看似正常,也可能报错、崩溃,或者在开启优化后表现出完全不同的结果。是否能在运行时发现某一类错误,取决于具体代码、编译选项、运行库和检查工具,不能简单归结为某个编译器一定会检查、另一个一定不会。
编译器认为正确的 C++ 程序不会有未定义行为。因此在一些优化策略下,事情可能会朝着奇妙的方向发展。考虑下面的代码:
int value{2147483647};
std::cout << (value < value + 1); // 标准不保证输出结果在常见的 32 位 int 环境中,计算 value + 1 会发生有符号整数溢出,从而产生未定义行为。某次未优化构建可能观察到补码回绕后的 -2147483648,但这不是 C++ 标准承诺的结果;换用另一组编译器版本或优化选项,结果就可能改变。
国内的不少基础 C++ 教材将溢出补码回绕视为理所当然的结果。这是错误的认知。
编译器可以假设一个具有已定义行为的程序不会发生有符号溢出。在这个前提下,value + 1 必然大于 value,所以优化器可能把整个比较直接替换成 true。它也可以产生其他结果,因为一旦执行到未定义行为,标准便不再施加要求。
需要区分「值」和「产生值的计算
在 32 位 」 : int环境中,-2147483648本身是可以表示的值;上例的问题是通过超出范围的有符号加法尝试得到它。
这显示出不同编译器、选项和构建面对 UB 的表现可能不同。
编译器警告和 Sanitizer 能发现一部分 UB,但不能代替正确的设计。编码时,要主动避免 UB。
小结
- C++ 标准以年份命名,在保留兼容性的同时不断演进
- 一般认为 C++11 是「现代 C++」的开端,现代性体现在:
- 从资源地址转向资源所有权
- 从步骤转向意图
- 从运行时约定转向编译期检查
- 从「面向对象」转向合适的抽象
- 新特性需要编译器和标准库的支持,还需要在编译时正确配置
- 未定义行为(UB)标准不规定、不保证,应避免