第 2 章 语言可用性的强化
当我们声明、定义一个变量或者常量,对代码进行流程控制、面向对象的功能、模板编程等这些都是运行时之前,可能发生在编写代码或编译器编译代码时的行为。为此,我们通常谈及语言可用性,是指那些发生在运行时之前的语言行为。
2.1 常量
nullptr
nullptr
出现的目的是为了替代 NULL
。在某种意义上来说,传统 C++ 会把 NULL
、0
视为同一种东西,这取决于编译器如何定义 NULL
,有些编译器会将 NULL
定义为 ((void*)0)
,有些则会直接将其定义为 0
。
C++ 不允许直接将 void *
隐式转换到其他类型。但如果编译器尝试把 NULL
定义为 ((void*)0)
,那么在下面这句代码中:
1 | char *ch = NULL; |
没有了 void *
隐式转换的 C++ 只好将 NULL
定义为 0
。而这依然会产生新的问题,将 NULL
定义成 0
将导致 C++
中重载特性发生混乱。考虑下面这两个 foo
函数:
1 | void foo(char*); |
那么 foo(NULL);
这个语句将会去调用 foo(int)
,从而导致代码违反直觉。
为了解决这个问题,C++11 引入了 nullptr
关键字,专门用来区分空指针、0
。而 nullptr
的类型为 nullptr_t
,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。
你可以尝试使用 clang++
编译下面的代码:
1 |
|
将输出:
1 | foo(int) is called |
从输出中我们可以看出,NULL
不同于 0
与 nullptr
。所以,请养成直接使用 nullptr
的习惯。
此外,在上面的代码中,我们使用了 decltype
和 std::is_same
这两个属于现代 C++ 的语法,简单来说,decltype
用于类型推导,而 std::is_same
用于比较两个类型是否相同,我们会在后面 decltype 一节中详细讨论。
constexpr
C++ 本身已经具备了常量表达式的概念,比如 1+2
, 3*4
这种表达式总是会产生相同的结果并且没有任何副作用。如果编译器能够在编译时就把这些表达式直接优化并植入到程序运行时,将能增加程序的性能。一个非常明显的例子就是在数组的定义阶段:
1 |
|
上面的例子中,char arr_4[len_2]
可能比较令人困惑,因为 len_2
已经被定义为了常量。为什么 char arr_4[len_2]
仍然是非法的呢?这是因为 C++ 标准中数组的长度必须是一个常量表达式,而对于 len_2
而言,这是一个 const
常数,而不是一个常量表达式,因此(即便这种行为在大部分编译器中都支持,但是)它是一个非法的行为,我们需要使用接下来即将介绍的 C++11 引入的 constexpr
特性来解决这个问题;而对于 arr_5
来说,C++98 之前的编译器无法得知 len_foo()
在运行期实际上是返回一个常数,这也就导致了非法的产生。
注意,现在大部分编译器其实都带有自身编译优化,很多非法行为在编译器优化的加持下会变得合法,若需重现编译报错的现象需要使用老版本的编译器。
C++11 提供了 constexpr
让用户显式的声明函数或对象构造函数在编译期会成为常量表达式,这个关键字明确的告诉编译器应该去验证 len_foo
在编译期就应该是一个常量表达式。
此外,constexpr
修饰的函数可以使用递归:
1 | constexpr int fibonacci(const int n) { |
从 C++14 开始,constexpr
函数可以在内部使用局部变量、循环和分支等简单语句,例如下面的代码在 C++11 的标准下是不能够通过编译的:
1 | constexpr int fibonacci(const int n) { |
为此,我们可以写出下面这类简化的版本来使得函数从 C++11 开始即可用:
1 | constexpr int fibonacci(const int n) { |
2.2 变量及其初始化
if/switch 变量声明强化
在传统 C++ 中,变量的声明虽然能够位于任何位置,甚至于 for
语句内能够声明一个临时变量 int
,但始终没有办法在 if
和 switch
语句中声明一个临时的变量。例如:
1 |
|
在上面的代码中,我们可以看到 itr
这一变量是定义在整个 main()
的作用域内的,这导致当我们需要再次遍历整个 std::vectors
时,需要重新命名另一个变量。C++17 消除了这一限制,使得我们可以在 if
(或 switch
)中完成这一操作:
1 | // 将临时变量放到 if 语句内 |
怎么样,是不是和 Go 语言很像?
初始化列表
初始化是一个非常重要的语言特性,最常见的就是在对象进行初始化时进行使用。在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体)类型都可以使用 {}
进行初始化,也就是我们所说的初始化列表。而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 ()
进行。这些不同方法都针对各自对象,不能通用。例如:
1 |
|
为解决这个问题,C++11 首先把初始化列表的概念绑定到类型上,称其为 std::initializer_list
,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:
1 |
|
这种构造函数被叫做初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊关照。
初始化列表除了用在对象构造上,还能将其作为普通函数的形参,例如:
1 | public: |
其次,C++11 还提供了统一的语法来初始化任意的对象,例如:
1 | Foo foo2 {3, 4}; |
结构化绑定
结构化绑定提供了类似其他语言中提供的多返回值的功能。在容器一章中,我们会学到 C++11 新增了 std::tuple
容器用于构造一个元组,进而囊括多个返回值。但缺陷是,C++11/14 并没有提供一种简单的方法直接从元组中拿到并定义元组中的元素,尽管我们可以使用 std::tie
对元组进行拆包,但我们依然必须非常清楚这个元组包含多少个对象,各个对象是什么类型,非常麻烦。
C++17 完善了这一设定,给出的结构化绑定可以让我们写出这样的代码:
1 |
|
关于 auto
类型推导会在 auto 类型推导一节中进行介绍。
2.3 类型推导
在传统 C 和 C++ 中,参数的类型都必须明确定义,这其实对我们快速进行编码没有任何帮助,尤其是当我们面对一大堆复杂的模板类型时,必须明确的指出变量的类型才能进行后续的编码,这不仅拖慢我们的开发效率,也让代码变得又臭又长。
C++11 引入了 auto
和 decltype
这两个关键字实现了类型推导,让编译器来操心变量的类型。这使得 C++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯。
auto
auto
在很早以前就已经进入了 C++,但是他始终作为一个存储类型的指示符存在,与 register
并存。在传统 C++ 中,如果一个变量没有声明为 register
变量,将自动被视为一个 auto
变量。而随着 register
被弃用(在 C++17 中作为保留关键字,以后使用,目前不具备实际意义),对 auto
的语义变更也就非常自然了。
使用 auto
进行类型推导的一个最为常见而且显著的例子就是迭代器。你应该在前面的小节里看到了传统 C++ 中冗长的迭代写法:
1 | // 在 C++11 之前 |
而有了 auto
之后可以:
1 |
|
一些其他的常见用法:
1 | auto i = 5; // i 被推导为 int |
从 C++ 20 起,auto
甚至能用于函数传参,考虑下面的例子:
1 | int add(auto x, auto y) { |
>
注意:
auto
还不能用于推导数组类型:
1
2
3
4 auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型
2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
auto auto_arr2[10] = {arr};
decltype
decltype
关键字是为了解决 auto
关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 typeof
很相似:
1 | decltype(表达式) |
有时候,我们可能需要计算某个表达式的类型,例如:
1 | auto x = 1; |
你已经在前面的例子中看到 decltype
用于推断类型的用法,下面这个例子就是判断上面的变量 x, y, z
是否是同一类型:
1 | if (std::is_same<decltype(x), int>::value) |
其中,std::is_same<T, U>
用于判断 T
和 U
这两个类型是否相等。输出结果为:
1 | type x == int |
尾返回类型推导
你可能会思考,在介绍 auto
时,我们已经提过 auto
不能用于函数形参进行类型推导,那么 auto
能不能用于推导函数的返回类型呢?还是考虑一个加法函数的例子,在传统 C++ 中我们必须这么写:
1 | template<typename R, typename T, typename U> |
注意:typename 和 class 在模板参数列表中没有区别,在 typename 这个关键字出现之前,都是使用 class 来定义模板参数的。但在模板中定义有嵌套依赖类型的变量时,需要用 typename 消除歧义
这样的代码其实变得很丑陋,因为程序员在使用这个模板函数的时候,必须明确指出返回类型。但事实上我们并不知道 add()
这个函数会做什么样的操作,以及获得一个什么样的返回类型。
在 C++11 中这个问题得到解决。虽然你可能马上会反应出来使用 decltype
推导 x+y
的类型,写出这样的代码:
1 | decltype(x+y) add(T x, U y) |
但事实上这样的写法并不能通过编译。这是因为在编译器读到 decltype(x+y) 时,x
和 y
尚未被定义。为了解决这个问题,C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto
关键字将返回类型后置:
1 | template<typename T, typename U> |
令人欣慰的是从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:
1 | template<typename T, typename U> |
可以检查一下类型推导是否正确:
1 | // after c++11 |
decltype(auto)
decltype(auto)
是 C++14 开始提供的一个略微复杂的用法。
要理解它你需要知道 C++ 中参数转发的概念,我们会在语言运行时强化一章中详细介绍,你可以到时再回来看这一小节的内容。
简单来说,decltype(auto)
主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype
的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:
1 | std::string lookup1(); |
在 C++11 中,封装实现是如下形式:
1 | std::string look_up_a_string_1() { |
而有了 decltype(auto)
,我们可以让编译器完成这一件烦人的参数转发:
1 | decltype(auto) look_up_a_string_1() { |
2.4 控制流
if constexpr
正如本章开头出,我们知道了 C++11 引入了 constexpr
关键字,它将表达式或函数编译为常量结果。一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr
这个关键字引入到 if
语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:
1 |
|
在编译时,实际代码就会表现为如下:
1 | int print_type_info(const int& t) { |
区间 for 迭代
终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句,我们可以进一步简化前面的例子:
1 |
|
2.5 模板
C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。
外部模板
传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。
为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:
1 | template class std::vector<bool>; // 强行实例化 |
尖括号 “>”
在传统 C++ 的编译器中,>>
一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:
1 | std::vector<std::vector<int>> matrix; |
这在传统 C++ 编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。甚至于像下面这种写法都能够通过编译:
1 | template<bool T> |
类型别名模板
在了解类型别名模板之前,需要理解『模板』和『类型』之间的不同。仔细体会这句话:模板是用来产生类型的。在传统 C++ 中,typedef
可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。例如:
1 | template<typename T, typename U> |
C++11 使用 using
引入了下面这种形式的写法,并且同时支持对传统 typedef
相同的功效:
通常我们使用
typedef
定义别名的语法是:typedef 原名称 新名称;
,但是对函数指针等别名的定义语法却不相同,这通常给直接阅读造成了一定程度的困难。
1 | typedef int (*process)(void *); |
变长参数模板
模板一直是 C++ 所独有的黑魔法(一起念:Dark Magic)之一。
在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子,
接受一组固定数量的模板参数;而 C++11 加入了新的表示方法,
允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。
1 | template<typename... Ts> class Magic; |
模板类 Magic 的对象,能够接受不受限制个数的 typename 作为模板的形式参数,例如下面的定义:
1 | class Magic<int, |
既然是任意形式,所以个数为 0
的模板参数也是可以的:class Magic<> nothing;
。
如果不希望产生的模板参数个数为 0
,可以手动的定义至少一个模板参数:
1 | template<typename Require, typename... Args> class Magic; |
变长参数模板也能被直接调整到到模板函数上。传统 C 中的 printf
函数,
虽然也能达成不定个数的形参的调用,但其并非类别安全。
而 C++11 除了能定义类别安全的变长参数函数外,
还可以使类似 printf
的函数能自然地处理非自带类别的对象。
除了在模板参数中能使用 ...
表示不定长模板参数外,
函数参数也使用同样的表示法代表不定长参数,
这也就为我们简单编写变长参数函数提供了便捷的手段,例如:
1 | template<typename... Args> void printf(const std::string &str, Args... args); |
那么我们定义了变长的模板参数,如何对参数进行解包呢?
首先,我们可以使用 sizeof...
来计算参数的个数,:
1 | template<typename... Ts> |
我们可以传递任意个参数给 magic
函数:
1 | magic(); // 输出0 |
其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:
1. 递归模板函数
递归是非常容易想到的一种手段,也是最经典的处理方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的:
1 |
|
2. 变参模板展开
你应该感受到了这很繁琐,在 C++17 中增加了变参模板展开的支持,于是你可以在一个函数中完成 printf
的编写:
1 | template<typename T0, typename... T> |
事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用
std::bind
及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的。
3. 初始化列表展开
递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。
这里介绍一种使用初始化列表展开的黑魔法:
1 | template<typename T, typename... Ts> |
在这个代码中,额外使用了 C++11 中提供的初始化列表以及 Lambda 表达式的特性(下一节中将提到)。
通过初始化列表,(lambda 表达式, value)...
将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。
为了避免编译器警告,我们可以将 std::initializer_list
显式的转为 void
。
折叠表达式
C++ 17 中将变长参数这种特性进一步带给了表达式,考虑下面这个例子:
1 |
|
非类型模板参数推导
前面我们主要提及的是模板参数的一种形式:类型模板参数。
1 | template <typename T, typename U> |
其中模板的参数 T
和 U
为具体的类型。
但还有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数:
1 | template <typename T, int BufSize> |
在这种模板参数形式下,我们可以将 100
作为模板的参数进行传递。
在 C++11 引入了类型推导这一特性后,我们会很自然的问,既然此处的模板参数
以具体的字面量进行传递,能否让编译器辅助我们进行类型推导,
通过使用占位符 auto
从而不再需要明确指明类型?
幸运的是,C++17 引入了这一特性,我们的确可以 auto
关键字,让编译器辅助完成具体类型的推导,
例如:
1 | template <auto value> void foo() { |
2.6 面向对象
委托构造
C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:
1 |
|
继承构造
在传统 C++ 中,构造函数如果需要继承是需要将参数一一传递的,这将导致效率低下。C++11 利用关键字 using
引入了继承构造函数的概念:
1 |
|
显式虚函数重载
在传统 C++ 中,经常容易发生意外重载虚函数的事情。例如:
1 | struct Base { |
SubClass::foo
可能并不是程序员尝试重载虚函数,只是恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果。
C++11 引入了 override
和 final
这两个关键字来防止上述情形的发生。
override
当重载虚函数时,引入 override
关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的虚函数,否则将无法通过编译:
1 | struct Base { |
final
final
则是为了防止类被继续继承以及终止虚函数继续重载引入的。
1 | struct Base { |
显式禁用默认函数
在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、
复制构造、赋值算符以及析构函数。
另外,C++ 也为所有类定义了诸如 new
delete
这样的运算符。
当程序员有需要时,可以重载这部分函数。
这就引发了一些需求:无法精确控制默认函数的生成行为。
例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private
。
尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。
并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。
若用户定义了任何构造函数,编译器将不再生成默认构造函数,
但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。
C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。
例如:
1 | class Magic { |
强类型枚举
在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果。
C++11 引入了枚举类(enumeration class),并使用 enum class
的语法进行声明:
1 | enum class new_enum : unsigned int { |
这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较,
更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:
1 | if (new_enum::value3 == new_enum::value4) { |
在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int
)。
而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 <<
这个算符来进行输出,可以收藏下面这个代码段:
1 |
|
这时,下面的代码将能够被编译:
1 | std::cout << new_enum::value3 << std::endl |
总结
本节介绍了现代 C++ 中对语言可用性的增强,其中笔者认为最为重要的几个特性是几乎所有人都需要了解并熟练使用的:
auto
类型推导- 范围
for
迭代 - 初始化列表
- 变参模板
第 3 章 语言运行期的强化
3.1 Lambda 表达式
Lambda 表达式是现代 C++ 中最重要的特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性,
而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多,
所以匿名函数几乎是现代编程语言的标配。
基础
Lambda 表达式的基本语法如下:
1 | [捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 { |
上面的语法规则除了 [捕获列表]
内的东西外,其他部分都很好理解,只是一般函数的函数名被略去,
返回值使用了一个 ->
的形式进行(我们在上一节前面的尾返回类型已经提到过这种写法了)。
所谓捕获列表,其实可以理解为参数的一种类型,Lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的,
这时候捕获列表可以起到传递外部数据的作用。根据传递的行为,捕获列表也分为以下几种:
1. 值捕获
与参数传值类似,值捕获的前提是变量可以拷贝,不同之处则在于,被捕获的变量在 Lambda 表达式被创建时拷贝,
而非调用时才拷贝:
1 | void lambda_value_capture() { |
2. 引用捕获
与引用传参类似,引用捕获保存的是引用,值会发生变化。
1 | void lambda_reference_capture() { |
3. 隐式捕获
手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个&
或 =
向编译器声明采用引用捕获或者值捕获.
总结一下,捕获提供了 Lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:
- [] 空捕获列表
- [name1, name2, …] 捕获一系列变量
- [&] 引用捕获, 让编译器自行推导引用列表
- [=] 值捕获, 让编译器自行推导值捕获列表
4. 表达式捕获
这部分内容需要了解后面马上要提到的右值引用以及智能指针
上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值。
C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获,
被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto
本质上是相同的:
1 |
|
在上面的代码中,important 是一个独占指针,是不能够被 “=” 值捕获到,这时候我们可以将其转移为右值,在表达式中初始化。
泛型 Lambda
上一节中我们提到了 auto
关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。
但是 Lambda 表达式并不是普通函数,所以在没有明确指明参数表类型的情况下,Lambda 表达式并不能够模板化。
幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始,Lambda 函数的形式参数可以使用 auto
关键字来产生意义上的泛型:
1 | auto add = [](auto x, auto y) { |
3.2 函数对象包装器
这部分内容虽然属于标准库的一部分,但是从本质上来看,它却增强了 C++ 语言运行时的能力,
这部分内容也相当重要,所以放到这里来进行介绍。
std::function
Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象),
当 Lambda 表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递,例如:
1 |
|
上面的代码给出了两种不同的调用形式,一种是将 Lambda 作为函数类型传递进行调用,
而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型,
统一称之为可调用类型。而这种类型,便是通过 std::function
引入的。
C++11 std::function
是一种通用、多态的函数封装,
它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作,
它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的),
换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。
例如:
1 |
|
std::bind
和 std::placeholder
而 std::bind
则是用来绑定函数调用的参数的,
它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数,
我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。
例如:
1 | int foo(int a, int b, int c) { |
提示:注意
auto
关键字的妙用。有时候我们可能不太熟悉一个函数的返回值类型,
但是我们却可以通过auto
的使用来规避这一问题的出现。
3.3 右值引用
右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题,
消除了诸如 std::vector
、std::string
之类的额外开销,
也才使得函数对象容器 std::function
成为了可能。
左值、右值的纯右值、将亡值、右值
要弄明白右值引用到底是怎么一回事,必须要对左值和右值做一个明确的理解。
左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说,
左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。
而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。
纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10
, true
;
要么是求值结果相当于字面量或匿名临时对象,例如 1+2
。非引用返回的临时变量、运算表达式产生的临时变量、
原始字面量、Lambda 表达式都属于纯右值。
需要注意的是,字面量除了字符串字面量以外,均为纯右值。而字符串字面量是一个左值,类型为 const char
数组。例如:
1 |
|
但是注意,数组可以被隐式转换成相对应的指针类型,而转换表达式的结果(如果不是左值引用)则一定是个右值(右值引用为将亡值,否则为纯右值)。例如:
1 | const char* p = "01234"; // 正确,"01234" 被隐式转换为 const char* |
将亡值(xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++ 中,
纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。
将亡值可能稍有些难以理解,我们来看这样的代码:
1 | std::vector<int> foo() { |
在这样的代码中,就传统的理解而言,函数 foo
的返回值 temp
在内部创建然后被赋值给 v
,
然而 v
获得这个对象时,会将整个 temp
拷贝一份,然后把 temp
销毁,如果这个 temp
非常大,
这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v
是左值、foo()
返回的值就是右值(也是纯右值)。但是,v
可以被别的变量捕获到,
而 foo()
产生的那个返回值作为一个临时值,一旦被 v
复制后,将立即被销毁,无法获取、也不能修改。
而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。
在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp
会被进行此隐式右值转换,
等价于 static_cast<std::vector<int> &&>(temp)
,进而此处的 v
会将 foo
局部返回的值进行移动。
也就是后面我们将会提到的移动语义。
右值引用和左值引用
要拿到一个将亡值,就需要用到右值引用:T &&
,其中 T
是类型。
右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。
C++11 提供了 std::move
这个方法将左值参数无条件的转换为右值,
有了它我们就能够方便的获得一个右值临时对象,例如:
1 |
|
rv2
虽然引用了一个右值,但由于它是一个引用,所以 rv2
依然是一个左值。
注意,这里有一个很有趣的历史遗留问题,我们先看下面的代码:
1 |
|
第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:
1 | void increase(int & v) { |
由于 int&
不能引用 double
类型的参数,因此必须产生一个临时值来保存 s
的值,
从而当 increase()
修改这个临时值时,调用完成后 s
本身并没有被修改。
第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要。
移动语义
传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,
调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。
试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、
再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。
传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。
右值引用的出现恰好就解决了这两个概念的混淆问题,例如:
1 |
|
在上面的代码中:
- 首先会在
return_rvalue
内部构造两个A
对象,于是获得两个构造函数的输出; - 函数返回后,产生一个将亡值,被
A
的移动构造(A(A&&)
)引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了obj
中,而将亡值的指针被设置为nullptr
,防止了这块内存区域被销毁。
从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:
1 |
|
完美转发
前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:
1 | void reference(int& v) { |
对于 pass(1)
来说,虽然传递的是右值,但由于 v
是一个引用,所以同时也是左值。
因此 reference(v)
会调用 reference(int&)
,输出『左值』。
而对于pass(l)
而言,l
是一个左值,为什么会成功传递给 pass(T&&)
呢?
这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,
但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,
既能左引用,又能右引用。但是却遵循如下规则:
函数形参类型 | 实参参数类型 | 推导后函数形参类型 |
---|---|---|
T& | 左引用 | T& |
T& | 右引用 | T& |
T&& | 左引用 | T& |
T&& | 右引用 | T&& |
因此,模板函数中使用 T&&
不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。
更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。
这才使得 v
作为左值的成功传递。
完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,
保持原来的参数类型(左引用保持左引用,右引用保持右引用)。
为了解决这个问题,我们应该使用 std::forward
来进行参数的转发(传递):
1 |
|
输出结果为:
1 | 传递右值: |
无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发,
所以 std::move
总会接受到一个左值,从而转发调用了reference(int&&)
输出右值引用。
唯独 std::forward
即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。
std::forward
和 std::move
一样,没有做任何事情,std::move
单纯的将左值转化为右值,std::forward
也只是单纯的将参数做了一个类型的转换,从现象上来看,std::forward<T>(v)
和 static_cast<T&&>(v)
是完全一样的。
读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值,
我们再简单看一看 std::forward
的具体实现机制,std::forward
包含两个重载:
1 | template<typename _Tp> |
在这份实现中,std::remove_reference
的功能是消除类型中的引用,std::is_lvalue_reference
则用于检查类型推导是否正确,在 std::forward
的第二个实现中
检查了接收到的值确实是一个左值,进而体现了坍缩规则。
当 std::forward
接受左值时,_Tp
被推导为左值,所以返回值为左值;而当其接受右值时,_Tp
被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + &&
的右值。
可见 std::forward
的原理在于巧妙的利用了模板类型推导中产生的差异。
这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&&
是最安全的方式?
因为当 auto
被推导为不同的左右引用时,与 &&
的坍缩组合是完美转发。
总结
本章介绍了现代 C++ 中最为重要的几个语言运行时的增强,其中笔者认为本节中提到的所有特性都是值得掌握的:
- Lambda 表达式
- 函数对象容器 std::function
- 右值引用
第 4 章 容器
4.1 线性容器
std::array
看到这个容器的时候肯定会出现这样的问题:
- 为什么要引入
std::array
而不是直接使用std::vector
? - 已经有了传统数组,为什么要用
std::array
?
先回答第一个问题,与 std::vector
不同,std::array
对象的大小是固定的,如果容器大小是固定的,那么可以优先考虑使用 std::array
容器。
另外由于 std::vector
是自动扩容的,当存入大量的数据后,并且对容器进行了删除操作,
容器并不会自动归还被删除元素相应的内存,这时候就需要手动运行 shrink_to_fit()
释放这部分内存。
1 | std::vector<int> v; |
而第二个问题就更加简单,使用 std::array
能够让代码变得更加“现代化”,而且封装了一些操作函数,比如获取数组大小以及检查是否非空,同时还能够友好的使用标准库中的容器算法,比如 std::sort
。
使用 std::array
很简单,只需指定其类型和大小即可:
1 | std::array<int, 4> arr = {1, 2, 3, 4}; |
当我们开始用上了 std::array
时,难免会遇到要将其兼容 C 风格的接口,这里有三种做法:
1 | void foo(int *p, int len) { |
std::forward_list
std::forward_list
是一个列表容器,使用方法和 std::list
基本类似,因此我们就不花费篇幅进行介绍了。
需要知道的是,和 std::list
的双向链表的实现不同,std::forward_list
使用单向链表进行实现,
提供了 O(1)
复杂度的元素插入,不支持快速随机访问(这也是链表的特点),
也是标准库容器中唯一一个不提供 size()
方法的容器。当不需要双向迭代时,具有比 std::list
更高的空间利用率。
4.2 无序容器
我们已经熟知了传统 C++ 中的有序容器 std::map
/std::set
,这些元素内部通过红黑树进行实现,
插入和搜索的平均复杂度均为 O(log(size))
。在插入元素时候,会根据 <
操作符比较元素大小并判断元素是否相同,
并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 <
操作符的顺序来逐个遍历。
而无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant)
,
在不关心容器内部元素顺序时,能够获得显著的性能提升。
C++11 引入了的两组无序容器分别是:std::unordered_map
/std::unordered_multimap
和std::unordered_set
/std::unordered_multiset
。
它们的用法和原有的 std::map
/std::multimap
/std::set
/set::multiset
基本类似,
由于这些容器我们已经很熟悉了,便不一一举例,我们直接来比较一下std::map
和std::unordered_map
:
1 |
|
最终的输出结果为:
1 | std::unordered_map |
4.3 元组
了解过 Python 的程序员应该知道元组的概念,纵观传统 C++ 中的容器,除了 std::pair
外,
似乎没有现成的结构能够用来存放不同类型的数据(通常我们会自己定义结构)。
但 std::pair
的缺陷是显而易见的,只能保存两个元素。
元组基本操作
关于元组的使用有三个核心的函数:
std::make_tuple
: 构造元组std::get
: 获得元组某个位置的值std::tie
: 元组拆包
1 |
|
std::get
除了使用常量获取元组对象外,C++14 增加了使用类型来获取元组中的对象:
1 | std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8); |
运行期索引
如果你仔细思考一下可能就会发现上面代码的问题,std::get<>
依赖一个编译期的常量,所以下面的方式是不合法的:
1 | int index = 1; |
那么要怎么处理?答案是,使用 std::variant<>
(C++ 17 引入),提供给 variant<>
的类型模板参数
可以让一个 variant<>
从而容纳提供的几种类型的变量(在其他语言,例如 Python/JavaScript 等,表现为动态类型):
1 |
|
这样我们就能:
1 | int i = 1; |
元组合并与遍历
还有一个常见的需求就是合并两个元组,这可以通过 std::tuple_cat
来实现:
1 | auto new_tuple = std::tuple_cat(get_student(1), std::move(t)); |
马上就能够发现,应该如何快速遍历一个元组?但是我们刚才介绍了如何在运行期通过非常数索引一个 tuple
那么遍历就变得简单了,
首先我们需要知道一个元组的长度,可以:
1 | template <typename T> |
这样就能够对元组进行迭代了:
1 | // 迭代 |
总结
本章简单介绍了现代 C++ 中新增的容器,它们的用法和传统 C++ 中已有的容器类似,相对简单,可以根据实际场景丰富的选择需要使用的容器,从而获得更好的性能。
std::tuple
虽然有效,但是标准库提供的功能有限,没办法满足运行期索引和迭代的需求,好在我们还有其他的方法可以自行实现。
第 5 章 智能指针与内存管理
5.1 RAII 与引用计数
了解 Objective-C
/Swift
的程序员应该知道引用计数的概念。引用计数这种计数是为了防止内存泄露而产生的。
基本想法是对于动态分配的对象,进行引用计数,每当增加一次对同一个对象的引用,那么引用对象的引用计数就会增加一次,
每删除一次引用,引用计数就会减一,当一个对象的引用计数减为零时,就自动删除指向的堆内存。
在传统 C++ 中,『记得』手动释放资源,总不是最佳实践。因为我们很有可能就忘记了去释放资源而导致泄露。
所以通常的做法是对于一个对象而言,我们在构造函数的时候申请空间,而在析构函数(在离开作用域时调用)的时候释放空间,
也就是我们常说的 RAII 资源获取即初始化技术。
凡事都有例外,我们总会有需要将对象在自由存储上分配的需求,在传统 C++ 里我们只好使用 new
和 delete
去
『记得』对资源进行释放。而 C++11 引入了智能指针的概念,使用了引用计数的想法,让程序员不再需要关心手动释放内存。
这些智能指针包括 std::shared_ptr
/std::unique_ptr
/std::weak_ptr
,使用它们需要包含头文件 <memory>
。
注意:引用计数不是垃圾回收,引用计数能够尽快收回不再被使用的对象,同时在回收的过程中也不会造成长时间的等待,
更能够清晰明确的表明资源的生命周期。
5.2 std::shared_ptr
std::shared_ptr
是一种智能指针,它能够记录多少个 shared_ptr
共同指向一个对象,从而消除显式的调用delete
,当引用计数变为零的时候就会将对象自动删除。
但还不够,因为使用 std::shared_ptr
仍然需要使用 new
来调用,这使得代码出现了某种程度上的不对称。
std::make_shared
就能够用来消除显式的使用 new
,所以std::make_shared
会分配创建传入参数中的对象,
并返回这个对象类型的std::shared_ptr
指针。例如:
1 |
|
std::shared_ptr
可以通过 get()
方法来获取原始指针,通过 reset()
来减少一个引用计数,
并通过use_count()
来查看一个对象的引用计数。例如:
1 | auto pointer = std::make_shared<int>(10); |
5.3 std::unique_ptr
std::unique_ptr
是一种独占的智能指针,它禁止其他智能指针与其共享同一个对象,从而保证代码的安全:
1 | std::unique_ptr<int> pointer = std::make_unique<int>(10); // make_unique 从 C++14 引入 |
make_unique
并不复杂,C++11 没有提供std::make_unique
,可以自行实现:
1
2
3
4 template<typename T, typename ...Args>
std::unique_ptr<T> make_unique( Args&& ...args ) {
return std::unique_ptr<T>( new T( std::forward<Args>(args)... ) );
}至于为什么没有提供,C++ 标准委员会主席 Herb Sutter 在他的博客中提到原因是因为『被他们忘记了』。
既然是独占,换句话说就是不可复制。但是,我们可以利用 std::move
将其转移给其他的 unique_ptr
,例如:
1 |
|
5.4 std::weak_ptr
如果你仔细思考 std::shared_ptr
就会发现依然存在着资源无法释放的问题。看下面这个例子:
1 | struct A; |
运行结果是 A, B 都不会被销毁,这是因为 a,b 内部的 pointer 同时又引用了 a,b
,这使得 a,b
的引用计数均变为了 2,而离开作用域时,a,b
智能指针被析构,却只能造成这块区域的引用计数减一,这样就导致了 a,b
对象指向的内存区域引用计数不为零,而外部已经没有办法找到这块区域了,也就造成了内存泄露,如图 5.1:
解决这个问题的办法就是使用弱引用指针 std::weak_ptr
,std::weak_ptr
是一种弱引用(相比较而言 std::shared_ptr
就是一种强引用)。弱引用不会引起引用计数增加,当换用弱引用时候,最终的释放流程如图 5.2 所示:
在上图中,最后一步只剩下 B,而 B 并没有任何智能指针引用它,因此这块内存资源也会被释放。
std::weak_ptr
没有 *
运算符和 ->
运算符,所以不能够对资源进行操作,它可以用于检查 std::shared_ptr
是否存在,其 expired()
方法能在资源未被释放时,会返回 false
,否则返回 true
;除此之外,它也可以用于获取指向原始对象的 std::shared_ptr
指针,其 lock()
方法在原始对象未被释放时,返回一个指向原始对象的 std::shared_ptr
指针,进而访问原始对象的资源,否则返回nullptr
。
总结
智能指针这种技术并不新奇,在很多语言中都是一种常见的技术,现代 C++ 将这项技术引进,在一定程度上消除了 new
/delete
的滥用,是一种更加成熟的编程范式。
第 6 章 正则表达式
6.1 正则表达式简介
正则表达式不是 C++ 语言的一部分,这里仅做简单的介绍。
正则表达式描述了一种字符串匹配的模式。一般使用正则表达式主要是实现下面三个需求:
- 检查一个串是否包含某种形式的子串;
- 将匹配的子串替换;
- 从某个串中取出符合条件的子串。
正则表达式是由普通字符(例如 a 到 z)以及特殊字符组成的文字模式。模式描述在搜索文本时要匹配的一个或多个字符串。
正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。
普通字符
普通字符包括没有显式指定为元字符的所有可打印和不可打印字符。这包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。
特殊字符
特殊字符是正则表达式里有特殊含义的字符,也是正则表达式的核心匹配语法。参见下表:
特别字符 | 描述 | |
---|---|---|
$ |
匹配输入字符串的结尾位置。 | |
( ,) |
标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。 | |
* |
匹配前面的子表达式零次或多次。 | |
+ |
匹配前面的子表达式一次或多次。 | |
. |
匹配除换行符 \n 之外的任何单字符。 |
|
[ |
标记一个中括号表达式的开始。 | |
? |
匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。 | |
\ |
将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如, n 匹配字符 n 。\n 匹配换行符。序列 \\ 匹配 '\' 字符,而 \( 则匹配 '(' 字符。 |
|
^ |
匹配输入字符串的开始位置,除非在方括号表达式中使用,此时它表示不接受该字符集合。 | |
{ |
标记限定符表达式的开始。 | |
`\ | ` | 指明两项之间的一个选择。 |
限定符
限定符用来指定正则表达式的一个给定的组件必须要出现多少次才能满足匹配。见下表:
字符 | 描述 |
---|---|
* |
匹配前面的子表达式零次或多次。例如,foo* 能匹配 fo 以及 foooo 。* 等价于{0,} 。 |
+ |
匹配前面的子表达式一次或多次。例如,foo+ 能匹配 foo 以及 foooo ,但不能匹配 fo 。+ 等价于 {1,} 。 |
? |
匹配前面的子表达式零次或一次。例如,Your(s)? 可以匹配 Your 或 Yours 中的Your 。? 等价于 {0,1} 。 |
{n} |
n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 for 中的 o ,但是能匹配 foo 中的两个 o 。 |
{n,} |
n 是一个非负整数。至少匹配 n 次。例如,o{2,} 不能匹配 for 中的 o ,但能匹配 foooooo 中的所有 o 。o{1,} 等价于 o+ 。o{0,} 则等价于 o* 。 |
{n,m} |
m 和 n 均为非负整数,其中 n 小于等于 m 。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 foooooo 中的前三个 o 。o{0,1} 等价于 o? 。注意,在逗号和两个数之间不能有空格。 |
有了这两张表,我们通常就能够读懂几乎所有的正则表达式了。
6.2 std::regex 及其相关
对字符串内容进行匹配的最常见手段就是使用正则表达式。
可惜在传统 C++ 中正则表达式一直没有得到语言层面的支持,没有纳入标准库,
而 C++ 作为一门高性能语言,在后台服务的开发中,对 URL 资源链接进行判断时,
使用正则表达式也是工业界最为成熟的普遍做法。
一般的解决方案就是使用 boost
的正则表达式库。
而 C++11 正式将正则表达式的的处理方法纳入标准库的行列,从语言级上提供了标准的支持,
不再依赖第三方。
C++11 提供的正则表达式库操作 std::string
对象,
模式 std::regex
(本质是 std::basic_regex
)进行初始化,
通过 std::regex_match
进行匹配,
从而产生 std::smatch
(本质是 std::match_results
对象)。
我们通过一个简单的例子来简单介绍这个库的使用。考虑下面的正则表达式:
[a-z]+\.txt
: 在这个正则表达式中,[a-z]
表示匹配一个小写字母,+
可以使前面的表达式匹配多次,
因此[a-z]+
能够匹配一个小写字母组成的字符串。
在正则表达式中一个.
表示匹配任意字符,而\.
则表示匹配字符.
,
最后的txt
表示严格匹配txt
则三个字母。因此这个正则表达式的所要匹配的内容就是由纯小写字母组成的文本文件。
std::regex_match
用于匹配字符串和正则表达式,有很多不同的重载形式。
最简单的一个形式就是传入 std::string
以及一个 std::regex
进行匹配,
当匹配成功时,会返回 true
,否则返回 false
。例如:
1 |
|
另一种常用的形式就是依次传入 std::string
/std::smatch
/std::regex
三个参数,
其中 std::smatch
的本质其实是 std::match_results
。
故而在标准库的实现中, std::smatch
被定义为了 std::match_results<std::string::const_iterator>
,
也就是一个子串迭代器类型的 match_results
。
使用 std::smatch
可以方便的对匹配的结果进行获取,例如:
1 | std::regex base_regex("([a-z]+)\\.txt"); |
以上两个代码段的输出结果为:
1 | foo.txt: 1 |
总结
本节简单介绍了正则表达式本身,然后根据使用正则表达式的主要需求,通过一个实际的例子介绍了正则表达式库的使用。
第 7 章 并行与并发
7.1 并行基础
std::thread
用于创建一个执行的线程实例,所以它是一切并发编程的基础,使用时需要包含 <thread>
头文件,
它提供了很多基本的线程操作,例如 get_id()
来获取所创建线程的线程 ID,使用 join()
来加入一个线程等等,例如:
1 |
|
7.2 互斥量与临界区
我们在操作系统、亦或是数据库的相关知识中已经了解过了有关并发技术的基本知识,mutex
就是其中的核心之一。
C++11 引入了 mutex
相关的类,其所有相关的函数都放在 <mutex>
头文件中。
std::mutex
是 C++11 中最基本的 mutex
类,通过实例化 std::mutex
可以创建互斥量,
而通过其成员函数 lock()
可以进行上锁,unlock()
可以进行解锁。
但是在实际编写代码的过程中,最好不去直接调用成员函数,
因为调用成员函数就需要在每个临界区的出口处调用 unlock()
,当然,还包括异常。
这时候 C++11 还为互斥量提供了一个 RAII 语法的模板类 std::lock_guard
。
RAII 在不失代码简洁性的同时,很好的保证了代码的异常安全性。
在 RAII 用法下,对于临界区的互斥量的创建只需要在作用域的开始部分,例如:
1 |
|
由于 C++ 保证了所有栈对象在生命周期结束时会被销毁,所以这样的代码也是异常安全的。
无论 critical_section()
正常返回、还是在中途抛出异常,都会引发堆栈回退,也就自动调用了 unlock()
。
而 std::unique_lock
则是相对于 std::lock_guard
出现的,std::unique_lock
更加灵活,std::unique_lock
的对象会以独占所有权(没有其他的 unique_lock
对象同时拥有某个 mutex
对象的所有权)
的方式管理 mutex
对象上的上锁和解锁的操作。所以在并发编程中,推荐使用 std::unique_lock
。
std::lock_guard
不能显式的调用 lock
和 unlock
, 而 std::unique_lock
可以在声明后的任意位置调用,
可以缩小锁的作用范围,提供更高的并发度。
如果你用到了条件变量 std::condition_variable::wait
则必须使用 std::unique_lock
作为参数。
例如:
1 |
|
7.3 期物
期物(Future)表现为 std::future
,它提供了一个访问异步操作结果的途径,这句话很不好理解。
为了理解这个特性,我们需要先理解一下在 C++11 之前的多线程行为。
试想,如果我们的主线程 A 希望新开辟一个线程 B 去执行某个我们预期的任务,并返回我一个结果。
而这时候,线程 A 可能正在忙其他的事情,无暇顾及 B 的结果,
所以我们会很自然的希望能够在某个特定的时间获得线程 B 的结果。
在 C++11 的 std::future
被引入之前,通常的做法是:
创建一个线程 A,在线程 A 里启动任务 B,当准备完毕后发送一个事件,并将结果保存在全局变量中。
而主函数线程 A 里正在做其他的事情,当需要结果的时候,调用一个线程等待函数来获得执行的结果。
而 C++11 提供的 std::future
简化了这个流程,可以用来获取异步任务的结果。
自然地,我们很容易能够想象到把它作为一种简单的线程同步手段,即屏障(barrier)。
为了看一个例子,我们这里额外使用 std::packaged_task
,它可以用来封装任何可以调用的目标,从而用于实现异步的调用。
举例来说:
1 |
|
在封装好要调用的目标后,可以使用 get_future()
来获得一个 std::future
对象,以便之后实施线程同步。
7.4 条件变量
条件变量 std::condition_variable
是为了解决死锁而生,当互斥操作不够用而引入的。
比如,线程可能需要等待某个条件为真才能继续执行,
而一个忙等待循环中可能会导致所有其他线程都无法进入临界区使得条件为真时,就会发生死锁。
所以,condition_variable
实例被创建出现主要就是用于唤醒等待线程从而避免死锁。std::condition_variable
的 notify_one()
用于唤醒一个线程;notify_all()
则是通知所有线程。下面是一个生产者和消费者模型的例子:
1 |
|
值得一提的是,在生产者中我们虽然可以使用 notify_one()
,但实际上并不建议在此处使用,
因为在多消费者的情况下,我们的消费者实现中简单放弃了锁的持有,这使得可能让其他消费者
争夺此锁,从而更好的利用多个消费者之间的并发。话虽如此,但实际上因为 std::mutex
的排他性,
我们根本无法期待多个消费者能真正意义上的并行消费队列的中生产的内容,我们仍需要粒度更细的手段。
7.5 原子操作与内存模型
细心的读者可能会对前一小节中生产者消费者模型的例子可能存在编译器优化导致程序出错的情况产生疑惑。
例如,布尔值 notified
没有被 volatile
修饰,编译器可能对此变量存在优化,例如将其作为一个寄存器的值,
从而导致消费者线程永远无法观察到此值的变化。这是一个好问题,为了解释清楚这个问题,我们需要进一步讨论
从 C++ 11 起引入的内存模型这一概念。我们首先来看一个问题,下面这段代码输出结果是多少?
1 |
|
从直观上看,t2
中 a = 5;
这一条语句似乎总在 flag = 1;
之前得到执行,而 t1
中 while (flag != 1)
似乎保证了 std::cout << "b = " << b << std::endl;
不会再标记被改变前执行。从逻辑上看,似乎 b
的值应该等于 5。
但实际情况远比此复杂得多,或者说这段代码本身属于未定义的行为,因为对于 a
和 flag
而言,他们在两个并行的线程中被读写,
出现了竞争。除此之外,即便我们忽略竞争读写,仍然可能受 CPU 的乱序执行,编译器对指令的重排的影响,
导致 a = 5
发生在 flag = 1
之后。从而 b
可能输出 0。
原子操作
std::mutex
可以解决上面出现的并发读写的问题,但互斥锁是操作系统级的功能,
这是因为一个互斥锁的实现通常包含两条基本原理:
- 提供线程间自动的状态转换,即『锁住』这个状态
- 保障在互斥锁操作期间,所操作变量的内存与临界区外进行隔离
这是一组非常强的同步条件,换句话说当最终编译为 CPU 指令时会表现为非常多的指令(我们之后再来看如何实现一个简单的互斥锁)。
这对于一个仅需原子级操作(没有中间态)的变量,似乎太苛刻了。
关于同步条件的研究有着非常久远的历史,我们在这里不进行赘述。读者应该明白,现代 CPU 体系结构提供了 CPU 指令级的原子操作,
因此在 C++11 中多线程下共享变量的读写这一问题上,还引入了 std::atomic
模板,使得我们实例化一个原子类型,将一个
原子类型读写操作从一组指令,最小化到单个 CPU 指令。例如:
1 | std::atomic<int> counter; |
并为整数或浮点数的原子类型提供了基本的数值成员函数,举例来说,
包括 fetch_add
, fetch_sub
等,同时通过重载方便的提供了对应的 +
,-
版本。
比如下面的例子:
1 |
|
当然,并非所有的类型都能提供原子操作,这是因为原子操作的可行性取决于具体的 CPU 架构,以及所实例化的类型结构是否能够满足该 CPU 架构对内存对齐
条件的要求,因而我们总是可以通过 std::atomic<T>::is_lock_free
来检查该原子类型是否需支持原子操作,例如:
1 |
|
一致性模型
并行执行的多个线程,从某种宏观层面上讨论,可以粗略的视为一种分布式系统。
在分布式系统中,任何通信乃至本地操作都需要消耗一定时间,甚至出现不可靠的通信。
如果我们强行将一个变量 v
在多个线程之间的操作设为原子操作,即任何一个线程在操作完 v
后,
其他线程均能同步感知到 v
的变化,则对于变量 v
而言,表现为顺序执行的程序,它并没有由于引入多线程
而得到任何效率上的收益。对此有什么办法能够适当的加速呢?答案便是削弱原子操作的在进程间的同步条件。
从原理上看,每个线程可以对应为一个集群节点,而线程间的通信也几乎等价于集群节点间的通信。
削弱进程间的同步条件,通常我们会考虑四种不同的一致性模型:
线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。
1
2
3
4
5
6x.store(1) x.load()
T1 ---------+----------------+------>
T2 -------------------+------------->
x.store(2)在这种情况下线程
T1
,T2
对x
的两次写操作是原子的,且x.store(1)
是严格的发生在x.store(2)
之前,x.store(2)
严格的发生在x.load()
之前。
值得一提的是,线性一致性对全局时钟的要求是难以实现的,这也是人们不断研究比这个一致性更弱条件下其他一致性的算法的原因。顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15x.store(1) x.store(3) x.load()
T1 ---------+-----------+----------+----->
T2 ---------------+---------------------->
x.store(2)
或者
x.store(1) x.store(3) x.load()
T1 ---------+-----------+----------+----->
T2 ------+------------------------------->
x.store(2)在顺序一致性的要求下,
x.load()
必须读到最近一次写入的数据,因此x.store(2)
与x.store(1)
并无任何先后保障,即 只要T2
的x.store(2)
发生在x.store(3)
之前即可。因果一致性:它的要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24a = 1 b = 2
T1 ----+-----------+---------------------------->
T2 ------+--------------------+--------+-------->
x.store(3) c = a + b y.load()
或者
a = 1 b = 2
T1 ----+-----------+---------------------------->
T2 ------+--------------------+--------+-------->
x.store(3) y.load() c = a + b
亦或者
b = 2 a = 1
T1 ----+-----------+---------------------------->
T2 ------+--------------------+--------+-------->
y.load() c = a + b x.store(3)上面给出的三种例子都是属于因果一致的,因为整个过程中,只有
c
对a
和b
产生依赖,而x
和y
在此例子中表现为没有关系(但实际情况中我们需要更详细的信息才能确定x
与y
确实无关)最终一致性:是最弱的一致性要求,它只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。因此我们甚至可以对此条件稍作加强,例如规定某个操作被观察到的时间总是有界的。当然这已经不在我们的讨论范围之内了。
1
2
3
4
5
6x.store(3) x.store(4)
T1 ----+-----------+-------------------------------------------->
T2 ---------+------------+--------------------+--------+-------->
x.read x.read() x.read() x.read()在上面的情况中,如果我们假设 x 的初始值为 0,则
T2
中四次x.read()
结果可能但不限于以下情况:1
2
3
4
53 4 4 4 // x 的写操作被很快观察到
0 3 3 4 // x 的写操作被观察到的时间存在一定延迟
0 0 0 4 // 最后一次读操作读到了 x 的最终值,但此前的变化并未观察到
0 0 0 0 // 在当前时间段内 x 的写操作均未被观察到,
// 但未来某个时间点上一定能观察到 x 为 4 的情况
内存顺序
为了追求极致的性能,实现各种强度要求的一致性,C++11 为原子操作定义了六种不同的内存顺序 std::memory_order
的选项,表达了四种多线程间的同步模型:
宽松模型:在此模型下,单个线程内的原子操作都是顺序执行的,不允许指令重排,但不同线程间原子操作的顺序是任意的。类型通过
std::memory_order_relaxed
指定。我们来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_relaxed);
});
}
for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;释放/消费模型:在此模型中,我们开始限制进程间的操作顺序,如果某个线程需要修改某个值,但另一个线程会对该值的某次操作产生依赖,即后者依赖前者。具体而言,线程 A 完成了三次对
x
的写操作,线程B
仅依赖其中第三次x
的写操作,与x
的前两次写行为无关,则当A
主动x.release()
时候(即使用std::memory_order_release
),选项std::memory_order_consume
能够确保B
在调用x.load()
时候观察到A
中第三次对x
的写操作。我们来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 初始化为 nullptr 防止 consumer 线程从野指针进行读取
std::atomic<int*> ptr(nullptr);
int v;
std::thread producer([&]() {
int* p = new int(42);
v = 1024;
ptr.store(p, std::memory_order_release);
});
std::thread consumer([&]() {
int* p;
while(!(p = ptr.load(std::memory_order_consume)));
std::cout << "p: " << *p << std::endl;
std::cout << "v: " << v << std::endl;
});
producer.join();
consumer.join();释放/获取模型:在此模型下,我们可以进一步加紧对不同线程间原子操作的顺序的限制,在释放
std::memory_order_release
和获取std::memory_order_acquire
之间规定时序,即发生在释放(release)操作之前的所有写操作,对其他线程的任何获取(acquire)操作都是可见的,亦即发生顺序(happens-before)。可以看到,
std::memory_order_release
确保了它之前的写操作不会发生在释放操作之后,是一个向后的屏障(backward),而std::memory_order_acquire
确保了它之前的写行为不会发生在该获取操作之后,是一个向前的屏障(forward)。对于选项std::memory_order_acq_rel
而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后:我们来看一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20std::vector<int> v;
std::atomic<int> flag = {0};
std::thread release([&]() {
v.push_back(42);
flag.store(1, std::memory_order_release);
});
std::thread acqrel([&]() {
int expected = 1; // must before compare_exchange_strong
while(!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel))
expected = 1; // must after compare_exchange_strong
// flag has changed to 2
});
std::thread acquire([&]() {
while(flag.load(std::memory_order_acquire) < 2);
std::cout << v.at(0) << std::endl; // must be 42
});
release.join();
acqrel.join();
acquire.join();在此例中我们使用了
compare_exchange_strong
比较交换原语(Compare-and-swap primitive),它有一个更弱的版本,即compare_exchange_weak
,它允许即便交换成功,也仍然返回false
失败。其原因是因为在某些平台上虚假故障导致的,具体而言,当 CPU 进行上下文切换时,另一线程加载同一地址产生的不一致。除此之外,compare_exchange_strong
的性能可能稍差于compare_exchange_weak
,但大部分情况下,鉴于其使用的复杂度而言,compare_exchange_weak
应该被有限考虑。顺序一致模型:在此模型下,原子操作满足顺序一致性,进而可能对性能产生损耗。可显式的通过
std::memory_order_seq_cst
进行指定。最后来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_seq_cst);
});
}
for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;这个例子与第一个宽松模型的例子本质上没有区别,仅仅只是将原子操作的内存顺序修改为了
memory_order_seq_cst
,有兴趣的读者可以自行编写程序测量这两种不同内存顺序导致的性能差异。
总结
C++11 语言层提供了并发编程的相关支持,本节简单的介绍了 std::thread
, std::mutex
, std::future
这些并发编程中不可回避的重要工具。
除此之外,我们还介绍了 C++11 最重要的几个特性之一的『内存模型』,
它们为 C++ 在标准化高性能计算中提供了重要的基础。