C++11/14/17/20/23新特性,哪些是必须掌握的,哪些基本用得不多?

发布时间:
2024-09-10 18:22
阅读量:
8

C++11 就像是一个新的语言,它弥补了之前 C++ 中的很多问题,引入的大量新特性,使 C++ 变成了一个非常易用的计算机语言,这让很多新程序员开始学习 C++,也让 C++ 重新焕发生机。

可以说,C++11 代表着现代 C++,使用 C++11 标准编写 C++ 代码已经成为一个最基本的项目管理要求,下一个这样的分界线应该是 C++20 了。

然而,C++11 的推出实际上困难重重,它最初提案时的版本是 C++0x,因为就没打算在 2010 年之后推出。很多 C++11 的新特性,在 2000 年之前就已经被提出了,一些特性已经在 Boost 库中被实现。C++ 语言作为一种非集权的计算机语言,在推出新标准时,需要考虑非常多的问题,又因为有各种领域、行业、公司的人一起参与拟定和决策,让 C++ 标准的最终成型变得更为艰难。

另外,C++ 的教学和很多行业的接受度并没有那么积极,导致 C++11 在推出十几年后,很多软件和库依然没有得到全面的更新和替代。对于标准委员会、编译器厂商来说已经是过去的东西,但对大多数普通 C++ 用户来说,却是未来。结果就是,这么多年后,C++11 的一些内容依然没有得到普遍接受。我在面试时问到的一些面试者,他们的项目依然采用 C with class 的 C++ 编程风格。

我学习了 C++之父 Bjarne Stroustrup 编写的 HOPL4(History of Programming Languages),整理了以下一些笔记。每隔一段时间,就应该把这些东西拿出来看看,和自己的业务代码对比一下,看看哪些地方值得改进,不要让自己和团队永远地困惑下去。

# 参考材料

如果你有充足的时间和英文阅读能力,推荐直接读:stroustrup.com/hopl20ma

如果你有充足的时间,但不想阅读原文,这是一些大佬翻译的中文版:github.com/Cpp-Club/Cxx

如果你没有充足的时间,请看本文和后续我发布的几篇文章。我的笔记中不会详细介绍每个特性具体的内容,所以如果你看不懂其中部分内容,可能需要自行查找其他资料补充。

# 并发

C++11 中最重要的一块新功能就是引入了并发编程的接口,在这之前,使用 C++ 编写并发程序只能依赖操作系统提供的库工具完成,从而影响了软件的可移植性。2006 年左右是一个转折点,在那之后,CPU 处理器厂商意识到单核处理器随着频率提高,性能到达了天花板,而需要转向多核处理器设计来实现性能提升。利用好这种新的硬件结构非常重要,C++ 标准便及时地更新了这块的能力。

## 内存模型

对于并发编程中,内存模型的重要性有时会被低估,由于我们在学习并发编程时,内存模型总是很靠后的一节内容。然而在 C++ 标准委员会订立这块特性时,内存模型却需要优先考虑。

内存模型是描述计算机系统内存布局和访问行为的一系列约定。在并发编程中,内存模型用于规定不同线程或处理器如何访问共享内存中的数据,从而在并发程序中能够有可遵循的读写操作可见性和顺序性,确保并发访问时程序的正确性。

C++ 的内存模型基本采用了先行发生关系(happens-before),既支持宽松的内存模型,也支持顺序一致模型。另外,C++ 也支持了原子类型和无锁编程。

C++ 订立内存模型要比其他高级语言复杂的多,因为 C++ 需要考虑多个不同硬件平台的应用厂商的需求,比如以 Intel 为代表的 x86 体系结构下的内存同步模型,以及以 IBM 为代表的 PowerPC 体系结构模型。因为这些难以忽视的差异,导致 C++ 的内存模型要复杂的多。

涉及到的内容,本文不展开,类似的介绍可以在其他书籍和网络资料中找到。主要的几个关键点有:

  • 序列一致性:C++ 不要求严格的序列一致性,而是提供了松散的一致性保证,可以确保在一定程度上的有序执行。
  • 原子操作:当使用原子类型操作时,原子操作在并发层面上是不可细分的,避免了线程之间的竞争。
  • 内存序:不同的内存序可以配置序列执行顺序的不同约束。
  • 数据竞争:明确了数据竞争的条件和后果。

