Skip to content

0.1 C++ 标准二三事

所以「现代 C++」到底现代在哪里?

每本讲义开头,似乎总要来点历史课的,那不妨就依了这传统罢。

今天是昨天的明天

C++ 的前身是 Bjarne Stroustrup 在贝尔实验室开发的 “C with class它保留了 C 的运行效率、接近硬件的能力和相当多的语法习惯,同时逐渐加入类、虚函数、模板等机制。

这段历史留下了 C++ 的一个基本特点:不断增加抽象、同时努力兼容旧写法、保留旧代码能力。今天的 C++ 仍然可以调用很多 C 接口,也仍然允许使用裸指针、手工内存管理和宏,C 与 C++ 的混合编译也十分成熟。

如今,C++ 标准由 ISO / IEC C++ 委员会维护。编译器和标准库实现根据标准提供语言功能与库功能,程序员则可以在编译时选择一个语言标准模式。

标准名称中的年份表示该版本完成技术工作的年份。例如 C++98 对应 1998 年、C++03 对应 2003 年。

版本大事件
C++98第一版广泛使用的 ISO C++ 标准,确定了早期 C++ 的基本面貌
C++03以修正问题为主的维护版本
C++11C++ 历史上的重大转折,加入移动语义、lambda、范围 forauto、智能指针等
C++14对 C++11 的小幅完善,语言和标准库继续变得更易用
C++17结构化绑定、if constexprstd::optional、文件系统等进入标准
C++20concept、ranges、协程、模块等重要功能进入标准
C++23继续完善库和语言,例如 std::expected

截至撰稿,C++23 是已经发布的最新 ISO C++ 标准,C++26 仍在推进中。当前标准状态标准简介 可以查看官方说明。

摩登时代

C++98 已经是一门功能丰富的语言,但它在很多日常任务上写起来笨重。一个简单的动态数组需要手工管理内存,算法需要显式书写迭代器,临时对象和资源的转移也缺少统一的表达方式。

通常,C++11 以前的代码称为「传统 C++C++11 及之后的写法统称为「现代 C++虽然这并非什么严格的分界线,也不是个官方定义,但 C++11 确实是重要的分水岭。它让 C++ 的思维模式发生了变化,将一整套新的表达方式带进了语言和标准库:

cpp
#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 classconstexprstatic_assert、concept 和更明确的函数参数类型,都可以把一部分约定交给编译器检查。

cpp
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++ 程序不会有未定义行为。因此在一些优化策略下,事情可能会朝着奇妙的方向发展。考虑下面的代码:

cpp
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)标准不规定、不保证,应避免