## 线程和锁

是并发编程中使用并发相对较差的一种并发模型,但使用简单,不容易出错。

相关的内容包括:

  • thread:系统级的线程对象,支持 join() 和 detach()
  • mutex:系统级的互斥锁,支持 lock()、unlock() 和 RAII 实现的加锁解锁(unique_lock 和 lock_guard)
  • condition_variable:系统级的条件变量
  • thread_local:线程本地存储

欠缺的一块内容是线程取消操作,即通过一个线程向正在运行的其他线程发送停止命令。在订立这块特性时,C 委员会的一些代表反对支持这块特性。C++20 中提供了一种机制来实现这个目的。

## Future 和 Promise

我一直不知道这个特性应该怎么翻译。一种更现代的、高层次的并发编程模型。

相关的内容包括:

  • future:通过它可以从一个共享的缓冲区中获取 .get() 一个值,可能要等待 promise 将值提前放入缓冲区
  • promise:通过它可以将一个值放入 .put() 到一个共享的缓冲区,并唤醒等待 future 的线程
  • packaged_task:一个类,将一个函数和一个异步线程绑定,并由 future 来获取返回的结果
  • async:一个函数,用来启动任务并在一个线程上执行,将任务包装在 packaged_task 中,并利用 future 和 promise 来实现数据传输

# 简化使用

C++ 容易被人批评的一个缺点是,它写起来非常啰嗦,有不少冗余重复的键入。在 C++11 中,引入了一些特性来改善这种问题。

被程序员们快速接受的新特性是 auto、范围 for 循环和 lambda 表达式。

这些改进的新功能并没有改变之前的任何问题,或者是引入新的有用的功能,仅仅只是让代码写起来更简洁、清晰,但这也足够有意义。

## auto

很早之前,auto 被用于指明某个变量位于栈上,但这个属性是默认的,可以不指定。类似的,要求变量存在寄存器中的关键字 register 一直存在,当然用途也并不多。据 Bjarne Stroustrup 说,他在 1982 年就支持了这个特性,但因为和 C 的不兼容性问题,特性被搁置,最终在 C++11 中才再次拿了出来。

C++11 中的 auto 只能用于声明变量类型时使用,由编译器来根据给变量赋值的表达式的类型,来推导变量的类型。这大大简化了很多代码的录入,尤其是一些类型非常复杂,甚至无法写出类型(如 lambda 对象)的变量。

NOTE:auto 用于变量声明时,并不是泛型,而是由编译器来推导的静态类型。

C++ 使用指南中,建议使用 auto 来避免类型名称的多余重复。但在一些场合下,使用 auto 也会带来歧义,比如表达式返回类型不明确时,使用 auto 会让代码不易读:

auto ret = function(x, 3); // 不易读,看不出来 ret 是什么类型

然而,C++11 中的 auto 依然做的很保守,它只能用于变量的自动推导。C++17 中支持了对函数返回值的 auto 类型和对 lambda 表达式的参数和返回值的 auto 类型;C++20 中才支持了函数参数的 auto 类型。

## 范围 for 循环

大多数现代编程语言中都提供了 for each 的语法,C++ 也不会例外。

这种语法简化了循环,在不需要关心循环细节时,使用它可以避免一些常见的错误,比如边界判断、错误的索引变量等。

## 移动语义和右值引用

在这个特性出现之前,从函数返回大数据时,为了避免复制数据带来的性能开销,只能通过在自由存储区中分配内存,并通过指针传递数据的引用,无论是从参数传入返回指针,还是将指针从返回值返回。这种写法确实能用,但却很难在运算符重载中应用在自定义类型,比如:

Matrix* operator+(const Matrix&, const Matrix&); void use(const Matrix& m1, const Matrtix& m2, const Matrix& m3) { Matrix* mret = m1 + m2; // 这个可以用 (1) // Matrix* mret = m1 + m2 + m3; // 这个做不了 (2) }

移动语义通过移动构造函数来实现,而移动构造函数为了要和复制构造函数区分,采用右值引用作为参数。右值引用作为对右值的引用,也刚好符合移动后,之前位置的所有权便不存在的这一语义。从移动构造函数内部视角来看,形参是右值引用,表示当前构造函数拥有该资源的唯一所有权,也符合 “移动到该函数内” 的定义。

NOTE:符号 && 用在模板参数时,被叫做引用转发(或完美转发),它用来保持左值引用或右值应用的实参类型,而不发生引用折叠。

现在,STL 中的所有容器类都实现了移动语义,可以使得大型数据能够高效的移动。

## 智能指针

C++11 中提供了 shared_ptrunique_ptr,它们利用了 RAII 机制,实现了不需要手动 delete 资源的资源管理,也就是将资源 delete 和对象生命周期绑定,在对象析构时同时 delete 相关的资源。

shared_ptr 比 unique_ptr 出现更早一些,它利用引用计数来标记当前资源有多少个指针在引用,当计数为 0 时才 delete 资源。然而,在多线程程序中,这会导致同步带来的性能问题,所以并不建议滥用 shared_ptr。

也因此,标准中又提供了 unique_ptr,unique_ptr 是和裸指针一样高效的指针,没有额外开销,它有资源的独占所有权,也就是只实现了移动构造函数和移动赋值运算符,而删掉了拷贝构造函数和拷贝赋值运算符。它取代了之前的 auto_ptr。

虽然智能指针足够智能,但过分随意的使用依然会带来很严重的问题。前边提到的 shared_ptr 在多线程程序中的性能问题是其中之一,而没有理解 unique_ptr 的所有权性质便乱用,比如 unique_ptr & 这种,无视了智能指针带来的优势。另外,项目中混合使用智能指针和裸指针的操作,也可能引入难以发现的 bug。

仅在必要时使用智能指针,大多数时候,使用局部变量和更好的类结构和移动语义来实现数据流动。

## 统一初始化器

在该特性之前,不同类型的初始化有不同的写法:

int x; // 基本类型可以有默认初始化,这里给 x 初始化了 0 int x = 7; // 使用 = 来对值进行初始化 int a[] = { 1, 2 }; // 使用 {} 来对数组进行初始化,注意只能用于数组 string s; // 调用了默认构造函数做初始化 vector<int> v(10); // 使用 vector 的带有 int 参数的构造函数初始化 // vector<int> v = { 1, 2 }; // 非法操作

不同的初始化方式带来了很多不确定性,尤其是另一个问题,既同样是列表类型,但自定义容器类型就不能使用 {} 来完成初始化。

这些需求驱动了 C++ 标准委员会为其拟定一套统一的初始化方式。

在新的初始化方式下,采用初始化器来初始化,同时允许对其中一些语法做省略:

int x = {0}; // 也可写作 int x {0}; int a[] = { 1, 2 }; // 也可写作 int a[] {1, 2}; vector<int> v = { 1, 2 }; // 也可写作 vector<int> v {1, 2};

然而,这种语法的引入事实上为本就混乱的初始化语法带来了更多的混乱,很多人依然习惯于使用过去的写法,而另一些人使用了初始化器的写法。虽然 C++ 标准中保证了编译时不会带来歧义,但从阅读上来说,还是带来了新的负担,比如:

vector<int> v1(10); // 初始化 10 个值为 0 的元素 vector<int> v2{10, 20, 30}; // 初始化 3 个值为 10 20 30 的元素 vector<int> v3{10}; // 应该怎么理解?是 10 个值为 0 的元素,还是 1 个值为 10 的元素

第三行就是容易引起歧义的用法。虽然 C++ 标准规定了这种写法属于 “1 个值为 10 的元素”,但对于语法表示上来说,确实不是那么清晰。然而,为了兼容旧代码,过去的写法不可能删除,这种问题大概率会一直保持下去。唯一能做的就是约束软件项目的编程规范(拟定项目的 C++ 标准子集),再通过人工或工具做 code review 来避免项目代码中出现类似的问题。

## nullptr

不需要过多解释的一个特性。它用于避免之前空指针和 0 值的混用,尤其是在类型推导时,一个值为 0 的空指针,既可以推导为指针类型,也可以推导为 int 类型。

之所以没有使用更简短的名称,原因是在拟定这个特性时,之前的旧代码中已经广泛的使用了如 null、nil 等名字。C++ 标准中引入一个关键词时需要考虑到向前兼容,不能破坏之前的代码,所以新的关键词可能都不会太简单。

## constexpr

使用 constexpr 修饰的函数,是在编译期就可以运算和求值的函数,将运行时行为提前到编译期,可以提高运行时效率。除此之外,还有一些场景用到 constexpr:

  • 编译期检查的类型安全计算
  • 在嵌入式系统编程中减少代码内存占用
  • 支持元编程

虽然应用代码中能用到 constexpr 的地方并不多,但 STL 中却有非常多的用途。比如对一些数学计算的查表和固定换算。

C++11 中引入的 constexpr 特性还只是一部分,在之后的 C++20 中,这个特性才得到完善,并逐渐成为元编程的关键支持特性。

## 用户定义字面量

对字面量做扩展,可以理解为自定义对字面量做处理的一种语法糖。

这个特性提供了程序员可以自定义任何符合规范的字面量后缀形式,而不仅限于语言提供的那些。这给一些特殊的场景提供了很大的便利性,比如对数字添加单位:10m 表示 10 米,1.2i 表示虚数 1.2。

定义方法是一个特殊的字面量运算符,比如虚数字面量的定义:

constexpr Imaginary operator""i(long double x) { return Imaginary(x); }

其中 operator”” 为字面量自定义函数,后边接一个标识符,表示后缀内容。

这个特性也比较小众,一般用不到。

值得提出的是,在 C++14 中,内建类型的一些字面量后缀才加入到标准库中,比如 100ul 表示 100 且类型是 unsigned long,”10”s 表示 “10” 且类型是 std::string。

## 原始字符串字面量

另外一个小细节。C++11 支持了 regex 特性,正则模式中,大量使用了 \" 这些符号,而 C++ 字符串中,这两个符号却需要转义才能表示其自身。

为了让正则模式字符串看起来更清晰,这个特性提供了原始字符串标记,使用 R" " 这种模式来标记其中的字符串字面量是原始字符串,不需要转义任何特殊字符。

这是一个小细节,但在特定的场景下非常实用。

## 属性

属性是一些通过 [[ ]] 包含着的特定名词,用来向编译器传递一些信息。C++11 标准中支持的属性有:

  • [[noreturn]]:表示函数不会返回,比如一些终止程序的函数、和固定抛出异常的函数,编译器可以据此属性做优化,比如不用额外生成针对这种函数调用的上下文恢复的代码。
  • [[carries_dependency]] :用于指示一个操作具有依赖关系,通常用在多线程编程中,编译器可以参考这个属性,保证多线程代码的正确优化

# 泛型

C++11 不是最早引入泛型编程的版本,早在 C++98 之前,泛型编程已经得到了广泛的使用,然而,当时的语法比较拙劣,导致非常复杂的编码实现和出错信息显示,即使这样,很多程序员依然忍受着痛苦而继续使用泛型编程。人们对泛型编程的需求非常大,C++11 中提出了一些特性来改善泛型编程的使用体验。

## lambda 表达式

lambda 表达式是 C++11 中提出的新概念,一经提出,便得到了广泛认可和使用。简单来说,lambda 表达式解决了这样的一些需求:

  • 在需要完整代码块的位置定义代码块(而不是在函数外边或类里边定义成员函数)
  • 从代码块里访问代码的上下文(也就是闭包)
  • 代码块是完整独立的封装,可以以统一方式引用

lambda 表达式的语法并不复杂,但需要简单熟悉,捕获列表中的按引用捕获和按值捕获,以及能够指定捕获某个确定的上下文变量的能力,让 lambda 表达式非常灵活。

通常,编译器实现 lambda 表达式的方式是将其构建为一个函数对象,并传递这个函数对象。捕获的变量成为函数对象的数据成员,函数体成为函数对象的调用运算符函数(operator())。这很可能成为一个有趣的面试题。

在 C++14 中,进一步对 lambda 表达式支持了参数泛型和移动捕获,从而让它在泛型编程中更易于使用。

## 变长模板参数

这是一个必要的功能,它解决了两个问题:

  • 实例化包含任意长度的模板类或模板函数
  • 不能以类型安全的方式传递变长参数

其基本语法是 ... ,通过在类型或模板类型后边添加 ... 来表示这个类型或模板类型参数是变长参数。

在变长参数展开时,传统的做法是通过递归调用当前函数,每次递归中,处理变长参数的第一个参数。然后提供一个非变长参数的特化函数(变长参数的位置只是单独的参数类型)作为递归的出口。

这种写法实际上会带来一些问题,比如递归本身带来的调用栈空间开销,以及可能大量的模板实例化开销。

在C++17 中,增加了折叠表达式的特性,允许用简洁方便的语法展开变长模板参数。

## 别名

C 的别名机制使用 typedef 来实现,C++ 也支持这种语法。但众所周知,这种语法在某些表现下非常难以阅读,所以 C++11 中提出了 using 来代替 typedef。

它们的主要区别就是,using 把别名的名称放到了前边,用 = 来连接别名名称和要定义的类型模式。

在用于给复杂的模板类和模板函数定义起别名中,非常有用。

## 元组

tuple 是 C++11 引入的新功能,在这之前,pair 已经出现在标准中。C++ 委员会希望能引入一种可以不限制其中元素数量的打包对象。

最终,元组 tuple 以库的形式加入到 C++11,而不是以语言特性的形式。C++ 标准在添加新特性时,总是倾向于优先以库的形式增加特性,这样有一些优势:

  • 在测试时,测试库比测试语言特性更方便
  • 库可以早于编译器支持新的语言特性之前便投入使用

然而在我看来,tuple 和 pair 的设计并不是非常优雅,它们本身是未命名的对象集合,所以在传递后,其物理意义可能会被丢弃或被误解,从而降低代码的可读性。建议仅在明显具有打包形式的物理值中使用这种功能,而不是随意使用。比如描述颜色,RGB 三个值可以用 tuple 包装,但将普通参数和返回状态包装在一起就不是好的设计。

# 类型安全

C++ 是静态类型语言,虽然支持泛型,但泛型不等于动态类型。静态类型有一些明显的好处:

  • 程序表现更清晰,无论是方便程序员理解代码还是梳理编程逻辑
  • 编译器更容易检查出隐藏的程序问题
  • 编译器可以生成更优化的程序

将任何基础类型(int、string 等)都使用具有物理意义的自定义类型(PersonID、PersonName 等)取代,是一种好的编程实践。无论是直接阅读,还是在自定义类型中加入更多的类型合法性检查,都可以进一步加强代码的质量。

C++11 代码中应当使用以下具有类型安全的指导原则:

  • 不要再使用 Linux POSIX 和 Windows 提供的并发接口,既不安全,也缺少移植性。使用新的 thread 系列工具
  • 如无必要(比如必须要获取下标),使用范围 for 循环遍历容器和数组类型
  • 尽量少的使用指针,同时也不要混合使用指针和引用。使用移动语义时也需要考虑好所有权问题
  • 放心的使用智能指针,自己实现资源管理类时也要有意识使用 RAII,不要混合使用普通指针和智能指针
  • 为了避免混乱的初始化语法,使用 C++11 提供的统一初始化语法,同时使用 auto 类型
  • 使用 constexpr 尽可能地代替宏用法,宏容易出错,出错后报错信息可能很复杂
  • 用户自定义字面量可以改善字面值的表现力,也加强了类型安全
  • 用 enum class 代替 enum,收益很大
  • 在使用内置数组时,考虑用 std::array 替代

C++ 没有办法删除之前不合理的遗留设计,因为需要考虑兼容老代码。C++ 语言的这种顾虑,就像是 Windows 一样,把困难的东西留给自己,把便捷留给用户,才成就了 C++ 语言历久弥新的特质。

# 标准库

C++11 的标准库中增加了大量的新特性和接口,同时也提供了很多有利于开发标准库的实践方法。

## 复杂的实现

C++ 标准库的设计中,存在着很多复杂的实现,很多实现就像是 “黑魔法”,在可读性上简直就是灾难,但它们确实好用,在 C++20 之前,很多奇怪的用法在标准库的实现中不断出现和进化。

认为 C++ 比较复杂和难以学习的一个原因,便是标准库的实现,Bjarne Stroustrup 大佬直白的指出,C++ 的那些所谓“专家”,一股脑地涌入标准库中,去研究这些复杂的语言实现,并且是是而非的通过网络和演讲向其他人解释这些内容,以换取成就感和名望。在其他计算机语言中,这种复杂性通常被隐藏在编译器内部或者库源码中而不开放给普通用户学习。

比如有代表性的技巧是 SFINAE(Substitution Failure Is Not An Error),它在标准库代码中被大量使用,但单独拿出来理解时比较困难,它以奇怪的缩写而被广泛传播。这里不展开,有很多网络资料中介绍这个概念。

## 元编程

另一个有意义的支持是元编程。C++11 之前,人们已经使用基础的模板和宏等语法特性实现了复杂的元编程,但这些实现非常糟糕,无论是编译时间,对计算机资源的浪费,还是可读性和出错后调试的难度。

C++11 中提供的大量新特性改善了 C++ 元编程的难度。比如 lambda 表达式、模板别名、constexpr、type trait 以及 enable_if 等内建函数。C++20 才引入的 concept,早在 C++11 中就被提案,但却遗憾的没有被采纳。

## noexcept

它用来指明一个函数不会抛出异常,编译器可以静态的检查代码中是否有错误的异常设计。

这在用户代码中用处并不大,但在库实现中用处很大。库的实现时,需要考虑到一些操作是否会抛出异常,从而以更无感的方式来处理异常。如果操作明确不会抛出异常,那么便不需要做任何可能的异常假设,这使得那些担心异常带来性能问题的人放心。

然而,不抛出异常不代表不需要处理程序错误,没有异常时,很可能需要通过添加其他逻辑代码来处理如返回值、errno 等状态,所以也可能带来工程的复杂化。

在是否使用 noexcept 的回答之前,需要先搞清楚,异常仅代表着一种处理程序故障的方式,尤其是一些非本地错误。虽然异常发生时并不是零开销的,但这里的 “零开销设计”,应该被理解为,当其他同样严重的错误出现时,和使用非异常的方式处理相比,没有额外的开销。

## 标准库组件

一些典型的新组件(不完整列表):

  • unique_ptrshared_ptr:依赖 RAII 实现的智能指针类型
  • threadmutexcondition_variable:支持多线程编程
  • futurepromisepackaged_task:支持更高级和现代的并发编程
  • tuple:匿名复合类型
  • regex:支持正则表达式
  • chrono:支持和时间有关的操作
  • random:支持不同类型随机数的产生
  • unordered_mapunordered_set:无序的 map 和 set 容器,使用哈希表实现
  • arrayforward_list:更实用的列表类型
  • 类型特征,比如 is_copy_assignable 等,常用于元编程

很多标准库组件来自于 Boost,一个非常重要的 C++ 三方库,它被用来预先验证 C++ 的功能,并在成熟后被引入 C++ 标准库。Boost 的优势是其非常活跃,有很多不同领域的 C++ 的大佬参与其中,促成了它的高质量和广泛性。

unordered_map 和 unordered_set 之所以没有被命名为更显然的 hash_map 和 hash_set,也是因为过去已经有一些项目中使用了这些名字(C++ 引入新名称变得越来越困难了),当然现在的名字也并不差,指明了这些容器的特性,不过,只是名字有点太长了。

chrono 是一个设计非常好且易用的库,用途很广泛。

除此之外,还有新的一些算法库被引入。


文章已经很长了,本来没打算写这么多,阅读起来会挺累,为此也删了一些内容。奈何 C++11 中有趣的特性太多了,围绕着他们产生的故事也很多,值得说道说道。

封面图片是加拿大的 Kinney Lake,来自:A lake surrounded by mountains with a sky filled with clouds photo – Free Kinney lake Image on Unsplash

END