现代C++实战30讲
01丨基础篇
01 | 堆、栈、RAII:C++里该如何管理资源?
1.1 基本概念
堆,英文是 heap,在内存管理的语境下,指的是动态分配内存的区域。
C++ 标准里一个相关概念是自由存储区,英文是 free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集:
- new 和 delete 操作的区域是 free store
- malloc 和 free 操作的区域是 heap
但 new 和 delete 通常底层使用 malloc 和 free 来实现,所以 free store 也是 heap。鉴于对其区分的实际意义并不大,在本专栏里,除非另有特殊说明,我会只使用堆这一术语。
栈,英文是 stack,在内存管理的语境下,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈和数据结构里的栈高度相似,都满足”后进先出”(last-in-first-out 或 LIFO)。
RAII,完整的英文是 Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII,但主流的编程语言中, C++ 是唯一一个依赖 RAII 来做资源管理的。
1.2 堆
1.3 栈
编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用,还有一个专门的术语,叫栈展开(stack unwinding)。
1.4 RAII
1.5 参考资料
- Wikipedia, “Memory management”. https://en.wikipedia.org/wiki/Memory_management
- Wikipedia, “Stack-based memory allocation”. https://en.wikipedia.org/wiki/Stack-based_memory_allocation
- Wikipedia, “Resource acquisition is initialization”. https://en.wikipedia.org/wiki/RAII
- Wikipedia, “Call stack”. https://en.wikipedia.org/wiki/Call_stack
- Wikipedia, “Object slicing”. https://en.wikipedia.org/wiki/Object_slicing
- Stack Overflow, “Why does the stack address grow towards decreasing memory addresses?” https://stackoverflow.com/questions/4560720/why-doesthe-stack-address-grow-towards-decreasing-memory-addresses
02 | 自己动手,实现C++的智能指针
2.1 回顾
上一讲给出了下面这个类:
1 | class shape_wrapper { |
这个类可以完成智能指针的最基本的功能:对超出作用域的对象进行释放。但它缺了点东西:
- 这个类只适用于 shape 类
- 该类对象的行为不够像指针
- 拷贝该类对象会引发程序行为异常
下面我们来逐一看一下怎么弥补这些问题。
2.2 模板化和易用性
要让这个类能够包装任意类型的指针,我们需要把它变成一个类模板。
1 |
|
2.3 拷贝构造和赋值
试试在拷贝时转移指针的所有权?大致实现如下:
1 | template <typename T> |
在拷贝构造函数中,通过调用 other 的 release 方法来释放它对指针的所有权。在赋值函数中,则通过拷贝构造产生一个临时对象并调用 swap 来交换对指针的所有权。实现上是不复杂的。
如果你学到的赋值函数还有一个类似于 if (this != &rhs) 的判断的话,那种用法更啰嗦,而且异常安全性不够好——如果在赋值过程中发生异常的话,this 对象的内容可能已经被部分破坏了,对象不再处于一个完整的状态。
目前这种惯用法(见参考资料 [1])则保证了强异常安全性:赋值分为拷贝构造和交换两步,异常只可能在第一步发生;而第一步如果发生异常的话,this 对象完全不受任何影响。无论拷贝构造成功与否,结果只有赋值成功和赋值没有效果两种状态,而不会发生因为赋值破坏了当前对象这种场景。
上面实现的最大问题是,它的行为会让程序员非常容易犯错。一不小心把它传递给另外一个 smart_ptr,你就不再拥有这个对象了……
2.4 “移动”指针?
在下一讲我们将完整介绍一下移动语义。这一讲,我们先简单看一下 smart_ptr 可以如何使用”移动”来改善其行为。
1 | template <typename T> |
改了两个地方:
- 把拷贝构造函数中的参数类型 smart_ptr& 改成了 smart_ptr&&;现在它成了移动构造函数。
- 把赋值函数中的参数类型 smart_ptr& 改成了 smart_ptr,在构造参数时直接生成新的智能指针,从而不再需要在函数体中构造临时对象。现在赋值函数的行为是移动还是拷贝,完全依赖于构造参数时走的是移动构造还是拷贝构造。
根据 C++ 的规则,如果我提供了移动构造函数而没有手动提供拷贝构造函数,那后者自动被禁用(记住,C++ 里那些复杂的规则也是为方便编程而设立的)。于是,我们自然地得到了以下结果:
1 | smart_ptr<shape> ptr1{create_shape(shape_type::circle)}; |
这也是 C++11 的 unique_ptr 的基本行为。
补充上述内容
1 |
|
语句1:sp1 = sp2;
这条语句在上面的代码是存在问题的。因为sp2需要赋值变量给 smart_ptr &operator=(smart_ptr rhs)
函数中的参数,由于需要拷贝构造到内存一份副本,但是上述类并未有拷贝构造,所以报错。
解决方法:
- 添加
smart_ptr(smart_ptr &other)
函数 - 添加
smart_ptr &operator=(smart_ptr &rhs)
函数
语句2: sp1 = std::move(sp2);
因为需要传参,传参就需要拷贝,就会创建一份对象副本。所以先会调用移动构造创建一个对象,在进行对象的赋值。
2.5 子类指针向基类指针的转换
不知道你注意到没有,一个 circle*
是可以隐式转换成 shape*
的,但上面的 smart_ptr<circle>
却无法自动转换成 smart_ptr<shape>
。
只需要修改我们的移动构造函数一处即可。
1 | template <typename U> |
现在 smart_ptr<circle>
可以移动给 smart_ptr<shape>
,但不能移动给 smart_ptr<triangle>
。不正确的转换会在代码编译时直接报错。
至于非隐式的转换,因为本来就是要写特殊的转换函数的,我们留到这一讲的最后再讨论。
2.6 引用计数
unique_ptr 和 shared_ptr 的主要区别如下图所示:
先来写出共享计数的接口:
1 | class shared_count { |
由于真正多线程安全的版本需要用到我们目前还没学到的知识,我们目前先实现一个简单化的版本:
1 | class shared_count { |
现在我们可以实现我们的引用计数智能指针了。首先是构造函数、析构函数和私有成员变量:
1 | template <typename T> |
为了方便实现赋值(及其他一些惯用法),我们需要一个新的 swap 成员函数:
1 | void swap(smart_ptr& rhs) |
赋值函数可以跟前面一样,保持不变,但拷贝构造和移动构造函数是需要更新一下的:
1 | smart_ptr(const smart_ptr& other) noexcept |
不过,上面的代码有个问题:它不能正确编译。编译器会报错,像:
1 fatal error: 'ptr_' is a private member of 'smart_ptr<circle>'
错误原因是模板的各个实例间并不天然就有 friend 关系,因而不能互访私有成员 ptr_
和 shared_count_
。我们需要在 smart_ptr 的定义中显式声明:
1 | template <typename U> |
此外,我们之前的实现(类似于单一所有权的 unique_ptr )中用 release 来手工释放所有权。在目前的引用计数实现中,它就不太合适了,应当删除。但我们要加一个对调试非常有用的函数,返回引用计数值。定义如下:
1 | long use_count() const |
2.7 指针类型转换
对应于 C++ 里的不同的类型强制转换:
- static_cast
- reinterpret_cast
- const_cast
- dynamic_cast
智能指针需要实现类似的函数模板。实现本身并不复杂,但为了实现这些转换,我们需要添加构造函数,允许在对智能指针内部的指针对象赋值时,使用一个现有的智能指针的共享计数。如下所示:
1 | template <typename U> |
这样我们就可以实现转换所需的函数模板了。下面实现一个 dynamic_pointer_cast 来示例一下:
1 | template <typename T, typename U> |
2.8 代码列表
完整的 smart_ptr 代码:
1 |
|
如果你足够细心的话,你会发现我在代码里加了不少 noexcept。这对这个智能指针在它的目标场景能正确使用是十分必要的。
2.9 参考资料
- Stack Overflow, GManNickG’s answer to “What is the copy-and-swapidiom?”. https://stackoverflow.com/a/3279550/816999
- cppreference.com, “std::shared_ptr”. https://en.cppreference.com/w/cpp/memory/shared_ptr
03 | 右值和移动究竟解决了什么问题?
移动语义是 C++11 里引入的一个重要概念;理解这个概念,是理解很多现代 C++ 里的优化的基础。
3.1 值分左右
C++标准里规定了下面这些值类别(value categories):
先理解一下这些名词的字面含义:
- 一个 lvalue 是通常可以放在等号左边的表达式,左值
- 一个 rvalue 是通常只能放在等号右边的表达式,右值
- 一个 glvalue 是 generalized lvalue,广义左值
- 一个 xvalue 是 expiring lvalue,将亡值
- 一个 prvalue 是 pure rvalue,纯右值
暂且抛开这些概念,只看其中两个:lvalue 和 prvalue。
左值 lvalue 是有标识符、可以取地址的表达式,最常见的情况有:
- 变量、函数或数据成员的名字
- 返回左值引用的表达式,如 ++x、x = 1、cout << ‘ ‘
- 字符串字面量如 “hello world”
在函数调用时,左值可以绑定到左值引用的参数,如 T&。一个常量只能绑定到常左值引用,如 const T&。
反之,纯右值 prvalue 是没有标识符、不可以取地址的表达式,一般也称之为”临时对象”。最常见的情况有:
- 返回非引用类型的表达式,如 x++、x + 1、
make_shared<int>(42)
- 除字符串字面量之外的字面量,如 42、true
在 C++11 之前,右值可以绑定到常左值引用(const lvalue reference)的参数,如 const T&,但不可以绑定到非常左值引用(non-const lvalue reference),如 T&。从 C++11 开始,C++ 语言里多了一种引用类型——右值引用。右值引用的形式是 T&&,比左值引用多一个 & 符号。跟左值引用一样,我们可以使用 const 和 volatile 来进行修饰,但最常见的情况是,我们不会用 const 和 volatile 来修饰右值。本专栏就属于这种情况。
引入一种额外的引用类型当然增加了语言的复杂性,但也带来了很多优化的可能性。由于 C++ 有重载,我们就可以根据不同的引用类型,来选择不同的重载函数,来完成不同的行为。回想一下,在上一讲中,我们就利用了重载,让 smart_ptr 的构造函数可以有不同的行为:
1 | template <typename U> |
使用右值引用的第二个重载函数中的变量 other 算是左值还是右值呢?根据定义,other 是个变量的名字,变量有标识符、有地址,所以它还是一个左值——虽然它的类型是右值引用。
尤其重要的是,拿这个 other 去调用函数时,它匹配的也会是左值引用。也就是说,**类型是右值引用的变量是一个左值!**这点可能有点反直觉,但跟 C++ 的其他方面是一致的。毕竟对于一个右值引用的变量,你是可以取地址的,这点上它和左值完全一致。稍后我们再回到这个话题上来。
再看一下下面的代码:
1 | smart_ptr<shape> ptr1{new circle()}; |
第一个表达式里的 new circle() 就是一个纯右值;但对于指针,我们通常使用值传递,并不关心它是左值还是右值。
第二个表达式里的 std::move(ptr) 就有趣点了。它的作用是把一个左值引用强制转换成一个右值引用,而并不改变其内容。从实用的角度,在我们这儿 std::move(ptr1) 等价于 static_cast<smart_ptr<shape>&&>(ptr1)
。因此,std::move(ptr1) 的结果是指向 ptr1 的一个右值引用,这样构造 ptr2 时就会选择上面第二个重载。
我们可以把 std::move(ptr1) 看作是一个有名字的右值。为了跟无名的纯右值 prvalue 相区别,C++ 里目前就把这种表达式叫做 xvalue。跟左值 lvalue 不同,xvalue 仍然是不能取地址的——这点上,xvalue 和 prvalue 相同。所以,xvalue 和 prvalue 都被归为右值 rvalue。我们用下面的图来表示会更清楚一点:
另外请注意,”值类别”(value category)和”值类型”(value type)是两个看似相似、却毫不相干的术语。前者指的是上面这些左值、右值相关的概念,后者则是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另外一个数值。在 C++ 里,所有的原生类型、枚举、结构、联合、类都代表值类型,只有引用(&)和指针(*)才是引用类型。在 Java 里,数字等原生类型是值类型,类则属于引用类型。在 Python 里,一切类型都是引用类型。
3.2 生命周期和表达式类型
一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象,当然这个对象的生命周期也在那时结束。那临时对象(prvalue)呢?在这儿,C++ 的规则是:一个临时对象会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁,除非有生命周期延长发生。我们先看一个没有生命周期延长的基本情况:
1 |
|
输出结果可能会是(circle 和 triangle 的顺序在标准中没有规定):
1 | main() |
目前我让 process_shape 也返回了一个结果,这是为了下一步演示的需要。你可以看到结果的临时对象最后生成、最先析构。
为了方便对临时对象的使用,C++ 对临时对象有特殊的生命周期延长规则。这条规则是:
如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。
我们对上面的代码只要改一行就能演示这个效果。把 process_shape 那行改成:
1 | result&& r = process_shape(circle(), triangle()); |
我们就能看到不同的结果了:
1 | main() |
现在 result 的生成还在原来的位置,但析构被延到了 main 的最后。
需要万分注意的是,这条生命期延长规则只对 prvalue 有效,而对 xvalue 无效。如果由于某种原因,prvalue 在绑定到引用以前已经变成了 xvalue,那生命期就不会延长。不注意这点的话,代码就可能会产生隐秘的 bug。比如,我们如果这样改一下代码,结果就不对了:
1 |
|
这时的代码输出就回到了前一种情况。虽然执行到 something else 那儿我们仍然有一个有效的变量 r,但它指向的对象已经不存在了,对 r 的解引用是一个未定义行为。由于 r 指向的是栈空间,通常不会立即导致程序崩溃,而会在某些复杂的组合条件下才会引致问题……
对 C++ 的这条生命期延长规则,在后面讲到视图(view)的时候会十分有用。那时我们会看到,有些 C++ 的用法实际上会隐式地利用这条规则。
此外,参考资料 [5] 中提到了一个有趣的事实:你可以把一个没有虚析构函数的子类对象绑定到基类的引用变量上,这个子类对象的析构仍然是完全正常的——这是因为这条规则只是延后了临时对象的析构而已,不是利用引用计数等复杂的方法,因而只要引用绑定成功,其类型并没有什么影响
同样的例子可以说明上述表达核心。
例子1:没有生命周期延长的情况
1 |
|
输出:
1 | Begin main() |
在这个例子中,createA()
返回的临时对象 A()
在完整表达式结束后立即销毁。因此,在 createA()
调用之后,临时对象 A()
的析构函数被调用。
例子2:使用生命周期延长的情况1
1 |
|
输出:
1 | Begin main() |
在这个例子中,createA()
返回的临时对象 A()
被延长其生命周期,因为它绑定到了常量引用 const A& ref
上。因此,临时对象 A()
的析构函数在 main()
函数结束时才被调用,而不是在完整表达式结束时立即被销毁。
这些例子展示了C++中临时对象生命周期延长的效果,通过绑定到引用来延长临时对象的生命周期,可以有效地避免在表达式结束时过早销毁临时对象
也可以直接用右值引用延长生命周期
1 |
|
例子3:使用生命周期延长的情况2+
1 |
|
在这个例子中,使用了 std::move()
将 createBase()
返回的临时对象转换为一个 xvalue(将其作为右值引用),而不再是 prvalue。因此,生命周期延长规则不再适用,临时对象 Base()
在完整表达式结束后立即被销毁,导致其析构函数在 main()
函数结束前被调用。
注意:如果使用了std::move函数将其转换成右值引用的话就会出错,我认为这是编译器的选择。
3.3 移动的意义
对于 smart_ptr,我们使用右值引用的目的是实现移动,而实现移动的意义是减少运行的开销——在引用计数指针的场景下,这个开销并不大。移动构造和拷贝构造的差异仅在于:
- 少了一次 other.shared_count_->add_count() 的调用
- 被移动的指针被清空,因而析构时也少了一次 shared_count_->reduce_count() 的调用
在使用容器类的情况下,移动更有意义。我们可以尝试分析一下下面这个假想的语句(假设 name 是 string 类型):
1 | string result = string("Hello, ") + name + "."; |
在 C++11 之前的年代里,这种写法是绝对不推荐的。因为它会引入很多额外开销,执行流程大致如下:
- 调用构造函数 string(const char*),生成临时对象 1;”Hello, “ 复制 1 次。
- 调用 operator+(const string&, const string&),生成临时对象 2;”Hello,” 复制 2 次,name 复制 1 次。
- 调用 operator+(const string&, const char*),生成对象 3;”Hello, “ 复制 3 次,name 复制 2 次,”.” 复制 1 次。
- 假设返回值优化能够生效(最佳情况),对象 3 可以直接在 result 里构造完成。
- 临时对象 2 析构,释放指向 string(“Hello, “) + name 的内存。
- 临时对象 1 析构,释放指向 string(“Hello, “) 的内存。
既然 C++ 是一门追求性能的语言,一个合格的 C++ 程序员会写:
1 | string result = "Hello, "; |
这样的话,只会调用构造函数一次和 string::operator+= 两次,没有任何临时对象需要生成和析构,所有的字符串都只复制了一次。但显然代码就啰嗦多了——尤其如果拼接的步骤比较多的话。从 C++11 开始,这不再是必须的。同样上面那个单行的语句,执行流程大致如下:
- 调用构造函数 string(const char*),生成临时对象 1;”Hello, “ 复制 1 次。
- 调用 operator+(string&&, const string&),直接在临时对象 1 上面执行追加操作,并把结果移动到临时对象 2;name 复制 1 次。
- 调用 operator+(string&&, const char*),直接在临时对象 2 上面执行追加操作,并把结果移动到 result;”.” 复制 1 次。
- 临时对象 2 析构,内容已经为空,不需要释放任何内存。
- 临时对象 1 析构,内容已经为空,不需要释放任何内存。
性能上,所有的字符串只复制了一次;虽然比啰嗦的写法仍然要增加临时对象的构造和析构,但由于这些操作不牵涉到额外的内存分配和释放,是相当廉价的。程序员只需要牺牲一点点性能,就可以大大增加代码的可读性。而且,所谓的性能牺牲,也只是相对于优化得很好的 C 或 C++ 代码而言——这样的 C++ 代码的性能仍然完全可以超越 Python 类的语言的相应代码。
一句话总结,移动语义使得在 C++ 里返回大对象(如容器)的函数和运算符成为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率。
3.4 如何实现移动?
要让你设计的对象支持移动的话,通常需要下面几步:
- 你的对象应该有分开的拷贝构造和移动构造函数(除非你只打算支持移动,不支持拷贝——如 unique_ptr)。
- 你的对象应该有 swap 成员函数,支持和另外一个对象快速交换成员。
- 在你的对象的名空间下,应当有一个全局的 swap 函数,调用成员函数 swap 来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的 swap 函数。
- 实现通用的 operator=。
- 上面各个函数如果不抛异常的话,应当标为 noexcept。这对移动构造函数尤为重要。
具体写法可以参考我们当前已经实现的 smart_ptr:
- smart_ptr 有拷贝构造和移动构造函数(虽然此处我们的模板构造函数严格来说不算拷贝或移动构造函数)。移动构造函数应当从另一个对象获取资源,清空其资源,并将其置为一个可析构的状态。
3.5 不要返回本地变量的引用
在 C++11 之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization,或 NRVO),能把对象直接构造到调用者的栈上。从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move 进行干预——使用std::move 对于移动行为没有帮助,反而会影响返回值优化。
1 |
|
输出
1 | *** 1 *** |
也就是,用了 std::move 反而妨碍了返回值优化。
3.6 引用坍缩和完美转发
引用坍缩(又称”引用折叠”)。
我们已经讲了对于一个实际的类型 T,它的左值引用是 T&,右值引用是 T&&。那么:
- 是不是看到 T&,就一定是个左值引用?
- 是不是看到 T&&,就一定是个右值引用?
对于前者的回答是”是”,对于后者的回答为”否”。
关键在于,在有模板的代码里,对于类型参数的推导结果可能是引用。我们可以略过一些繁复的语法规则,要点是:
- 对于
template <typename T> foo(T&&)
这样的代码,如果传递过去的参数是左值,T 的推导结果是左值引用;如果传递过去的参数是右值,T 的推导结果是参数的类型本身。 - 如果 T 是左值引用,那 T&& 的结果仍然是左值引用——即 type& && 坍缩成了 type&。
- 如果 T 是一个实际类型,那 T&& 的结果自然就是一个右值引用。
我们之前提到过,右值引用变量仍然会匹配到左值引用上去。下面的代码会验证这一行为:
1 | void foo(const shape&) |
输出为:
1 | bar(shape&&) |
如果我们要让 bar 调用右值引用的那个 foo 的重载,我们必须写成:
1 | foo(std::move(s)); |
或:
1 | foo(static_cast<shape&&>(s)); |
完整代码
1 |
|
可如果两个 bar 的重载除了调用 foo 的方式不一样,其他都差不多的话,我们为什么要提供两个不同的 bar 呢?
事实上,很多标准库里的函数,连目标的参数类型都不知道,但我们仍然需要能够保持参数的值类型:左值的仍然是左值,右值的仍然是右值。这个功能在 C++ 标准库中已经提供了,叫 std::forward。它和 std::move 一样都是利用引用坍缩机制来实现。此处,我们不介绍其实现细节,而是重点展示其用法。我们可以把我们的两个 bar 函数简化成:
1 | template <typename T> |
1 | template <typename T> |
对于下面这样的代码:
1 | circle temp; |
现在的输出是:
1 | foo(const shape&) |
因为在 T 是模板参数时,T&& 的作用主要是保持值类别进行转发,它有个名字就叫”转发引用”(forwarding reference)。因为既可以是左值引用,也可以是右值引用,它也曾经被叫做”万能引用”(universal reference)。
3.7 参考资料
- cppreference.com, “Value categories”. https://en.cppreference.com/w/cpp/language/value_category
- Anders Schau Knatten, “lvalues, rvalues, glvalues, prvalues, xvalues, help!”. https://blog.knatten.org/2018/03/09/lvalues-rvalues-glvalues-prvalues-xvalueshelp/
- Jeaye, “Value category cheat-sheet”. https://blog.jeaye.com/2017/03/19/xvalues/
- Thomas Becker, “C++ rvalue references explained”. http://thbecker.net/articles/rvalue_references/section_01.html
- Herb Sutter, “GotW #88: A candidate for the ‘most important const’”. https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-mostimportant-const/
04 | 容器汇编 I:比较简单的若干容器
4.1 string
在 string 的情况下,由于考虑到和 C 字符串的兼容,end 指向代表字符串结尾的 \0 字符。
string 的内存布局大致如下图所示:
一些策略:
- 如果不修改字符串的内容,使用 const string& 或 C++17 的 string_view 作为参数类型。后者是最理想的情况,因为即使在只有 C 字符串的情况,也不会引发不必要的内存复制。
- 如果需要在函数内修改字符串内容、但不影响调用者的该字符串,使用 string 作为参数类型(自动拷贝)。
- 如果需要改变调用者的字符串内容,使用 string& 作为参数类型(通常不推荐)。
4.2 vector
vector 通常保证强异常安全性,如果元素类型没有提供一个保证不抛异常的移动构造函数,vector 通常会使用拷贝构造函数。因此,对于拷贝代价较高的自定义元素类型,我们应当定义移动构造函数,并标其为 noexcept,或只在容器中放置对象的智能指针。这就是为什么我之前需要在 smart_ptr 的实现中标上 noexcept 的原因。
1 |
|
输出
1 | Obj1() |
Obj1 和 Obj2 的定义只差了一个 noexcept,但这个小小的差异就导致了 vector 是否会移动对象。这点非常重要。
C++11 开始提供的 emplace… 系列函数是为了提升容器的性能而设计的。你可以试试把 v1.emplace_back() 改成 v1.push_back(Obj1())。对于 vector 里的内容,结果是一样的;但使用 push_back 会额外生成临时对象,多一次拷贝构造和一次析构。
在扩容的时,只有移动构造函数加上noexcept关键字,才会使用移动构造函数。vector 保证强异常安全性。
4.3 deque
deque 的内存布局一般是这样的:
4.4 list
list 的内存布局一般是下图这个样子:
某些标准算法在 list 上会导致问题,list 提供了成员函数作为替代,包括下面几个:
- merge
- remove
- remove_if
- reverse
- sort
- unique
4.5 forward_list
从 C++11 开始,前向列表 forward_list 成了标准的一部分。
它的内存布局:
4.6 queue
queue 缺省用 deque 来实现。
从概念上讲,它的结构可如下所示:
4.7 stack
queue 缺省也是用 deque 来实现。
一般图形表示法会把 stack 表示成一个竖起的 vector:
4.8 参考资料
- cppreference.com, “Containers library”. https://en.cppreference.com/w/cpp/container
- QuantStack, xeus-cling. https://github.com/QuantStack/xeus-cling
- 吴咏炜, output_container. https://github.com/adah1972/output_container/blob/master/output_container.h
05 | 容器汇编 II:需要函数对象的容器
5.1 函数对象及其特化
在讲容器之前,我们需要首先来讨论一下两个重要的函数对象,less 和 hash。
在标准库里,通用的 less 大致是这样定义的:
在讲容器之前,我们需要首先来讨论一下两个重要的函数对象,less 和 hash。
在标准库里,通用的 less 大致是这样定义的:
cpp
1 | template <class T> |
也就是说,less 是一个函数对象,并且是个二元函数,执行对任意类型的值的比较,返回布尔类型。作为函数对象,它定义了函数调用运算符(operator()),并且缺省行为是对指定类型的对象进行 < 的比较操作。
在需要大小比较的场合,C++ 通常默认会使用 less,包括我们今天会讲到的若干容器和排序算法 sort。如果我们需要产生相反的顺序的话,则可以使用 greater,大于关系。
计算哈希值的函数对象 hash 的目的是把一个某种类型的值转换成一个无符号整数哈希值,类型为 size_t。它没有一个可用的默认实现。对于常用的类型,系统提供了需要的特化 [2],类似于:
cpp
1 | template <class T> |
要点是,对于每个类,类的作者都可以提供 hash 的特化,使得对于不同的对象值,函数调用运算符都能得到尽可能均匀分布的不同数值。
用下面这个例子来加深一下理解:
cpp
1 |
|
在 MSVC 下的某次运行结果如下所示:
1 | { 13, 6, 4, 11, 29 } |
可以看到,在这个实现里,空指针的哈希值是一个非零的数值,指针的哈希值也和指针的数值不一样。要注意不同的实现处理的方式会不一样。事实上,我的测试结果是 GCC、Clang 和 MSVC 对常见类型的哈希方式都各有不同。
在上面的例子里,我们同时可以看到,这两个函数对象的值不重要。我们甚至可以认为,每个 less(或 greater 或 hash)对象都是等价的。关键在于其类型。以 sort 为例,第三个参数的类型确定了其排序行为。
对于容器也是如此,函数对象的类型确定了容器的行为。
5.2 priority_queue
priority_queue 也是一个容器适配器。上一讲没有和其他容器适配器一起讲的原因就在于它用到了比较函数对象(默认是 less)。在使用缺省的 less 作为其 Compare 模板参数时,最大的数值会出现在容器的”顶部”。如果需要最小的数值出现在容器顶部,则可以传递 greater 作为其 Compare 模板参数。
5.3 关联容器
关联容器有 set(集合)、map(映射)、multiset(多重集)和 multimap(多重映射)。跳出 C++ 的语境,map(映射)的更常见的名字是关联数组和字典 [3],而在 JSON里直接被称为对象(object)。在 C++ 外这些容器常常是无序的;在 C++ 里关联容器则被认为是有序的。
关联容器都有 find、lower_bound、upper_bound 等查找函数,结果是一个迭代器:
- find(k) 可以找到任何一个等价于查找键 k 的元素(!(x < k || k < x))
- lower_bound(k) 找到第一个不小于查找键 k 的元素(!(x < k))
- upper_bound(k) 找到第一个大于查找键 k 的元素(k < x)
如果你需要在 multimap 里精确查找满足某个键的区间的话,建议使用 equal_range,可以一次性取得上下界(半开半闭)。
对于自定义类型,我推荐尽量使用标准的 less 实现,通过重载 <(及其他标准比较运算符)对该类型的对象进行排序。存储在关联容器中的键一般应满足严格弱序关系(strict weak ordering;[4]),即:
- 对于任何该类型的对象 x:!(x < x)(非自反)
- 对于任何该类型的对象 x 和 y:如果 x < y,则 !(y < x)(非对称)
- 对于任何该类型的对象 x、y 和 z:如果 x < y 并且 y < z,则 x < z(传递性)
- 对于任何该类型的对象 x、y 和 z:如果 x 和 y 不可比(!(x < y) 并且 !(y < x))并且 y 和 z 不可比,则 x 和 z 不可比(不可比的传递性)
5.4 无序关联容器
从 C++11 开始,每一个关联容器都有一个对应的无序关联容器,它们是:
- unordered_set
- unordered_map
- unordered_multiset
- unordered_multimap
一个示例:
1 |
|
输出可能是(顺序不能保证):
1 | { 21, 5, 8, 3, 13, 2, 1 } |
请注意我们在 std 名空间中添加了特化,这是少数用户可以向 std 名空间添加内容的情况之一。正常情况下,向 std 名空间添加声明或定义是禁止的,属于未定义行为。
5.5 array
C 数组在 C++ 里继续存在,主要是为了保留和 C 的向后兼容性。C 数组本身和 C++ 的容器相差是非常大的:
- C 数组没有 begin 和 end 成员函数(虽然可以使用全局的 begin 和 end 函数)
- C 数组没有 size 成员函数(得用一些模板技巧来获取其长度)
- C 数组作为参数有退化行为,传递给另外一个函数后那个函数不再能获得 C 数组的长度和结束位置
在 C 的年代,大家有时候会定义这样一个宏来获得数组的长度:
1 |
如果在一个函数内部对数组参数使用这个宏,结果肯定是错的。现在 GCC 会友好地发出警告:
1 | void test(int a[8]) |
warning: sizeof on array function parameter will return size of ‘int *’ instead of ‘int [8]’ [-Wsizeof-array-argument]
cout << ARRAY_LEN(a) << endl;
C++17 直接提供了一个 size 方法,可以用于提供数组长度,并且在数组退化成指针的情况下会直接失败:
1 |
|
此外,C 数组也没有良好的复制行为。
array 可以避免 C 数组的种种怪异行径。
5.6 参考资料
- cppreference.com, “Containers library”. https://en.cppreference.com/w/cpp/container
- cppreference.com, “Explicit (full) template specialization”. https://en.cppreference.com/w/cpp/language/template_specialization
- Wikipedia, “Associative array”. https://en.wikipedia.org/wiki/Associative_array
- Wikipedia, “Weak ordering”. https://en.wikipedia.org/wiki/Weak_ordering
- Wikipedia, “Hash table”. https://en.wikipedia.org/wiki/Hash_table
06 | 异常:用还是不用,这是个问题
首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。
6.1 没有异常的世界
我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。
假设我们要做一些矩阵的操作,定义了下面这个矩阵的数据结构:
1 | typedef struct { |
我们至少需要有初始化和清理的代码:
1 | enum matrix_err_code { |
然后,我们做一下矩阵乘法吧。函数定义大概会是这个样子:
1 | int matrix_multiply(matrix* result, const matrix* lhs, const matrix* rhs) |
调用代码:
1 | matrix c; |
可以看到,我们有大量需要判断错误的代码,零散分布在代码各处。
可这是 C 啊。我们用 C++、不用异常可以吗?
当然可以,但你会发现结果好不了多少。毕竟,C++ 的构造函数是不能返回错误码的,所以你根本不能用构造函数来做可能出错的事情。你不得不定义一个只能清零的构造函数,再使用一个 init 函数来做真正的构造操作。C++ 虽然支持运算符重载,可你也不能使用,因为你没法返回一个新矩阵……
6.2 使用异常
如果使用异常的话,我们就可以在构造函数里做真正的初始化工作了。假设我们的矩阵类有下列的数据成员:
1 | class matrix { |
构造函数和析构函数我们可以这样写:
1 | matrix::matrix(size_t nrows, size_t ncols) |
乘法函数可以这样写:
1 | class matrix { |
使用乘法的代码则更是简单:
1 | matrix c = a * b; |
你可能已经非常疑惑了:错误处理在哪儿呢?只有一个 throw,跟前面的 C 代码能等价吗?
异常处理并不意味着需要写显式的 try 和 catch。异常安全的代码,可以没有任何 try 和 catch。
如果你不确定什么是”异常安全”,我们先来温习一下概念:异常安全是指当异常发生时,既不会发生资源泄漏,系统也不会处于一个不一致的状态。
我们看看可能会出现错误 / 异常的地方:
- 首先是内存分配。如果 new 出错,按照 C++ 的规则,一般会得到异常 bad_alloc,对象的构造也就失败了。这种情况下,在 catch 捕捉到这个异常之前,所有的栈上对象会全部被析构,资源全部被自动清理。
- 如果是矩阵的长宽不合适不能做乘法呢?我们同样会得到一个异常,这样,在使用乘法的地方,对象 c 根本不会被构造出来。
- 如果在乘法函数里内存分配失败呢?一样,result 对象根本没有构造出来,也就没有 c 对象了。还是一切正常。
- 如果 a、b 是本地变量,然后乘法失败了呢?析构函数会自动释放其空间,我们同样不会有任何资源泄漏。
总而言之,只要我们适当地组织好代码、利用好 RAII,实现矩阵的代码和使用矩阵的代码都可以更短、更清晰。我们可以统一在外层某个地方处理异常——通常会记日志、或在界面上向用户报告错误了。
6.3 避免异常的风格指南?
但大名鼎鼎的 Google 的 C++ 风格指南不是说要避免异常吗 [1]?这又是怎么回事呢?
答案实际已经在 Google 的文档里了:
Given that Google’s existing code is not exception-tolerant, the costs of using
exceptions are somewhat greater than the costs in a new project. The conversion
process would be slow and error-prone. We don’t believe that the available
alternatives to exceptions, such as error codes and assertions, introduce a
significant burden.Our advice against using exceptions is not predicated on philosophical or moral
grounds, but practical ones. Because we’d like to use our open-source projects
at Google and it’s difficult to do so if those projects use exceptions, we need to
advise against exceptions in Google open-source projects as well. Things would
probably be different if we had to do it all over again from scratch.
我来翻译一下(我的加重):
鉴于 Google 的现有代码不能承受异常,使用异常的代价要比在全新的项目中使用异常
大一些。 转换 [代码来使用异常的] 过程会缓慢而容易出错。我们不认为可代替异常的方
法,如错误码或断言,会带来明显的负担。我们反对异常的建议并非出于哲学或道德的立场,而是出于实际考虑。因为我们希望在
Google 使用我们的开源项目,而如果这些项目使用异常的话就会对我们的使用带来困
难,我们也需要反对在 Google 的开源项目中使用异常。如果我们从头再来一次的话,
事情可能就会不一样了。
这个如果还比较官方、委婉的话,Reddit 上还能找到一个更个人化的表述 [2]:
I use [sic] to work at Google, and Craig Silverstein, who wrote the first draft of
the style guideline, said that he regretted the ban on exceptions, but he had no
choice; when he wrote it, it wasn’t only that the compiler they had at the time
did a very bad job on exceptions, but that they already had a huge volume of
non-exception-safe code.
我的翻译(同样,我的加重):
我过去在 Google 工作,写了风格指南初稿的 Craig Silverstein 说过 他对禁用异常感到
遗憾 ,但他当时别无选择。在他写风格指南的时候,不仅他们使用的编译器在异常上工
作得很糟糕,而且他们已经有了一大堆异常不安全的代码了。
当然,除了历史原因以外,也有出于性能等其他原因禁用异常的。美国国防部的联合攻击战斗机(JSF)项目的 C++ 编码规范就禁用异常,因为工具链不能保证抛出异常时的实时性能。不过在那种项目里,被禁用的 C++ 特性就多了,比如动态内存分配都不能使用。
一些游戏项目为了追求高性能,也禁用异常。这个实际上也有一定的历史原因,因为今天的主流 C++ 编译器,在异常关闭和开启时应该已经能够产生性能差不多的代码(在异常未抛出时)。代价是产生的二进制文件大小的增加,因为异常产生的位置决定了需要如何做栈展开,这些数据需要存储在表里。典型情况,使用异常和不使用异常比,二进制文件大小会有约百分之十到二十的上升。LLVM 项目的编码规范里就明确指出这是不使用 RTTI 和异常的原因 [3]:
In an effort to reduce code and executable size, LLVM does not use RTTI (e.g. dynamic_cast<>;) or exceptions.
6.4 异常的问题
异常当然不是一个完美的特性,否则也不会招来这些批评和禁用了。对它的批评主要有两条:
- 异常违反了”你不用就不需要付出代价”的 C++ 原则。只要开启了异常,即使不使用异常你编译出的二进制代码通常也会膨胀。
- 异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。
对于第一条,开发者没有什么可做的。事实上,这也算是 C++ 实现的一个折中了。目前的主流异常实现中,都倾向于牺牲可执行文件大小、提高主流程(happy path)的性能。只要程序不抛异常,C++ 代码的性能比起完全不做错误检查的代码,都只有几个百分点的性能损失 [4]。除了非常有限的一些场景,可执行文件大小通常不会是个问题。
第二条可以算作是一个真正有效的批评。和 Java 不同,C++ 里不会对异常规约进行编译时的检查。从 C++17 开始,C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常—— noexcept、noexcept(true) 或 throw()。这也是 C++ 的运行时唯一会检查的东西了。如果一个函数声明了不会抛出异常、结果却抛出了异常,C++ 运行时会调用 std::terminate 来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。
当然,不声明异常是有理由的。特别是在泛型编程的代码里,几乎不可能预知会发生些什么异常。我个人对避免异常带来的问题有几点建议:
- 写异常安全的代码,尤其在模板里。可能的话,提供强异常安全保证 [5],在任何第三方代码发生异常的情况下,不改变对象的内容,也不产生任何资源泄漏。
- 如果你的代码可能抛出异常的话,在文档里明确声明可能发生的异常类型和发生条件。确保使用你的代码的人,能在不检查你的实现的情况,了解需要准备处理哪些异常。
- 对于肯定不会抛出异常的代码,将其标为 noexcept。注意类的特殊成员(构造函数、析构函数、赋值函数等)会自动成为 noexcept,如果它们调用的代码都是 noexcept 的话。所以,像 swap 这样的成员函数应当尽可能标成 noexcept。
6.5 使用异常的理由
虽然后面我们会描述到一些不使用异常、也不使用错误返回码的错误处理方式,但异常是渗透在 C++ 中的标准错误处理方式。标准库的错误处理方式就是异常。其中不仅包括运行时错误,甚至包括一些逻辑错误。比如,在说容器的时候,有一个我没提的地方是,在能使用 [] 运算符的地方,C++ 的标准容器也提供了 at 成员函数,能够在下标不存在的时候抛出异常,作为一种额外的帮助调试的手段。
C++ 的标准容器在大部分情况下提供了强异常保证,即,一旦异常发生,现场会恢复到调用函数之前的状态,容器的内容不会发生改变,也没有任何资源泄漏。前面提到过, vector 会在元素类型没有提供保证不抛异常的移动构造函数的情况下,在移动元素时会使用拷贝构造函数。这是因为一旦某个操作发生了异常,被移动的元素已经被破坏,处于只能析构的状态,异常安全性就不能得到保证了。
只要你使用了标准容器,不管你自己用不用异常,你都得处理标准容器可能引发的异常——至少有 bad_alloc,除非你明确知道你的目标运行环境不会产生这个异常。这对普通配置的 Linux 环境而言,倒确实是对的……这也算是 Google 这么规定的一个底气吧。
虽然对于运行时错误,开发者并没有什么选择余地;但对于代码中的逻辑错误,开发者则是可以选择不同的处理方式的:你可以使用异常,也可以使用 assert,在调试环境中报告错误并中断程序运行。由于测试通常不能覆盖所有的代码和分支,assert 在发布模式下一般被禁用,两者并不是完全的替代关系。在允许异常的情况下,使用异常可以获得在调试和发布模式下都良好、一致的效果。
标准 C++ 可能会产生哪些异常,可以查看参考资料 [6]。
6.6 参考资料
- Google, “Google C++ style guide”. https://google.github.io/styleguide/cppguide.html#Exceptions
- Reddit, Discussion on “Examples of C++ projects which embrace exceptions?”. https://www.reddit.com/r/cpp/comments/4wkkge/examples_of_c_projects_which_embrace_exceptions/
- LLVM Project, “LLVM coding standards”. https://llvm.org/docs/CodingStandards.html#do-not-use-rtti-or-exceptions
- Standard C++ Foundation, “FAQ—exceptions and error handling”. https://isocpp.org/wiki/faq/exceptions
- cppreference.com, “Exceptions”. https://en.cppreference.com/w/cpp/language/exceptions
- cppreference.com, “std::exception”. https://en.cppreference.com/w/cpp/error/exception
07 | 迭代器和好用的新for循环
7.1 什么是迭代器?
迭代器是一个很通用的概念,并不是一个特定的类型。它实际上是一组对类型的要求([1])。它的最基本要求就是从一个端点出发,下一步、下一步地到达另一个端点。
我在用 output_container.h 输出容器内容的时候,实际上就对容器的 begin 和 end 成员函数返回的对象类型提出了要求。假设前者返回的类型是 I,后者返回的类型是 S,这些要求是:
- I 对象支持
*
操作,解引用取得容器内的某个对象。 - I 对象支持 ++,指向下一个对象。
- I 对象可以和 I 或 S 对象进行相等比较,判断是否遍历到了特定位置(在 S 的情况下是是否结束了遍历)。
注意在 C++17 之前,begin 和 end 返回的类型 I 和 S 必须是相同的。从 C++17 开始,I 和 S 可以是不同的类型。
上面的类型 I,多多少少就是一个满足输入迭代器(input iterator)的类型了。不过, output_container.h 只使用了前置 ++,但输入迭代器要求前置和后置 ++ 都得到支持。
输入迭代器不要求对同一迭代器可以多次使用 *
运算符,也不要求可以保存迭代器来重新遍历对象,换句话说,只要求可以单次访问。如果取消这些限制、允许多次访问的话,那迭代器同时满足了前向迭代器(forward iterator)。
一个前向迭代器的类型,如果同时支持 —(前置及后置),回到前一个对象,那它就是个双向迭代器(bidirectional iterator)。也就是说,可以正向遍历,也可以反向遍历。
一个双向迭代器,如果额外支持在整数类型上的 +、-、+=、-=,跳跃式地移动迭代器;支持 [],数组式的下标访问;支持迭代器的大小比较(之前只要求相等比较);那它就是个随机访问迭代器(random-access iterator)。
一个随机访问迭代器 i 和一个整数 n,在 *i
可解引用且 i + n 是合法迭代器的前提下,如果额外还满足 *(addressdof(*i) + n)
等价于 *(i + n)
,即保证迭代器指向的对象在内存里是连续存放的,那它(在 C++20 里)就是个连续迭代器(contiguous iterator)。
以上这些迭代器只考虑了读取。如果一个类型像输入迭代器,但 *i
只能作为左值来写而不能读,那它就是个输出迭代器(output iterator)。
而比输入迭代器和输出迭代器更底层的概念,就是迭代器了。基本要求是:
- 对象可以被拷贝构造、拷贝赋值和析构。
- 对象支持 * 运算符。
- 对象支持前置 ++ 运算符。
迭代器类型的关系可从下图中全部看到:
迭代器通常是对象。但需要注意的是,指针可以满足上面所有的迭代器要求,因而也是迭代器。这应该并不让人惊讶,因为本来迭代器就是根据指针的特性,对其进行抽象的结果。事实上,vector 的迭代器,在很多实现里就直接是使用指针的。
7.2 常用迭代器
最常用的迭代器就是容器的 iterator 类型了。一般而言,iterator 可写入,const_iterator 类型不可写入,但这些迭代器都被定义为输入迭代器或其派生类型:
- vector::iterator 和 array::iterator 可以满足到连续迭代器。
- deque::iterator 可以满足到随机访问迭代器(记得它的内存只有部分连续)。
- list::iterator 可以满足到双向迭代器(链表不能快速跳转)。
- forward_list::iterator 可以满足到前向迭代器(单向链表不能反向遍历)。
很常见的一个输出迭代器是 back_inserter 返回的类型 back_inserter_iterator 了;用它我们可以很方便地在容器的尾部进行插入操作。另外一个常见的输出迭代器是 ostream_iterator,方便我们把容器内容”拷贝”到一个输出流。
7.3 使用输入行迭代器
下面我们来看一下一个我写的输入迭代器。它的功能本身很简单,就是把一个输入流(istream)的内容一行行读进来。配上 C++11 引入的基于范围的 for 循环的语法,我们可以把遍历输入流的代码以一种自然、非过程式的方式写出来,如下所示:
1 | for (const string& line : istream_line_reader(is)) { |
我们可以对比一下以传统的方式写的 C++ 代码,其中需要照顾不少细节:
cpp
1 | string line; |
我们后面会分析一下这个输入迭代器。在此之前,我先解说一下基于范围的 for 循环这个语法。虽然这可以说是个语法糖,但它对提高代码的可读性真的非常重要。如果不用这个语法糖的话,简洁性上的优势就小多了。我们直接把这个循环改写成等价的普通 for 循环的样子。
cpp
1 | { |
可以看到,它做的事情也不复杂,就是:
- 获取冒号后边的范围表达式的结果,并隐式产生一个引用,在整个循环期间都有效。注意根据生命期延长规则,表达式结果如果是临时对象的话,这个对象要在循环结束后才被销毁。
- 自动生成遍历这个范围的迭代器。
- 循环内自动生成根据冒号左边的声明和 *it 来进行初始化的语句。
- 下面就是完全正常的循环体。
生成迭代器这一步有可能是——但不一定是——调用 r 的 begin 和 end 成员函数。具体规则是:
- 对于 C 数组(必须是没有退化为指针的情况),编译器会自动生成指向数组头尾的指针(相当于自动应用可用于数组的 std::begin 和 std::end 函数)。
- 对于有 begin 和 end 成员的对象,编译器会调用其 begin 和 end 成员函数(我们目前的情况)。
- 否则,编译器会尝试在 r 对象所在的名空间寻找可以用于 r 的 begin 和 end 函数,并
- 调用 begin® 和 end®;找不到的话则失败报错。
7.4 定义输入行迭代器
C++ 里有些固定的类型要求规范。对于一个迭代器,我们需要定义下面的类型:
1 | class istream_line_reader { |
仿照一般的容器,我们把迭代器定义为 istream_line_reader 的嵌套类。它里面的这五个类型是必须定义的(其他泛型 C++ 代码可能会用到这五个类型;之前标准库定义了一个可以继承的类模板 std::iterator 来产生这些类型定义,但这个类目前已经被废弃[2])。其中:
- difference_type 是代表迭代器之间距离的类型,定义为 ptrdiff_t 只是种标准做法(指针间差值的类型),对这个类型没什么特别作用。
- value_type 是迭代器指向的对象的值类型,我们使用 string,表示迭代器指向的是字符串。
- pointer 是迭代器指向的对象的指针类型,这儿就平淡无奇地定义为 value_type 的常指针了(我们可不希望别人来更改指针指向的内容)。类似的,reference 是 value_type 的常引用。
- iterator_category 被定义为 input_iterator_tag,标识这个迭代器的类型是
- input iterator(输入迭代器)。
作为一个真的只能读一次的输入迭代器,有个特殊的麻烦(前向迭代器或其衍生类型没有):到底应该让 *
负责读取还是 ++ 负责读取。我们这儿采用常见、也较为简单的做法,让 ++ 负责读取,*
负责返回读取的内容(这个做法会有些副作用,但按我们目前的用法则没有问题)。这样的话,这个 iterator 类需要有一个数据成员指向输入流,一个数据成员来存放读取的结果。根据这个思路,我们定义这个类的基本成员函数和数据成员:
1 | class istream_line_reader { |
我们定义了默认构造函数,将 stream_
清空;相应的,在带参数的构造函数里,我们根据传入的输入流来设置 stream_
。我们也定义了 *
和 -> 运算符来取得迭代器指向的文本行的引用和指针,并用 ++ 来读取输入流的内容(后置 ++ 则以惯常方式使用前置 ++ 和拷贝构造来实现)。唯一”特别”点的地方,是我们在构造函数里调用了 ++,确保在构造后调用 *
运算符时可以读取内容,符合日常先使用 *
、再使用 ++ 的习惯。一旦文件读取到尾部(或出错),则 stream_
被清空,回到默认构造的情况。
对于迭代器之间的比较,我们则主要考虑文件有没有读到尾部的情况,简单定义为:
1 | bool operator==(const iterator& rhs) const noexcept |
有了这个 iterator 的定义后,istream_line_reader 的定义就简单得很了:
1 | class istream_line_reader { |
也就是说,构造函数只是简单地把输入流的指针赋给 stream_ 成员变量。begin 成员函数则负责构造一个真正有意义的迭代器;end 成员函数则只是返回一个默认构造的迭代器而已。
以上就是一个完整的基于输入流的行迭代器了。这个行输入模板的设计动机和性能测试结果可参见参考资料 [3] 和 [4];完整的工程可用代码,请参见参考资料 [5]。该项目中还提供了利用 C 文件接口的 file_line_reader 和基于内存映射文件的 mmap_line_reader。
7.5 参考资料
- cppreference.com, “Iterator library”. https://en.cppreference.com/w/cpp/iterator
- Jonathan Boccara, “std::iterator is deprecated: why, what it was, and what to use instead”. https://www.fluentcpp.com/2018/05/08/std-iterator-deprecated/
- 吴咏炜, “Python yield and C++ coroutines”. https://yongweiwu.wordpress.com/2016/08/16/python-yield-and-cpluspluscoroutines/
- 吴咏炜, “Performance of my line readers”. https://yongweiwu.wordpress.com/2016/11/12/performance-of-my-line-readers/
- 吴咏炜, nvwa. https://github.com/adah1972/nvwa/
08 | 易用性改进 I:自动类型推断和初始化
8.1 自动类型推断
-
auto
自动类型推断,顾名思义,就是编译器能够根据表达式的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明([1])。但需要说明的是,auto 并没有改变 C++ 是静态类型语言这一事实——使用 auto 的变量(或函数返回值)的类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已。
auto 实际使用的规则类似于函数模板参数的推导规则([3])。当你写了一个含 auto 的表达式时,相当于把 auto 替换为模板参数的结果。举具体的例子:
- auto a = expr; 意味着用 expr 去匹配一个假想的
template <typename T> f(T)
函数模板,结果为值类型。 - const auto& a = expr; 意味着用 expr 去匹配一个假想的
template <typename T> f(const T&)
函数模板,结果为常左值引用类型。 - auto&& a = expr; 意味着用 expr 去匹配一个假想的
template <typename T> f(T&&)
函数模板,根据 [第 3 讲] 中我们讨论过的转发引用和引用坍缩规则,结果是一个跟 expr 值类别相同的引用类型。
- auto a = expr; 意味着用 expr 去匹配一个假想的
-
decltype
decltype 的用途是获得一个表达式的类型,结果可以跟类型一样使用。它有两个基本用法:
-
decltype(变量名) 可以获得变量的精确类型。
-
decltype(表达式) (表达式不是变量名,但包括 decltype((变量名)) 的情况)可以获得表达式的引用类型;除非表达式的结果是个纯右值(prvalue),此时结果仍然是值类型。
如果我们有 int a;,那么:
-
decltype(a) 会获得 int(因为 a 是 int)。
-
decltype((a)) 会获得 int&(因为 a 是 lvalue)。
-
decltype(a + a) 会获得 int(因为 a + a 是 prvalue)。
-
-
decltype(auto)
通常情况下,能写 auto 来声明变量肯定是件比较轻松的事。但这儿有个限制,你需要在写下 auto 时就决定你写下的是个引用类型还是值类型。根据类型推导规则,auto 是值类型,auto& 是左值引用类型,auto&& 是转发引用(可以是左值引用,也可以是右值引用)。使用 auto 不能通用地根据表达式类型来决定返回值的类型。不过, decltype(expr) 既可以是值类型,也可以是引用类型。因此,我们可以这么写:
1
decltype(expr) a = expr;
这种写法明显不能让人满意,特别是表达式很长的情况(而且,任何代码重复都是潜在的问题)。为此,C++14 引入了 decltype(auto) 语法。对于上面的情况,我们只需要像下面这样写就行了。
1
decltype(auto) a = expr;
8.2 函数返回值类型推断
从 C++14 开始,函数的返回值也可以用 auto 或 decltype(auto) 来声明了。同样的,用 auto 可以得到值类型,用 auto& 或 auto&& 可以得到引用类型;而用 decltype(auto) 可以根据返回表达式通用地决定返回的是值类型还是引用类型。
和这个形式相关的有另外一个语法,后置返回值类型声明。严格来说,这不算”类型推断”,不过我们也放在一起讲吧。它的形式是这个样子:
1 | auto foo(参数) -> 返回值类型声明 |
通常,在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法。以后我们会讲到一些实例。今天暂时不多讲了。
8.3 类模板的模板参数推导
如果你用过 pair 的话,一般都不会使用下面这种形式:
1 | pair<int, int> pr{1, 42}; |
使用 make_pair 显然更容易一些:
1 | auto pr = make_pair(1, 42); |
这是因为函数模板有模板参数推导,使得调用者不必手工指定参数类型;但 C++17 之前的类模板却没有这个功能,也因而催生了像 make_pair 这样的工具函数。
在进入了 C++17 的世界后,这类函数变得不必要了。现在我们可以直接写:
1 | pair pr{1, 42}; |
在初次见到 array 时,我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了:
1 | int a1[] = {1, 2, 3}; |
这个问题在 C++17 里也是基本不存在的。虽然不能只提供一个模板参数,但你可以两个参数全都不写:
1 | array a{1, 2, 3}; // 得到 array<int, 3> |
这种自动推导机制,可以是编译器根据构造函数来自动生成:
1 | template <typename T> |
也可以是手工提供一个推导向导,达到自己需要的效果:
1 | template <typename T> |
更多的技术细节请参见参考资料 [4]。
8.4 结构化绑定
一个例子:
1 | multimap<string, int>::iterator lower, upper; |
这个例子里,返回值是个 pair,我们希望用两个变量来接收数值,就不得不声明了两个变量,然后使用 tie 来接收结果。在 C++11/14 里,这里是没法使用 auto 的。好在 C++17 引入了一个新语法,解决了这个问题。目前,我们可以把上面的代码简化为:
1 | auto [lower, upper] = mmp.equal_range("four"); |
这个语法使得我们可以用 auto 声明变量来分别获取 pair 或 tuple 返回值里各个子项,可以让代码的可读性更好。
关于这个语法的更多技术说明,请参见参考资料 [5]。
8.5 列表初始化
1 | vector<int> v{1, 2, 3, 4, 5}; |
这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。从技术角度,编译器的魔法只是对 {1, 2, 3} 这样的表达式自动生成一个初始化列表,在这个例子里其类型是 initializer_list<int>
。程序员只需要声明一个接受 initializer_list 的构造函数即可使用。从效率的角度,至少在动态对象的情况下,容器和数组也并无二致,都是通过拷贝(构造)进行初始化。
对于初始化列表在构造函数外的用法和更多的技术细节,请参见参考资料 [6]。
8.6 统一初始化
你可能已经注意到了,我在代码里使用了大括号 {} 来进行对象的初始化。这当然也是 C++11 引入的新语法,能够代替很多小括号 () 在变量初始化时使用。这被称为统一初始化(uniform initialization)。
大括号对于构造一个对象而言,最大的好处是避免了 C++ 里”最令人恼火的语法分析”(the most vexing parse)。我也遇到过。假设你有一个类,原型如下:
1 | class utf8_to_wstring { |
然后你在 Windows 下想使用这个类来帮助转换文件名,打开文件:
1 | ifstream ifs(utf8_to_wstring(filename)); |
上面这个写法会被编译器认为是和下面的写法等价的:
1 | ifstream ifs(utf8_to_wstring filename); |
换句话说,编译器认为你是声明了一个叫 ifs 的函数,而不是对象!
如果你把任何一对小括号替换成大括号(或者都替换,如下),则可以避免此类问题:
1 | ifstream ifs{utf8_to_wstring{filename}}; |
推而广之,你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点:当一个构造函数没有标成 explicit 时,你可以使用大括号不写类名来进行构造,如果调用上下文要求那类对象的话。如:
1 | Obj getObj() |
如果 Obj 类可以使用浮点数进行构造的话,上面的写法就是合法的。如果有无参数、多参数的构造函数,也可以使用这个形式。除了形式上的区别,它跟 Obj(1.0) 的主要区别是,后者可以用来调用 Obj(int),而使用大括号时编译器会拒绝”窄”转换,不接受以 {1.0} 或 Obj{1.0} 的形式调用构造函数 Obj(int)。
这个语法主要的限制是,如果一个构造函数既有使用初始化列表的构造函数,又有不使用初始化列表的构造函数,那编译器会千方百计地试图调用使用初始化列表的构造函数,导致各种意外。所以,如果给一个推荐的话,那就是:
- 如果一个类没有使用初始化列表的构造函数时,初始化该类对象可全部使用统一初始化语法。
- 如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。
关于这个语法的更多详细用法讨论,请参见参考资料 [7]。
8.7 类数据成员的默认初始化
按照 C++98 的语法,数据成员可以在构造函数里进行初始化。这本身不是问题,但实践中,如果数据成员比较多、构造函数又有多个的话,逐个去初始化是个累赘,并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此,C++11 增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。这样,当且仅当构造函数的初始化列表中不包含该数据成员时,这个数据成员就会自动使用初始化表达式进行初始化。
1 | class Complex { |
8.8 参考资料
- cppreference.com, “Placeholder type specifiers”. https://en.cppreference.com/w/cpp/language/auto
- Wikipedia, “Argument-dependent name lookup”. https://en.wikipedia.org/wiki/Argument-dependent_name_lookup
- cppreference.com, “Template argument deduction”. https://en.cppreference.com/w/cpp/language/template_argument_deduction
- cppreference.com, “Class template argument deduction”. https://en.cppreference.com/w/cpp/language/class_template_argument_deduction
- cppreference.com, “Structured binding declaration”. https://en.cppreference.com/w/cpp/language/structured_binding
- cppreference.com, “std::initializer_list”. https://en.cppreference.com/w/cpp/utility/initializer_list
- Scott Meyers,Effective Modern C++, item 7. O’Reilly Media, 2014. 有中文版(高博译,中国电力出版社,2018 年)
02丨提高篇
10 | 到底应不应该返回对象?
10.1 F.20
《C++ 核心指南》的 F.20 这一条款是这么说的 [1]:
F.20: For “out” output values, prefer return values to output parameters
翻译一下:
在函数输出数值时,尽量使用返回值而非输出参数
10.2 如何返回一个对象?
一个用来返回的对象,通常应当是可移动构造 / 赋值的,一般也同时是可拷贝构造 / 赋值的。如果这样一个对象同时又可以默认构造,我们就称其为一个**半正则(semiregular)**的对象。如果可能的话,我们应当尽量让我们的类满足半正则这个要求。
半正则意味着我们的 matrix 类提供下面的成员函数:
1 | class matrix { |
我们先看一下在没有返回值优化的情况下 C++ 是怎样返回对象的。以矩阵乘法为例,代码应该像下面这样:
1 | matrix operator*(const matrix& lhs, const matrix& rhs) |
在 [第 3 讲] 里说过的,返回非引用类型的表达式结果是个纯右值(prvalue)。在执行 auto r = … 的时候,编译器会认为我们实际是在构造 matrix r(…),而”…”部分是一个纯右值。因此编译器会首先试图匹配 matrix(matrix&&),在没有时则试图匹配 matrix(const matrix&);也就是说,有移动支持时使用移动,没有移动支持时则拷贝。
10.3 返回值优化(拷贝消除)
我们再来看一个能显示生命期过程的对象的例子:
1 |
|
如果你认为执行结果里应当有一行”Copy A”或”Move A”的话,你就忽视了返回值优化的威力了。即使完全关闭优化,三种主流编译器(GCC、Clang 和 MSVC)都只输出两行:
1 | Create A |
我们把代码稍稍改一下:
1 | A getA_named() |
这回结果有了一点点小变化。虽然 GCC 和 Clang 的结果完全不变,但 MSVC 在非优化编译的情况下产生了不同的输出(优化编译——使用命令行参数 /O1、/O2 或 /Ox——则不变):
1 | Create A |
也就是说,返回内容被移动构造了。
我们继续变形一下:
1 |
|
这回所有的编译器都被难倒了,输出是:
1 | Create A |
关于返回值优化的实验我们就做到这里。下一步,我们试验一下把移动构造函数删除:
1 | A(A&&) = delete; |
我们可以立即看到”Copy A”出现在了结果输出中,说明目前结果变成拷贝构造了。
如果再进一步,把拷贝构造函数也删除呢?是不是上面的 getA_unnamed、getA_named 和 getA_duang 都不能工作了?
在 C++14 及之前确实是这样的。但从 C++17 开始,对于类似于 getA_unnamed 这样的情况,即使对象不可拷贝、不可移动,这个对象仍然是可以被返回的!C++17 要求对于这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动的步骤 [3]。
综上,还是编译器的问题:
在C++11及之后的标准中,当编译器需要返回一个局部对象时,优先会尝试使用移动语义来优化返回值,这样可以避免不必要的拷贝。如果移动构造函数被删除(或者没有定义),编译器会回退到拷贝构造函数。然而,由于返回的是一个临时对象,如果不能使用移动构造函数,编译器不能自动退回到拷贝构造函数。
10.4 回到 F.20
理解了 C++ 里的对返回值的处理和返回值优化之后,我们再回过头看一下 F.20 里陈述的理由的话,应该就显得很自然了:
A return value is self-documenting, whereas a & could be either in-out or outonly and is liable to be misused.
返回值是可以自我描述的;而 & 参数既可能是输入输出,也可能是仅输出,且很容易被误用。
我想我对返回对象的可读性,已经给出了充足的例子。对于其是否有性能影响这一问题,也给出了充分的说明。
我们最后看一下 F.20 里描述的例外情况:
- “对于非值类型,比如返回值可能是子对象的情况,使用 unique_ptr 或 shared_ptr 来返回对象。”也就是面向对象、工厂方法这样的情况,像 [第 1 讲] 里给出的 create_shape 应该这样改造。
- “对于移动代价很高的对象,考虑将其分配在堆上,然后返回一个句柄(如 unique_ptr),或传递一个非 const 的目标对象的引用来填充(用作输出参数)。”也就是说不方便移动的,那就只能使用一个 RAII 对象来管理生命周期,或者老办法输出参数了。
- “要在一个内层循环里在多次函数调用中重用一个自带容量的对象:将其当作输入 / 输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。
10.5 参考资料
- Bjarne Stroustrup and Herb Sutter (editors), “C++ core guidelines”, item F.20. https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-out (非官方中文版可参见 https://github.com/lynnboy/CppCoreGuidelines-zh-CN)
- Conrad Sanderson and Ryan Curtin, Armadillo. http://arma.sourceforge.net/
- cppreference.com, “Copy elision”. https://en.cppreference.com/w/cpp/language/copy_elision
11 | Unicode:进入多文字支持的世界
11.1 一些历史
ASCII [1] 是一种创立于 1963 年的 7 位编码,用 0 到 127 之间的数值来代表最常用的字符,包含了控制字符(很多在今天已不再使用)、数字、大小写拉丁字母、空格和基本标点。它在编码上具有简单性,字母和数字的编码位置非常容易记忆。时至今日,ASCII 可以看作是字符编码的基础,主要的编码方式都保持着与 ASCII 的兼容性。
ASCII 里只有基本的拉丁字母,它既没有带变音符的拉丁字母(如 é 和 ä ),也不支持像希腊字母(如 α、β、γ)、西里尔字母(如 Пушкин)这样的其他欧洲文字(也难怪,毕竟它是 American Standard Code for Information Interchange)。很多其他编码方式纷纷应运而生,包括 ISO 646 系列、ISO/IEC 8859 系列等等;大部分编码方式都是头 128 个字符与 ASCII 兼容,后 128 个字符是自己的扩展,总共最多是 256 个字符。每次只有一套方式可以生效,称之为一个代码页(code page)。这种做法,只能适用于文字相近、且字符数不多的国家。比如,下图表示了 ISO-8859-1(也称作 Latin-1)和后面的 Windows 扩展代码页 1252(下图中绿框部分为 Windows 的扩展),就只能适用于西欧国家。
最早的中文字符集标准是 1980 年的国标 GB2312 [3],其中收录了 6763 个常用汉字和 682 个其他符号。我们平时会用到编码 GB2312,其实更正确的名字是 EUC-CN [4],它是一种与 ASCII 兼容的编码方式。它用单字节表示 ASCII 字符而用双字节表示 GB2312 中的字符;由于 GB2312 中本身也含有 ASCII 中包含的字符,在使用中逐渐就形成了”半角”和”全角”的区别。
国标字符集后面又有扩展,这个扩展后的字符集就是 GBK [5],是中文版 Windows 使用的标准编码方式。GB2312 和 GBK 所占用的编码位置可以参看下面的图(由 John M. Długosz 为 Wikipedia 绘制):
图中 GBK/1 和 GBK/2 为 GB2312 中已经定义的区域,其他的则是后面添加的字符,总共定义了两万多个编码点,支持了绝大部分现代汉语中还在使用的字。
Unicode [6] 作为一种统一编码的努力,诞生于八十年代末九十年代初,标准的第一版出版于 1991—1992 年。由于最初发明者的目标放得太低,只期望对活跃使用中的现代文字进行编码,他们认为 16 比特的”宽 ASCII”就够用了。这就导致了早期采纳 Unicode 的组织,特别是微软,在其操作系统和工具链中广泛采用了 16 比特的编码方式。在今天,微软的系统中宽字符类型 wchar_t 仍然是 16 位的,操作系统底层接口大量使用 16 位字符编码的 API,说到 Unicode 编码时仍然指的是 16 位的编码 UTF-16(这一不太正确的名字,跟中文 GBK 编码居然可以被叫做 ANSI 相比,实在是小巫见大巫了)。在微软以外的世界, Unicode 本身不作编码名称用,并且最主流的编码方式并不是 UTF-16,而是和 ASCII 全兼容的 UTF-8。
早期 Unicode 组织的另一个决定是不同语言里的同一个字符使用同一个编码点,来减少总编码点的数量。中日韩三国使用的汉字就这么被统一了:像”将”、”径”、”网”等字,每个字在 Unicode 中只占一个编码点。这对网页的字体选择也造成了不少麻烦,时至今日我们仍然可以看到这个问题 [10]。不过这和我们的主题无关,就不再多费笔墨了。
11.2 Unicode 简介
Unicode 在今天已经大大超出了最初的目标。到 Unicode 12.1 为止,Unicode 已经包含了 137,994 个字符,囊括所有主要语言(使用中的和已经不再使用的),并包含了表情符号、数学符号等各种特殊字符。仍然要指出一下,Unicode 字符是根据含义来区分的,而非根据字形。除了前面提到过中日韩汉字没有分开,像斜体(italics)、小大写字母(small caps)等排版效果在 Unicode 里也没有独立的对应。不过,因为 Unicode 里包含了很多数学、物理等自然科学中使用的特殊符号,某些情况下你也可以找到对应的符号,可以用在聊天中耍酷,如 𝒷𝒶𝒹(但不适合严肃的排版)。
Unicode 的编码点是从 0x0 到 0x10FFFF,一共 1,114,112 个位置。一般用”U+”后面跟 16 进制的数值来表示一个 Unicode 字符,如 U+0020 表示空格,U+6C49 表示”汉”,U+1F600 表示”😀”,等等(不足四位的一般写四位)。
Unicode 字符的常见编码方式有:
- UTF-32 [7]:32 比特,是编码点的直接映射。
- UTF-16 [8]:对于从 U+0000 到 U+FFFF 的字符,使用 16 比特的直接映射;对于大于 U+FFFF 的字符,使用 32 比特的特殊映射关系——在 Unicode 的 16 比特编码点中 0xD800–0xDFFF 是一段空隙,使得这种变长编码成为可能。在一个 UTF-16 的序列中,如果看到内容是 0xD800–0xDBFF,那这就是 32 比特编码的前 16 比特;如果看到内容是 0xDC00–0xDFFF,那这是 32 比特编码的后 16 比特;如果内容在 0xD800–0xDFFF 之外,那就是一个 16 比特的映射。
- UTF-8 [9]:1 到 4 字节的变长编码。在一个合法的 UTF-8 的序列中,如果看到一个字节的最高位是 0,那就是一个单字节的 Unicode 字符;如果一个字节的最高两比特是 10,那这是一个 Unicode 字符在编码后的后续字节;否则,这就是一个 Unicode 字符在编码后的首字节,且最高位开始连续 1 的个数表示了这个字符按 UTF-8 的方式编码有几个字节。
在上面三种编码方式里,只有 UTF-8 完全保持了和 ASCII 的兼容性,目前得到了最广泛的使用。在我们下面讲具体编码方式之前,我们先看一下上面提到的三个字符在这三种方式下的编码结果:
- UTF-32:U+0020 映射为 0x00000020,U+6C49 映射为 0x00006C49,U+1F600 映射为 0x0001F600。
- UTF-16:U+0020 映射为 0x0020,U+6C49 映射为 0x6C49,而 U+1F600 会映射为 0xD83D DE00。
- UTF-8:U+0020 映射为 0x20,U+6C49 映射为 0xE6 B1 89,而 U+1F600 会映射为 0xF0 9F 98 80。
Unicode 有好几种(上面还不是全部)不同的编码方式,上面的 16 比特和 32 比特编码方式还有小头党和大头党之争(”汉”按字节读取时是 6C 49 呢,还是 49 6C?);同时,任何一种编码方式还需要跟传统的编码方式容易区分。因此,Unicode 文本文件通常有一个使用 BOM(byte order mark)字符的约定,即字符 U+FEFF [11]。由于 Unicode 不使用 U+FFFE,在文件开头加一个 BOM 即可区分各种不同编码:
- 如果文件开头是 0x00 00 FE FF,那这是大头在前的 UTF-32 编码;
- 否则如果文件开头是 0xFF FE 00 00,那这是小头在前的 UTF-32 编码;
- 否则如果文件开头是 0xFE FF,那这是大头在前的 UTF-16 编码;
- 否则如果文件开头是 0xFF FE,那这是小头在前的 UTF-16 编码(注意,这条规则和第二条的顺序不能相反);
- 否则如果文件开头是 0xEF BB BF,那这是 UTF-8 编码;
- 否则,编码方式使用其他算法来确定。
在 UTF-8 编码下使用 BOM 字符并非必需,尤其在 Unix 上。但 Windows 上通常会使用 BOM 字符,以方便区分 UTF-8 和传统编码。
总结:
Unicode 是一种字符编码标准。
-
UTF-8: 可变长度编码,使用1到4个字节表示一个字符。向后兼容ASCII,是目前最广泛使用的Unicode编码。
-
UTF-16: 可变长度编码,使用2或4个字节表示一个字符。常用于操作系统和编程语言的内部表示。
-
UTF-32: 固定长度编码,使用4个字节表示一个字符。虽然内存占用较大,但便于字符处理。
字符编码是将字符映射为计算机可处理的数字表示的规则,用于在计算机中存储和传输文本。
11.3 C++ 中的 Unicode 字符类型
C++98 中有 char 和 wchar_t 两种不同的字符类型,其中 char 的长度是单字节,而 wchar_t 的长度不确定。在 Windows 上它是双字节,只能代表 UTF-16,而在 Unix 上一般是四字节,可以代表 UTF-32。为了解决这种混乱,目前我们有了下面的改进:
- C++11 引入了 char16_t 和 char32_t 两个独立的字符类型(不是类型别名),分别代表 UTF-16 和 UTF-32。
- C++20 将引入 char8_t 类型,进一步区分了可能使用传统编码的窄字符类型和 UTF-8 字符类型。
- 除了 string 和 wstring,我们也相应地有了 u16string、u32string(和将来的 u8string)。
- 除了传统的窄字符 / 字符串字面量(如 “hi”)和宽字符 / 字符串字面量(如 L”hi”),引入了新的 UTF-8、UTF-16 和 UTF-32 字面量,分别形如 u8”hi”、u”hi” 和 U”hi”。
- 为了确保非 ASCII 字符在源代码中可以简单地输入,引入了新的 Unicode 换码序列。比如,我们前面说到的三个字符可以这样表达成一个 UTF-32 字符串字面量:U”\u6C49\U0001F600”。要生成 UTF-16 或 UTF-8 字符串字面量只需要更改前缀即可。
使用这些新的字符(串)类型,我们可以用下面的代码表达出 UTF-32 和其他两种 UTF 编码间是如何转换的:
1 |
|
输出结果是:
1 | 0020 6c49 d83d de00 |
11.4 平台区别
下面我们看一下在两个主流的平台上一般是如何处理 Unicode 编码问题的。
-
Unix
现代 Unix 系统,包括 Linux 和 macOS 在内,已经全面转向了 UTF-8。这样的系统中一般直接使用 char[] 和 string 来代表 UTF-8 字符串,包括输入、输出和文件名,非常简单。不过,由于一个字符单位不能代表一个完整的 Unicode 字符,在需要真正进行文字处理的场合转换到 UTF-32 往往会更简单。在以前及需要和 C 兼容的场合,会使用 wchar_t、uint32_t 或某个等价的类型别名;在新的纯 C++ 代码里,就没有理由不使用 char32_t 和 u32string 了。
Unix 下输出宽字符串需要使用 wcout(这点和 Windows 相同),并且需要进行区域设置,通常使用 setlocale(LC_ALL, “en_US.UTF-8”); 即足够。由于没有什么额外好处,Unix 平台下一般只用 cout,不用 wcout。
-
Windows
Windows 由于历史原因和保留向后兼容性的需要(Windows 为了向后兼容性已经到了大规模放弃优雅的程度了),一直用 char 表示传统编码(如,英文 Windows 上是 Windows-1252,简体中文 Windows 上是 GBK),用 wchar_t 表示 UTF-16。由于传统编码一次只有一种、且需要重启才能生效,要得到好的多语言支持,在和操作系统交互时必须使用 UTF-16。
对于纯 Windows 编程,全面使用宽字符(串)是最简单的处理方式。当然,源代码和文本很少用 UTF-16 存储,通常还是 UTF-8(除非是纯 ASCII,否则需要加入 BOM 字符来和传统编码相区分)。这时可能会有一个小小的令人惊讶的地方:微软的编译器会把源代码里窄字符串字面量中的非 ASCII 字符转换成传统编码。换句话说,同样的源代码在不同编码的 Windows 下编译可能会产生不同的结果!如果你希望保留 UTF-8 序列的话,就应该使用 UTF-8 字面量(并在将来使用 char8_t 字符类型)。
cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
void dump(const T& str)
{
for (char ch : str) {
printf("%.2x ", static_cast<unsigned char>(ch));
}
putchar('\n');
}
int main()
{
char str[] = "你好";
char u8str[] = u8"你好";
dump(str);
dump(u8str);
}下面展示的是以上代码在 Windows 下系统传统编码设置为简体中文时的编译、运行结果:
text
1
2c4 e3 ba c3 00
e4 bd a0 e5 a5 bd 00Windows 下的 wcout 主要用在配合宽字符的输出,此外没什么大用处。原因一样,只有进行了正确的区域设置,才能输出含非 ASCII 字符的宽字符串。如果要输出中文,得写 setlocale(LC_ALL, “Chinese_China.936”);,这显然就让”统一码”输出失去意义了。
由于窄字符在大部分 Windows 系统上只支持传统编码,要打开一个当前编码不支持的文件名称,就必需使用宽字符的文件名。微软的 fstream 系列类及其 open 成员函数都支持 const wchar_t* 类型的文件名,这是 C++ 标准里所没有的。
11.5 统一化处理
要想写出跨平台的处理字符串的代码,我们一般考虑两种方式之一:
- 源代码级兼容,但内码不同
- 源代码和内码都完全兼容
微软推荐的方式一般是前者。做 Windows 开发的人很多都知道 tchar.h 和 _T 宏,它们就起着类似的作用(虽然目的不同)。根据预定义宏的不同,系统会在同一套代码下选择不同的编码方式及对应的函数。拿一个最小的例子来说:
1 |
|
如果用缺省的命令行参数进行编译,上面的代码相当于:
1 |
|
而如果在命令行上加上了 /D_UNICODE,那代码则相当于:
1 |
|
当然,这个代码还是只能在 Windows 上用,并且仍然不漂亮(所有的字符和字符串字面量都得套上 _T)。后者无解,前者则可以找到替代方案(甚至自己写也不复杂)。C++ REST SDK 中就提供了类似的封装,可以跨平台地开发网络应用。但可以说,这种方式是一种主要照顾 Windows 的开发方式。
相应的,对 Unix 开发者而言更自然的方式是全面使用 UTF-8,仅在跟操作系统、文件系统打交道时把字符串转换成需要的编码。利用临时对象的生命周期,我们可以像下面这样写帮助函数和宏。
utf8_to_native.hpp:
1 |
|
utf8_to_native.cpp:
1 |
|
在头文件里,定义了在 Windows 下会做 UTF-8 到 UTF-16 的转换;在其他环境下则不真正做转换,而是不管提供的是字符指针还是 string 都会转换成字符指针。在 Windows 下每次调用 NATIVE_STR 会生成一个临时对象,当前语句执行结束后这个临时对象会自动销毁。
使用该功能的代码是这样的:
1 |
|
上面这样的代码可以同时适用于现代 Unix 和现代 Windows(任何语言设置下),用来读取名为”测试.txt”的文件。
11.6 编程支持
快速介绍一下其他的一些支持 Unicode 及其转换的 API。
-
Windows API
上一节的代码在 Windows 下用到了 MultiByteToWideChar [12],从某个编码转到 UTF-16。Windows 也提供了反向的 WideCharToMultiByte [13],从 UTF-16 转到某个编码。从上面可以看到,C 接口用起来并不方便,可以考虑自己封装一下。
-
iconv
Unix 下最常用的底层编码转换接口是 iconv [14],提供 iconv_open、iconv_close 和 iconv 三个函数。这同样是 C 接口,实践中应该封装一下。
-
ICU4C
ICU [15] 是一个完整的 Unicode 支持库,提供大量的方法,ICU4C 是其 C/C++ 的版本。ICU 有专门的字符串类型,内码是 UTF-16,但可以直接用于 IO streams 的输出。下面的程序应该在所有平台上都有同样的输出(但在 Windows 上要求当前系统传统编码能支持待输出的字符):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using namespace std;
using icu::UnicodeString;
int main()
{
auto str = UnicodeString::fromUTF8(u8"你好");
cout << str << endl;
string u8str;
str.toUTF8String(u8str);
cout << "In UTF-8 it is " << u8str.size() << " bytes" << endl;
} -
codecvt
C++11 曾经引入了一个头文件
<codecvt>
[16] 用作 UTF 编码间的转换,但很遗憾,那个头文件目前已因为存在安全性和易用性问题被宣告放弃(deprecated)[17]。<locale>
中有另外一个 codecvt 模板 [18],本身接口不那么好用,而且到 C++20 还会发生变化,这儿也不详细介绍了。有兴趣的话可以直接看参考资料。
11.7 参考资料
- Wikipedia, “ASCII”. https://en.wikipedia.org/wiki/ASCII
- Wikipedia, “EBCDIC”. https://en.wikipedia.org/wiki/EBCDIC
- Wikipedia, “GB 2312”. https://en.wikipedia.org/wiki/GB_2312
- Wikipedia, “EUC-CN”. https://en.wikipedia.org/wiki/Extended_Unix_Code#EUC-CN
- Wikipedia, “GBK”. https://en.wikipedia.org/wiki/GBK_(character_encoding)
- Wikipedia, “Unicode”. https://en.wikipedia.org/wiki/Unicode
- Wikipedia, “UTF-32”. https://en.wikipedia.org/wiki/UTF-32
- Wikipedia, “UTF-16”. https://en.wikipedia.org/wiki/UTF-16
- Wikipedia, “UTF-8”. https://en.wikipedia.org/wiki/UTF-8
- 吴咏炜, “Specify LANG in a UTF-8 web page”. http://wyw.dcweb.cn/lang_utf8.htm
- Wikipedia, “Byte order mark”. https://en.wikipedia.org/wiki/Byte_order_mark
- Microsoft, “MultiByteToWideChar function”.https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nfstringapiset-multibytetowidechar
- Microsoft, “WideCharToMultiByte function”.https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nfstringapiset-widechartomultibyte
- Wikipedia, “iconv”. https://en.wikipedia.org/wiki/Iconv
- ICU Technical Committee, ICU—International Components for Unicode. http://site.icu-project.org/
- cppreference.com, “
Standard library header <codecvt>
“. https://en.cppreference.com/w/cpp/header/codecvt - Alisdair Meredith, “
Deprecating <codecvt>
“. http://www.openstd.org/jtc1/sc22/wg21/docs/papers/2017/p0618r0.html - cppreference.com, “std::codecvt”. https://en.cppreference.com/w/cpp/locale/codecvt
12 | 编译期多态:泛型编程和模板入门
12.1 面向对象和多态
在面向对象的开发里,最基本的一个特性就是”多态” [1]——用相同的代码得到不同结果。
在很多动态类型语言里,有所谓的”鸭子”类型 [2]:
如果一只鸟走起来像鸭子、游起泳来像鸭子、叫起来也像鸭子,那么这只鸟就可以被当作鸭子。
鸭子类型使得开发者可以不使用继承体系来灵活地实现一些”约定”,尤其是使得混合不同来源、使用不同对象继承体系的代码成为可能。唯一的要求只是,这些不同的对象有”共通”的成员函数。这些成员函数应当有相同的名字和相同结构的参数(并不要求参数类型相同)。
来看一下 C++ 中的具体例子。
12.2 容器类的共性
容器类是有很多共性的。其中,一个最最普遍的共性就是,容器类都有 begin 和 end 成员函数——这使得通用地遍历一个容器成为可能。容器类不必继承一个共同的 Container 基类,而我们仍然可以写出通用的遍历容器的代码,如使用基于范围的循环。
大部分容器是有 size 成员函数的,在”泛型”编程中,我们同样可以取得一个容器的大小,而不要求容器继承一个叫 SizeableContainer 的基类。
很多容器具有 push_back 成员函数,可以在尾部插入数据。同样,我们不需要一个叫 BackPushableContainer 的基类。在这个例子里,push_back 函数的参数显然是都不一样的,但明显,所有的 push_back 函数都只接收一个参数。
我们可以清晰看到的是,虽然 C++ 的标准容器没有对象继承关系,但彼此之间有着很多的同构性。这些同构性很难用继承体系来表达,也完全不必要用继承来表达。C++ 的模板,已经足够表达这些鸭子类型。
12.3 C++ 模板
-
定义模板
1
2
3
4
5
6
7
8
9
10template <typename E>
E my_gcd(E a, E b)
{
while (b != E(0)) {
E r = a % b;
a = b;
b = r;
}
return a;
}我们对于”整数”这只鸭子的要求实际上是:
-
可以通过常量 0 来构造
-
可以拷贝(构造和赋值)
-
可以作不等于的比较
-
可以进行取余数的操作
对于标准的 int、long、long long 等类型及其对应的无符号类型,以上代码都能正常工作,并能得到正确的结果。
至于类模板的例子,我们可以直接参考 [第 2 讲] 中的智能指针。
-
-
实例化模板
不管是类模板还是函数模板,编译器在看到其定义时只能做最基本的语法检查,真正的类型检查要在实例化(instantiation)的时候才能做。一般而言,这也是编译器会报错的时候。
当我们在使用
vector<int>
这样的表达式时,我们就在隐式地实例化vector<int>
。我们同样也可以选择用template class vector<int>
; 来显式实例化,或使用extern template class vector<int>
; 来告诉编译器不需要实例化。显式实例化和外部实例化通常在大型项目中可以用来集中模板的实例化,从而加速编译过程——不需要在每个用到模板的地方都进行实例化了——但这种方式有额外的管理开销,如果实例化了不必要实例化的模板的话,反而会导致可执行文件变大。因而,显式实例化和外部实例化应当谨慎使用。 -
特化模板
通用而言,Herb Sutter 给出了明确的建议:对函数使用重载,对类模板进行特化 [3]。
展示特化的更好的例子是 C++11 之前的静态断言。使用特化技巧可以大致实现 static_assert 的功能:
1
2
3
4
5
6
7
8
9
10
11
12
13template <bool>
struct compile_time_error;
template <>
struct compile_time_error<true> {
};上面首先声明了一个 struct 模板,然后仅对 true 的情况进行了特化,产生了一个 struct 的定义。这样。如果遇到
compile_time_error<false>
的情况——也就是下面静态断言里的 Expr 不为真的情况——编译就会失败报错,因为compile_time_error<false>
从来就没有被定义过。
12.4 “动态”多态和”静态”多态的对比
我前面描述了面向对象的”动态”多态,也描述了 C++ 里基于泛型编程的”静态”多态。需要看到的是,两者解决的实际上是不太一样的问题。”动态”多态解决的是运行时的行为
变化。”静态”多态或者”泛型”——解决的是很不同的问题,让适用于不同类型的”同构”算法可以用同一套代码来实现,实际上强调的是对代码的复用。
C++ 里提供了很多标准算法,都一样只作出了基本的约定,然后对任何满足约定的类型都可以工作。以排序为例,C++ 里的标准 sort 算法(以两参数的重载为例)只要求:
- 参数满足随机访问迭代器的要求。
- 迭代器指向的对象之间可以使用 < 来比较大小,满足严格弱序关系。
- 迭代器指向的对象可以被移动。
它的性能超出 C 的 qsort,因为编译器可以内联(inline)对象的比较操作;而在 C 里面比较只能通过一个额外的函数调用来实现。此外,C 的 qsort 函数要求数组指向的内容是可按比特复制的,C++ 的 sort 则要求迭代器指向的内容是可移动的,可适用于更广的情况。
12.5 参考资料
[1] Wikipedia, “Polymorphism”. https://en.wikipedia.org/wiki/Polymorphism_(computer_science)
[2] Wikipedia, “Duck typing”. https://en.wikipedia.org/wiki/Duck_typing
[3] Herb Sutter, “Why not specialize function templates?”. http://www.gotw.ca/publications/mill17.htm
13 | 编译期能做些什么?一个完整的计算世界
模板的另外一种重要用途——编译期计算,也称作”模板元编程”。
介绍:模板元编程(Template Metaprogramming)能够在编译期计算出结果的原因在于它的定义和执行方式。C++ 编译器在编译期间解析和实例化模板,这使得模板的计算可以在代码生成之前完成。这种机制允许我们在编译期执行一些计算和逻辑操作,而不是等到运行期。
先简单来看几个模板的例子:
1. 编译期展开
当编译器遇到模板时,它会根据具体的模板参数展开模板并生成相应的代码。这个过程发生在编译期,因此所有涉及模板的计算也在编译期完成。
1 | template <int N> |
2. 静态常量表达式
模板元编程中的计算结果通常存储在 constexpr
或 static const
变量中,这些变量在编译期就可以确定它们的值。
1 | template <int N> |
3. 模板递归
模板元编程中的递归展开机制允许编译器在编译期进行复杂的递归计算。例如,计算斐波那契数列:
1 | template <int N> |
在编译期,编译器将递归展开 Fibonacci<10>
的计算,最终得到结果 55
。
4. 类型元编程
除了数值计算,模板元编程还支持类型级别的计算。编译器在编译期通过模板生成和操作类型。例如,条件选择类型:
1 | template <bool Condition, typename TrueType, typename FalseType> |
模板元编程能够在编译期计算出结果的原因在于:
- 编译期展开:模板在编译期间实例化和展开。
- 静态常量表达式:使用
constexpr
或static const
变量,计算结果在编译期确定。 - 模板递归:允许复杂的递归计算在编译期进行。
- 类型元编程:支持在编译期进行类型级别的计算和选择。
13.1 编译期计算
首先,我们给出一个已经被证明的结论:C++ 模板是图灵完全的 [1]。这句话的意思是,使用 C++ 模板,你可以在编译期间模拟一个完整的图灵机,也就是说,可以完成任何的计算任务。
1 | template <int n> |
上面定义了一个递归的阶乘函数。
那我们怎么知道这个计算是不是在编译时做的呢?我们可以直接看编译输出。下面直接贴出对上面这样的代码加输出(printf("%d\n", factorial<10>::value);
)在 x86-64 下的编译结果:
1 | .LC0: |
我们可以明确看到,编译结果里明明白白直接出现了常量 3628800。
可以看到,要进行编译期编程,最主要的一点,是需要把计算转变成类型推导。比如,下面的模板可以代表条件语句:
1 | template <bool cond, typename Then, typename Else> |
If 模板有三个参数,第一个是布尔值,后面两个则是代表不同分支计算的类型,这个类型可以是我们上面定义的任何一个模板实例,包括 If 和 factorial。第一个 struct 声明规定了模板的形式,然后我们不提供通用定义,而是提供了两个特化。第一个特化是真的情况,定义结果 type 为 Then 分支;第二个特化是假的情况,定义结果 type 为 Else 分支。
循环:
1 | template <bool condition, typename Body> |
这个循环的模板定义稍复杂点。首先,我们对循环体类型有一个约定,它必须提供一个静态数据成员,cond_value,及两个子类型定义,res_type 和 next_type:
- cond_value 代表循环的条件(真或假)
- res_type 代表退出循环时的状态
- next_type 代表下面循环执行一次时的状态
如果你之前模板用得不多的话,还有一个需要了解的细节,就是用 :: 取一个成员类型、并且 :: 左边有模板参数的话,得额外加上 typename 关键字来标明结果是一个类型。上面循环模板的定义里就出现了多次这样的语法。MSVC 在这方面往往比较宽松,不写 typename 也不会报错,但这是不符合 C++ 标准的用法。
为了进行计算,我们还需要通用的代表数值的类型。下面这个模板可以通用地代表一个整数常数:
1 | template <class T, T v> |
integral_constant 模板同时包含了整数的类型和数值,而通过这个类型的 value 成员我们又可以重新取回这个数值。有了这个模板的帮忙,我们就可以进行一些更通用的计算了。下面这个模板展示了如何使用循环模板来完成从 1 加到 n 的计算:
1 | template <int result, int n> |
然后你使用 While<Sum<10>::type>::type::value
就可以得到 1 加到 10 的结果。虽然有点绕,但代码实质就是在编译期间进行了以下的计算:
1 | int result = 0; |
13.2 编译期类型推导
C++ 标准库在 <type_traits>
头文件里定义了很多工具类模板,用来提取某个类型(type)在某方面的特点(trait)[2]。和上一节给出的例子相似,这些特点既是类型,又是常值。
为了方便地在值和类型之间转换,标准库定义了一些经常需要用到的工具类。上面描述的 integral_constant 就是其中一个(我的定义有所简化)。为了方便使用,针对布尔值有两个额外的类型定义:
1 | typedef std::integral_constant<bool, true> true_type; |
这两个标准类型 true_type 和 false_type 经常可以在函数重载中见到。有一个工具函数常常会写成下面这个样子:
1 | template <typename T> |
类似上面,很多容器类里会有一个 destroy 函数,通过指针来析构某个对象。为了确保最大程度的优化,常用的一个技巧就是用 is_trivially_destructible 模板来判断类是否是可平凡析构的——也就是说,不调用析构函数,不会造成任何资源泄漏问题。模板返回的结果还是一个类,要么是 true_type,要么是 false_type。如果要得到布尔值的话,当然使用 is_trivially_destructible<T>::value
就可以,但此处不需要。我们需要的是,使用 () 调用该类型的构造函数,让编译器根据数值类型来选择合适的重载。这样,在优化编译的情况下,编译器可以把不需要的析构操作彻底全部删除。
除了得到布尔值和相对应的类型的 trait 模板,我们还有另外一些模板,可以用来做一些类型的转换。以一个常见的模板 remove_const 为例(用来去除类型里的 const 修饰),它的定义大致如下:
1 | template <class T> |
同样,它也是利用模板的特化,针对 const 类型去掉相应的修饰。比如,如果我们对 const string& 应用 remove_const,就会得到 string&,即, remove_const<const string&>::type
等价于 string&。
这里有一个细节你要注意一下,如果对 const char*
应用 remove_const 的话,结果还是 const char*
。原因是,const char*
是指向 const char 的指针,而不是指向 char 的 const 指针。如果我们对 char * const
应用 remove_const 的话,还是可以得到 char*
的。
13.3 简易写法
如果你觉得写 is_trivially_destructible<T>::value
和 remove_const<T>::type
非常啰嗦的话,那你绝不是一个人。在当前的 C++ 标准里,前者有增加 _v
的编译时常量,后者有增加 _t
的类型别名:
1 | template <class T> |
至于什么是 constexpr,我们会单独讲。using 是现代 C++ 的新语法,功能大致与 typedef 相似,但 typedef 只能针对某个特定的类型,而 using 可以生成别名模板。目前我们只需要知道,在你需要 trait 模板的结果数值和类型时,使用带 _v
和 _t
后缀的模板可能会更方便,尤其是带 _t
后缀的类型转换模板。
13.4 通用的 fmap 函数模板
你应当多多少少听到过 map-reduce。抛开其目前在大数据应用中的具体方式不谈,从概念本源来看,map [3] 和 reduce [4] 都来自函数式编程。下面我们演示一个 map 函数(当然,在 C++ 里它的名字就不能叫 map 了),其中用到了目前为止我们学到的多个知识点:
1 | template < |
我们:
- 用 decltype 来获得用 f 来调用 inputs 元素的类型(参考第 8 讲);
- 用 decay_t 来把获得的类型变成一个普通的值类型;
- 缺省使用 vector 作为返回值的容器,但可以通过模板参数改为其他容器;
- 使用基于范围的 for 循环来遍历 inputs,对其类型不作其他要求(参考第 7 讲);
- 存放结果的容器需要支持 push_back 成员函数(参考第 4 讲)。
下面的代码可以验证其功能:
1 | vector<int> v { 1, 2, 3, 4, 5 }; |
在 fmap 执行之后,我们会在 result 里得到一个新容器,其内容是 2, 3, 4, 5, 6。
举例:
1 |
|
template <typename, typename> class OutContainer = vector
:这是一个模板模板参数,表示返回的容器类型。默认情况下,它是 std::vector
。它接收两个模板参数:元素类型和分配器类型。
13.5 参考资料
- Todd L. Veldhuizen, “C++ templates are Turing complete”. http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.3670
- cppreference.com, “
Standard library header <type_traits>
“. https://en.cppreference.com/w/cpp/header/type_traits - Wikipedia, “Map (higher-order function)”. https://en.wikipedia.org/wiki/Map_(higher-order_function)
- Wikipedia, “Fold (higher-order function)”. https://en.wikipedia.org/wiki/Fold_(higher-order_function)
14 | SFINAE:不是错误的替换失败是怎么回事?
讲模板里的一个特殊概念——替换失败非错(substituion failure is not an error),英文简称为SFINAE。
在开始之前,先简单看看概念。
SFINAE(替换失败非错误)是 C++ 模板元编程中的一个特殊概念,它指的是当模板参数替换失败时,编译器不会报错,而是会忽略这个模板并尝试其他可能的重载。
具体说明
在函数模板的重载决议过程中,当一个函数名称和某个函数模板名称匹配时,编译器会:
- 根据名称找出所有适用的函数和函数模板。
- 对于适用的函数模板,根据实际参数对模板形参进行替换。如果在替换过程中发生错误,这个模板会被丢弃,而不是报错。
- 在上面两步生成的可行函数集合中,编译器会寻找一个最佳匹配并调用它。
- 如果没有找到最佳匹配,或者找到多个匹配程度相当的函数,编译器会报错。
举个例子:假设我们有两个模板函数,一个用于处理整型,一个用于处理其他类型。在这两个函数模板中,我们使用 SFINAE 技术来决定哪个模板是适用的。
1 |
|
SFINAE 常用于模板元编程,以提供以下功能:
- 函数模板重载:根据模板参数的特性选择合适的模板实例化。
- 类型特征检测:通过检测类型特性(如是否为整型)来选择不同的实现。
- 实现条件编译:根据不同的类型特性或编译条件,选择不同的模板代码路径。
14.1 函数模板的重载决议
今天来着重看一个函数模板的情况。当一个函数名称和某个函数模板名称匹配时,重载决议过程大致如下:
- 根据名称找出所有适用的函数和函数模板
- 对于适用的函数模板,要根据实际情况对模板形参进行替换;替换过程中如果发生错误,这个模板会被丢弃
- 在上面两步生成的可行函数集合中,编译器会寻找一个最佳匹配,产生对该函数的调用
- 如果没有找到最佳匹配,或者找到多个匹配程度相当的函数,则编译器需要报错
一个具体的例子(改编自参考资料 [1]):
1 |
|
输出为:
1 | 1 |
我们来分析一下。首先看 f<Test>(10);
的情况:
- 我们有两个模板符合名字 f
- 替换结果为 f(Test::foo) 和 f(Test)
- 使用参数 10 去匹配,只有前者参数可以匹配,因而第一个模板被选择
再看一下 f<int>(10)
的情况:
- 还是两个模板符合名字 f
- 替换结果为 f(int::foo) 和 f(int);显然前者不是个合法的类型,被抛弃
- 使用参数 10 去匹配 f(int),没有问题,那就使用这个模板实例了
在这儿,体现的是 SFINAE 设计的最初用法:如果模板实例化中发生了失败,没有理由编译就此出错终止,因为还是可能有其他可用的函数重载的。
这儿的失败仅指函数模板的原型声明,即参数和返回值。函数体内的失败不考虑在内。如果重载决议选择了某个函数模板,而函数体在实例化的过程中出错,那我们仍然会得到一个编译错误。
14.2 编译期成员检测
编译期成员检测不过,很快人们就发现 SFINAE 可以用于其他用途。比如,根据某个实例化的成功或失败来在编译期检测类的特性。下面这个模板,就可以检测一个类是否有一个名叫 reserve、参数类型为 size_t 的成员函数:
1 | template <typename T> |
在这个模板里:
- 我们首先定义了两个结构 good 和 bad;它们的内容不重要,我们只关心它们的大小必须不一样。
- 然后我们定义了一个 SFINAE 模板,内容也同样不重要,但模板的第二个参数需要是第一个参数的成员函数指针,并且参数类型是 size_t,返回值是 void。
- 随后,我们定义了一个要求
SFINAE*
类型的 reserve 成员函数模板,返回值是 good;再定义了一个对参数类型无要求的 reserve 成员函数模板(不熟悉 … 语法的,可以看参考资料 [2]),返回值是 bad。 - 最后,我们定义常整型布尔值 value,结果是 true 还是 false,取决于 nullptr 能不能和
SFINAE*
匹配成功,而这又取决于模板参数 T 有没有返回类型是 void、接受一个参数并且类型为 size_t 的成员函数 reserve。
那这样的模板有什么用处呢?
14.3 SFINAE 模板技巧
enable_if
C++11 开始,标准库里有了一个叫 enable_if 的模板(定义在 <type_traits>
里),可以用它来选择性地启用某个函数的重载。
假设我们有一个函数,用来往一个容器尾部追加元素。我们希望原型是这个样子的:
1 | template <typename C, typename T> |
显然,container 有没有 reserve 成员函数,是对性能有影响的——如果有的话,我们通常应该预留好内存空间,以免产生不必要的对象移动甚至拷贝操作。利用 enable_if 和 上面的 has_reserve 模板,我们就可以这么写:
1 | template <typename C, typename T> |
enable_if_t<has_reserve<C>::value, void>
的意思可以理解成:如果类型 C 有 reserve 成员的话,那我们启用下面的成员函数,它的返回类型为 void。
enable_if 的定义(其实非常简单)和它的进一步说明,请查看参考资料 [3]。参考资料里同时展示了一个通用技巧,可以用在构造函数(无返回值)或不想手写返回值类型的情况下。但那个写法更绕一些,不是必需要用的话,就采用上面那个写出返回值类型的写法吧。
enable_if
的基本用法
enable_if
有两个模板参数:
- 第一个参数是一个布尔表达式,如果该表达式为 true,则
enable_if
将定义一个类型成员type
。 - 第二个参数是一个类型,默认是
void
。
当第一个参数为 true 时,enable_if
将定义一个类型成员 type
,否则 type
不存在。
示例代码
以下是一个使用 enable_if
的简单示例:
1 |
|
解释
print_type是一个模板函数,有两个版本:
- 第一个版本只在
T
是整型时启用。 - 第二个版本只在
T
是浮点型时启用。
-
通过
std::enable_if
和std::is_integral
、std::is_floating_point
组合,实现了条件编译。 -
在
main
函数中,根据传入的参数类型,编译器会选择合适的重载版本。 -
decltype 返回值
如果只需要在某个操作有效的情况下启用某个函数,而不需要考虑相反的情况的话,有另外一个技巧可以用。对于上面的 append 的情况,如果我们想限制只有具有 reserve 成员函数的类可以使用这个重载,我们可以把代码简化成:
cpp
1
2
3
4
5
6
7
8
9template <typename C, typename T>
auto append(C& container, T* ptr, size_t size)
-> decltype(declval<C&>().reserve(1U), void())
{
container.reserve(container.size() + size);
for (size_t i = 0; i < size; ++i) {
container.push_back(ptr[i]);
}
}这是我们第一次用到 declval [4],需要简单介绍一下。这个模板用来声明一个某个类型的参数,但这个参数只是用来参加模板的匹配,不允许实际使用。使用这个模板,我们可以在某类型没有默认构造函数的情况下,假想出一个该类的对象来进行类型推导。
declval<C&>().reserve(1U)
用来测试 C& 类型的对象是不是可以拿 1U 作为参数来调用 reserve 成员函数。此外,我们需要记得,C++ 里的逗号表达式的意思是按顺序逐个估值,并返回最后一项。所以,上面这个函数的返回值类型是 void。这个方式和 enable_if 不同,很难表示否定的条件。如果要提供一个专门给没有 reserve 成员函数的 C 类型的 append 重载,这种方式就不太方便了。因而,这种方式的主要用途是避免错误的重载。
-
void_t
void_t 是 C++17 新引入的一个模板 [5]。它的定义简单得令人吃惊:
1
2template <typename...>
using void_t = void;换句话说,这个类型模板会把任意类型映射到 void。它的特殊性在于,在这个看似无聊的过程中,编译器会检查那个”任意类型”的有效性。利用 decltype、declval 和模板特化,我们可以把 has_reserve 的定义大大简化:
1
2
3
4
5
6template <typename T, typename = void_t<>>
struct has_reserve : false_type {
};
template <typename T>
struct has_reserve<T, void_t<decltype(declval<T&>().reserve(1U))>> : true_type {
};这里第二个 has_reserve 模板的定义实际上是一个偏特化 [6]。偏特化是类模板的特有功能,跟函数重载有些相似。编译器会找出所有的可用模板,然后选择其中最”特别”的一个。像上面的例子,所有类型都能满足第一个模板,但不是所有的类型都能满足第二个模板,所以第二个更特别。当第二个模板能被满足时,编译器就会选择第二个特化的模板;而只有第二个模板不能被满足时,才会回到第一个模板的通用情况。
有了这个 has_reserve 模板,我们就可以继续使用其他的技巧,如 enable_if 和下面的标签分发,来对重载进行限制。
-
标签分发
在上一讲,我们提到了用 true_type 和 false_type 来选择合适的重载。这种技巧有个专门的名字,叫标签分发(tag dispatch)。我们的 append 也可以用标签分发来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20template <typename C, typename T>
void _append(C& container, T* ptr, size_t size, true_type)
{
container.reserve(container.size() + size);
for (size_t i = 0; i < size; ++i) {
container.push_back(ptr[i]);
}
}
template <typename C, typename T>
void _append(C& container, T* ptr, size_t size, false_type)
{
for (size_t i = 0; i < size; ++i) {
container.push_back(ptr[i]);
}
}
template <typename C, typename T>
void append(C& container, T* ptr, size_t size)
{
_append(container, ptr, size, integral_constant<bool, has_reserve<C>::value> {});
}回想起上一讲里 true_type 和 false_type 的定义,你应该很容易看出这个代码跟使用 enable_if 是等价的。当然,在这个例子,标签分发并没有使用 enable_if 显得方便。作为一种可以替代 enable_if 的通用惯用法,你还是需要了解一下。
另外,如果我们用 void_t 那个版本的 has_reserve 模板的话,由于模板的实例会继承 false_type 或 true_type 之一,代码可以进一步简化为:
1
2
3
4
5template <typename C, typename T>
void append(C& container, T* ptr, size_t size)
{
_append(container, ptr, size, has_reserve<C> {});
}
14.4 静态多态的限制?
看到这儿,你可能会怀疑,为什么我们不能像在 Python 之类的语言里一样,直接写下面这样的代码呢?
1 | template <typename C, typename T> |
如果你试验一下,就会发现,在 C 类型没有 reserve 成员函数的情况下,编译是不能通过的,会报错。这是因为 C++ 是静态类型的语言,所有的函数、名字必须在编译时被成功解析、确定。在动态类型的语言里,只要语法没问题,缺成员函数要执行到那一行上才会被发现。这赋予了动态类型语言相当大的灵活性;只不过,不能在编译时检查错误,同样也是很多人对动态类型语言的抱怨所在……
那在 C++ 里,我们有没有更好的办法呢?实际上是有的。具体方法,下回分解。
14.5 参考资料
[1] Wikipedia, “Substitution failure is not an error”. https://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error
[2] cppreference.com, “Variadic functions”. https://en.cppreference.com/w/c/variadic
[3] cppreference.com, “std::enable_if”. https://en.cppreference.com/w/cpp/types/enable_if
[4] cppreference.com, “std::declval”. https://en.cppreference.com/w/cpp/utility/declval
[5] cppreference.com, “std::void_t”. https://en.cppreference.com/w/cpp/types/void_t
[6] cppreference.com, “Partial template specialization”. https://en.cppreference.com/w/cpp/language/partial_specialization
15 | constexpr:一个常态的世界
在 C++11 引入、在 C++14 得到大幅改进的 constexpr 关键字的字面意思是 constant expression,常量表达式。存在两类 constexpr 对象:
- constexpr 变量
- constexpr 函数
补充:
constexpr
变量:一个在编译时完全确定的常数。constexpr
函数:至少对于某一组参数可以在编译期间产生一个常量。
一个 constexpr 变量是一个编译时完全确定的常数。一个 constexpr 函数至少对于某一组实参可以在编译期间产生一个编译期常数。
注意一个 constexpr 函数不保证在所有情况下都会产生一个编译期常数(因而也是可以作为普通函数来使用的)。编译器也没法通用地检查这点。编译器唯一强制的是:
- constexpr 变量必须立即初始化
- 初始化只能使用字面量或常量表达式,后者不允许调用任何非 constexpr 函数
constexpr 的实际规则当然稍微更复杂些,而且随着 C++ 标准的演进也有着一些变化,特别是对 constexpr 函数如何实现的要求在慢慢放宽。要了解具体情况包括其在不同 C++ 标准中的限制,可以查看参考资料 [1]。下面我们也会回到这个问题略作展开。
要检验一个 constexpr 函数能不能产生一个真正的编译期常量,可以把结果赋给一个 constexpr 变量。成功的话,我们就确认了,至少在这种调用情况下,我们能真正得到一个编译期常量。
1 |
|
15.2 constexpr 和编译期计算
以 [第 13 讲] 提到的阶乘函数为例,和那个版本基本等价的写法是:
1 | constexpr int factorial(int n) |
这里有一个问题:在这个 constexpr 函数里,是不能写 static_assert(n >= 0) 的。一个 constexpr 函数仍然可以作为普通函数使用——显然,传入一个普通 int 是不能使用静态断言的。替换方法是在 factorial 的实现开头加入:
1 | if (n < 0) { |
15.3 constexpr 和 const
const 的原本和基础的含义,自然是表示它修饰的内容不会变化,如:
1 | const int n = 1: |
本质上,const 用来表示一个运行时常量。
在 C++ 里,const 后面渐渐带上了现在的 constexpr 用法,也代表编译期常数。现在——在有了 constexpr 之后——我们应该使用 constexpr 在这些用法中替换 const 了。从编译器的角度,为了向后兼容性,const 和 constexpr 在很多情况下还是等价的。但有时候,它们也有些细微的区别,其中之一为是否内联的问题。
const
变量
const
变量是一个运行时常量,表示它的值不会在运行时改变。
1 | cppCopy codeconst int n = 1; |
constexpr
变量
constexpr
变量是一个编译时常量,表示它的值在编译时就能确定。
1 | cppCopy codeconstexpr int m = 2; |
示例代码对比
1 | cppCopy code |
区别和使用场景
- 运行时常量 vs 编译时常量:
const
用于表示运行时常量。constexpr
用于表示编译时常量。
- 内联问题:
constexpr
更适合用在需要编译时计算的情况下,它可以使得编译器在编译时就进行计算和优化。- 在某些情况下,
constexpr
比const
更倾向于内联,从而提高程序的效率。
例子展示细微区别
考虑以下代码:
1 | cppCopy code |
在这个例子中:
x
是一个const
变量,它的值在运行时确定。y
是一个constexpr
变量,它的值在编译时确定。
总结
const
用于表示一个值在运行时不变。constexpr
用于表示一个值在编译时就能确定。- 现代 C++ 更推荐使用
constexpr
来表示编译时常量,因为它可以使编译器在编译时进行更多优化。
15.4 内联变量
C++17 引入了内联(inline)变量的概念,允许在头文件中定义内联变量,然后像内联函数一样,只要所有的定义都相同,那变量的定义出现多次也没有关系。对于类的静态数据成员,const 缺省是不内联的,而 constexpr 缺省就是内联的。这种区别在你用 & 去取一个 const int 值的地址、或将其传到一个形参类型为 const int& 的函数去的时候(这在 C++ 文档里的行话叫 ODR-use),就会体现出来。
下面是个合法的完整程序:
1 |
|
我们稍微改一点:
1 |
|
程序在链接时就会报错了,说找不到 magic::number(注意:MSVC 缺省不报错,但使用标准模式——/Za 命令行选项——也会出现这个问题)。这是因为 ODR-use 的类静态常量也需要有一个定义,在没有内联变量之前需要在某一个源代码文件(非头文件)中这样写:
1 | const int magic::number = 42; |
必须正正好好一个,多了少了都不行,所以叫 one definition rule。内联函数,现在又有了内联变量,以及模板,则不受这条规则限制。
修正这个问题的简单方法是把 magic 里的 static const 改成 static constexpr 或 static inline const。前者可行的原因是,类的静态 constexpr 成员变量默认就是内联的。const 常量和类外面的 constexpr 变量不默认内联,需要手工加 inline 关键字才会变成内联。
15.5 constexpr 变量模板
变量模板是 C++14 引入的新概念。之前我们需要用类静态数据成员来表达的东西,使用变量模板可以更简洁地表达。constexpr 很合适用在变量模板里,表达一个和某个类型相关的编译期常量。由此,type traits 都获得了一种更简单的表示方式。再看一下我们在 [第13 讲] 用过的例子:
1 | template <class T> |
15.6 constexpr 变量仍是 const
一个 constexpr 变量仍然是 const 常类型。需要注意的是,就像 const char* 类型是指向常量的指针、自身不是 const 常量一样,下面这个表达式里的 const 也是不能缺少的:
1 | constexpr int a = 42; |
第二行里,constexpr 表示 b 是一个编译期常量,const 表示这个引用是常量引用。去掉这个 const 的话,编译器就会认为你是试图将一个普通引用绑定到一个常数上,报一个类似下面的错误信息:
error: binding reference of type ‘int&’ to ‘const int’ discards qualifiers
如果按照 const 位置的规则,constexpr const int& b 实际该写成 const int& constexpr b。不过,constexpr 不需要像 const 一样有复杂的组合,因此永远是写在类型前面的。
15.7 constexpr 构造函数和字面类型
一个合理的 constexpr 函数,应当至少对于某一组编译期常量的输入,能得到编译期常量的结果。为此,对这个函数也是有些限制的:
- 最早,constexpr 函数里连循环都不能有,但在 C++14 放开了。
- 目前,constexpr 函数仍不能有 try … catch 语句和 asm 声明,但到 C++20 会放开。
- constexpr 函数里不能使用 goto 语句。
- 等等。
一个有意思的情况是一个类的构造函数。如果一个类的构造函数里面只包含常量表达式、满足对 constexpr 函数的限制的话(这也意味着,里面不可以有任何动态内存分配),并且类的析构函数是平凡的,那这个类就可以被称为是一个字面类型。换一个角度想,对 constexpr 函数——包括字面类型构造函数——的要求是,得让编译器能在编译期进行计算,而不会产生任何”副作用”,比如内存分配、输入、输出等等。
为了全面支持编译期计算,C++14 开始,很多标准类的构造函数和成员函数已经被标为 constexpr,以便在编译期使用。当然,大部分的容器类,因为用到了动态内存分配,不能成为字面类型。下面这些不使用动态内存分配的字面类型则可以在常量表达式中使用:
- array
- initializer_list
- pair
- tuple
- string_view
- optional
- variant
- bitset
- complex
- chrono::duration
- chrono::time_point
- shared_ptr(仅限默认构造和空指针构造)
- unique_ptr(仅限默认构造和空指针构造)
- …
15.8 if constexpr
上一讲的结尾,我们给出了一个在类型参数 C 没有 reserve 成员函数时不能编译的代码:
cpp
1 | template <typename C, typename T> |
在 C++17 里,我们只要在 if 后面加上 constexpr,代码就能工作了 [2]。当然,它要求括号里的条件是个编译期常量。满足这个条件后,标签分发、enable_if 那些技巧就不那么有用了。显然,使用 if constexpr 能比使用其他那些方式,写出更可读的代码……
15.9 output_container.h 解读
到了今天,我们终于把 output_container.h([3])用到的 C++ 语法特性都讲过了,我们就拿里面的代码来讲解一下,让你加深对这些特性的理解。
1 | // Type trait to detect std::pair |
这段代码利用模板特化([第 12 讲] 、[第 14 讲])和 false_type、true_type 类型([第 13 讲]),定义了 is_pair,用来检测一个类型是不是 pair。随后,我们定义了内联 constexpr 变量(本讲)is_pair_v,用来简化表达。
1 | // Type trait to detect whether an |
这段代码使用 SFINAE 技巧([第 14 讲]),来检测模板参数 T 的对象是否已经可以直接输出到 ostream。然后,一样用一个内联 constexpr 变量来简化表达。
1 | // Output function for std::pair |
再然后我们声明了一个 pair 的输出函数(标准库没有提供这个功能)。我们这儿只是声明,是因为我们这儿有两个输出函数,且可能互相调用。所以,我们要先声明其中之一。
下面会看到,pair 的通用输出形式是”(x, y)”。
1 | // Element output function for |
对于容器成员的输出,我们也声明了两个不同的重载。我们的意图是,如果元素的类型是 pair 并且容器定义了一个 key_type 类型,我们就认为遇到了关联容器,输出形式为”x => y”(而不是”(x, y)”)。
1 | // Main output function, enabled |
主输出函数的定义。注意这儿这个函数的启用有两个不同的 SFINAE 条件:
- 用 decltype 返回值的方式规定了被输出的类型必须有 begin() 和 end() 成员函数。
- 用 enable_if_t 规定了只在被输出的类型没有输出函数时才启用这个输出函数。否则,对于 string 这样的类型,编译器发现有两个可用的输出函数,就会导致编译出错。
我们可以看到,用 decltype 返回值的方式比较简单,不需要定义额外的模板。但表达否定的条件还是要靠 enable_if。此外,因为此处是需要避免有二义性的重载,constexpr 条件语句帮不了什么忙。
1 | using element_type = decay_t<decltype(*container.begin())>; |
对非字符类型,我们在开始输出时,先输出”{ “。这儿使用了 decay_t,是为了把类型里的引用和 const/volatile 修饰去掉,只剩下值类型。如果容器里的成员是 char,这儿会把 char& 和 const char& 还原成 char。
后面的代码就比较简单了。可能唯一需要留意的是下面这句:
1 | output_element(os, *it, container, is_pair<element_type>()); |
这儿我们使用了标签分发技巧来输出容器里的元素。要记得,output_element 不纯粹使用标签分发,还会检查容器是否有 key_type 成员类型。
1 | template <typename T, typename Cont> |
output_element 的两个重载的实现都非常简单,应该不需要解释了。
1 | template <typename T, typename U> |
同样,pair 的输出的实现也非常简单。
唯一需要留意的,是上面三个函数的输出内容可能还是容器,因此我们要将其实现放在后面,确保它能看到我们的通用输出函数。
要看一下用到 output_container 的例子,可以回顾 [第 4 讲] 和 [第 5 讲]。
15.10 参考资料
- cppreference.com, “constexpr specifier”. https://en.cppreference.com/w/cpp/language/constexpr
- cppreference.com, “if statement”, section “constexpr if”. https://en.cppreference.com/w/cpp/language/if
- 吴咏炜, output_container. https://github.com/adah1972/output_container/blob/master/output_container.h
16 | 函数对象和lambda:进入函数式编程
16.1 C++98 的函数对象
函数对象(function object)[1] 自 C++98 开始就已经被标准化了。从概念上来说,函数对象是一个可以被当作函数来用的对象。它有时也会被叫做 functor,但这个术语在范畴论里有着完全不同的含义,还是不用为妙——否则玩函数式编程的人可能会朝着你大皱眉头的。
下面的代码定义了一个简单的加 n 的函数对象类(根据一般的惯例,我们使用了 struct关键字而不是 class 关键字):
1 | struct adder { |
它看起来相当普通,唯一有点特别的地方就是定义了一个 operator(),这个运算符允许我们像调用函数一样使用小括号的语法。随后,我们可以定义一个实际的函数对象:
1 | auto add_2 = adder(2); // C++11 风格 |
得到的结果 add_2 就可以当作一个函数来用了。你如果写下 add_2(5) 的话,就会得到结果 7。
C++98 里也定义了少数高阶函数:你可以传递一个函数对象过去,结果得到一个新的函数对象。最典型的也许是目前已经从 C++17 标准里移除的 bind1st 和 bind2nd 了(在 <functional>
头文件中提供):
1 | auto add_2 = bind2nd(plus<int>(), 2); |
这样产生的 add_2 功能和前面相同,是把参数 2 当作第二个参数绑定到函数对象 plus(它的 operator() 需要两个参数)上的结果。当然,auto 在 C++98 里是没有的,结果要赋给一个变量就有点别扭了,得写成:
1 | binder2nd<plus<int> > add_2(plus<int>(), 2); |
16.2 函数的指针和引用
除非你用一个引用模板参数来捕捉函数类型,传递给一个函数的函数实参会退化成为一个函数指针。不管是函数指针还是函数引用,你也都可以当成函数对象来用。
假设我们有下面的函数定义:
1 | int add_2(int x) |
如果我们有下面的模板声明:
1 | template <typename T> |
当我们拿 add_2 去调用这三个函数模板时,fn 的类型将分别被推导为 int (*)(int)
、 int (&)(int)
和 int (*)(int)
。不管我们得到的是指针还是引用,我们都可以直接拿它当普通的函数用。当然,在函数指针的情况下,我们直接写 *value 也可以。因而上面三个函数拿 add_2 作为实参调用的结果都是 4。
很多接收函数对象的地方,也可以接收函数的指针或引用。但在个别情况下,需要通过函数对象的类型来区分函数对象的时候,就不能使用函数指针或引用了——原型相同的函数,它们的类型也是相同的。
16.3 Lambda 表达式
看一下上一节给出的代码在使用 lambda 表达式时可以如何简化。
1 | auto add_2 = [](int x) { |
显然,定义 add_2 不再需要定义一个额外的类型了,我们可以直接写出它的定义。理解它只需要注意下面几点:
- Lambda 表达式以一对中括号开始(中括号中是可以有内容的;稍后我们再说)跟函数定义一样,我们有参数列表
- 跟正常的函数定义一样,我们会有一个函数体,里面会有 return 语句
- Lambda 表达式一般不需要说明返回值(相当于 auto);有特殊情况需要说明时,则应使用箭头语法的方式(参见**[第 8 讲]**):[] (int x) -> int { … }
- 每个 lambda 表达式都有一个全局唯一的类型,要精确捕捉 lambda 表达式到一个变量中,只能通过 auto 声明的方式
当然,我们想要定义一个通用的 adder 也不难
1 | auto adder = [](int n) { |
这次我们直接返回了一个 lambda 表达式,并且中括号中写了 n 来捕获变量 n 的数值。这个函数的实际效果和前面的 adder 函数对象完全一致。也就是说,捕获 n 的效果相当于在一个函数对象中用成员变量存储其数值。
一个 lambda 表达式除了没有名字之外,还有一个特点是你可以立即进行求值。这就使得我们可以把一段独立的代码封装起来,达到更干净、表意的效果。
先看一个简单的例子:
1 | [](int x) { return x * x; }(3) |
这个表达式的结果是 3 的平方 9。即使这个看似无聊的例子,都是有意义的,因为它免去了我们定义一个 constexpr 函数的必要。只要能满足 constexpr 函数的条件,一个 lambda 表达式默认就是 constexpr 函数。
16.4 变量捕获
变量捕获的开头是可选的默认捕获符 = 或 &,表示会自动按值或按引用捕获用到的本地变量,然后后面可以跟(逗号分隔):
- 本地变量名标明对其按值捕获(不能在默认捕获符 = 后出现;因其已自动按值捕获所有本地变量)
- & 加本地变量名标明对其按引用捕获(不能在默认捕获符 & 后出现;因其已自动按引用捕获所有本地变量)
- this 标明按引用捕获外围对象(针对 lambda 表达式定义出现在一个非静态类成员内的情况);注意默认捕获符 = 和 & 号可以自动捕获 this(并且在 C++20 之前,在 = 后写 this 会导致出错)
- *this 标明按值捕获外围对象(针对 lambda 表达式定义出现在一个非静态类成员内的情况;C++17 新增语法)
- 变量名 = 表达式 标明按值捕获表达式的结果(可理解为 auto 变量名 = 表达式)
- &变量名 = 表达式 标明按引用捕获表达式的结果(可理解为 auto& 变量名 = 表达式)
从工程的角度,大部分情况不推荐使用默认捕获符。更一般化的一条工程原则是:显式的代码比隐式的代码更容易维护。
一般而言,按值捕获是比较安全的做法。按引用捕获时则需要更小心些,必须能够确保被捕获的变量和 lambda 表达式的生命期至少一样长,并在有下面需求之一时才使用:
- 需要在 lambda 表达式中修改这个变量并让外部观察到
- 需要看到这个变量在外部被修改的结果
- 这个变量的复制代价比较高
如果希望以移动的方式来捕获某个变量的话,则应考虑 变量名 = 表达式 的形式。表达式可以返回一个 prvalue 或 xvalue,比如可以是 std::move(需移动捕获的变量)。
1 |
|
这个例子稍复杂,演示了好几个 lambda 表达式的特性:
- mutable 标记使捕获的内容可更改(缺省不可更改捕获的值,相当于定义了 operator()(…) const);
- [*this] 按值捕获外围对象(task);
- [count = get_count()] 捕获表达式可以在生成 lambda 表达式时计算并存储等号后表达式的结果。
这样,多个线程复制了任务对象,可以独立地进行计算。请自行运行一下代码,并把 *this
改成 this,看看输出会有什么不同。
16.5 泛型 lambda 表达式
函数的返回值可以 auto,但参数还是要一一声明的。在 lambda 表达式里则更进一步,在参数声明时就可以使用 auto(包括 auto&& 等形式)。不过,它的功能也不那么神秘,就是给你自动声明了模板而已。毕竟,在 lambda 表达式的定义过程中是没法写 template 关键字的。
1 | template <typename T1, typename T2> |
跟上面的函数等价的 lambda 表达式是:
1 | auto sum = [](auto x, auto y) { |
你可能要问,这么写有什么用呢?问得好。简单来说,答案是可组合性。上面这个 sum,就跟标准库里的 plus 模板一样,是可以传递给其他接受函数对象的函数的,而 + 本身则不行。下面的例子虽然略有点无聊,也可以演示一下:
1 |
|
虽然函数名字叫 accumulate——累加——但它的行为是通过第四个参数可修改的。我们把上面的加号 + 改成星号 *,上面的计算就从从 1 加到 5 变成了算 5 的阶乘了。
16.6 bind 模板
我们上面提到了 bind1st 和 bind2nd 目前已经从 C++ 标准里移除。原因实际上有两个:
- 它的功能可以被 lambda 表达式替代
- 有了一个更强大的 bind 模板 [5]
拿我们之前给出的例子
1 | transform(v.begin(), v.end(), v.begin(), bind2nd(plus<int>(), 2)); |
现在我们可以写成:
1 | using namespace std::placeholders; // for _1, _2... |
原先我们只能把一个给定的参数绑定到第一个参数或第二个参数上,现在则可以非常自由地适配各种更复杂的情况!当然,bind 的参数数量,必须是第一个参数(函数对象)所需的参数数量加一。而 bind 的结果的参数数量则没有限制——你可以无聊地写出 bind(plus<>(), _1, _3)(1, 2, 3)
,而结果是 4(完全忽略第二个参数)。
你可能会问,它的功能是不是可以被 lambda 表达式替代呢。回答是”是”。对 bind 只需要稍微了解一下就好——在 C++14 之后的年代里,已经没有什么地方必须要使用 bind 了。
16.7 function 模板
每一个 lambda 表达式都是一个单独的类型,所以只能使用 auto 或模板参数来接收结果。在很多情况下,我们需要使用一个更方便的通用类型来接收,这时我们就可以使用 function 模板 [6]。function 模板的参数就是函数的类型,一个函数对象放到 function 里之后,外界可以观察到的就只剩下它的参数、返回值类型和执行效果了。注意 function 对象的创建还是比较耗资源的,所以请你只在用 auto 等方法解决不了问题的时候使用这个模板。
1 | map<string, function<int(int, int)>> op_dict { |
这儿,由于要把函数对象存到一个 map 里,我们必须使用 function 模板。随后,我们就可以用类似于 op_dict.at(“+”)(1, 6) 这样的方式来使用 function 对象。这种方式对表达式的解析处理可能会比较有用。
16.8 参考资料
[1] Wikipedia, “Function object”. https://en.wikipedia.org/wiki/Function_object
[2] Wikipedia, “Anonymous function”. https://en.wikipedia.org/wiki/Anonymous_function
[3] Wikipedia, “Lambda calculus”. https://en.wikipedia.org/wiki/Lambda_calculus
[4] Wikipedia, “Currying”. https://en.wikipedia.org/wiki/Currying
[5] cppreference.com, “std::bind”. https://en.cppreference.com/w/cpp/utility/functional/bind
[6] cppreference.com, “std::function”. https://en.cppreference.com/w/cpp/utility/functional/function
17 | 函数式编程:一种越来越流行的编程范式
大致了解下函数式编程先
1 |
|
在我们的代码里不那么明显的一点是,函数式编程期望函数的行为像数学上的函数,而非一个计算机上的子程序。这样的函数一般被称为纯函数(pure function),要点在于:
- 会影响函数结果的只是函数的参数,没有对环境的依赖
- 返回的结果就是函数执行的唯一后果,不产生对环境的其他影响
我们的代码中也体现了其他一些函数式编程的特点:
- 函数就像普通的对象一样被传递、使用和返回。
- 代码为说明式而非命令式。在熟悉函数式编程的基本范式后,你会发现说明式代码的可读性通常比命令式要高,代码还短。
- 一般不鼓励(甚至完全不使用)可变量。上面代码里只有 count 的内容在执行过程中被修改了,而且这种修改实际是 transform 接口带来的。如果接口像 [第 13 讲] 展示的 fmap 函数一样返回一个容器的话,就可以连这个问题都消除了。(C++ 毕竟不是一门函数式编程语言,对灵活性的追求压倒了其他考虑。)
17.3 高阶函数
既然函数(对象)可以被传递、使用和返回,自然就有函数会接受函数作为参数或者把函数作为返回值,这样的函数就被称为高阶函数。我们现在已经见过不少高阶函数了,如:
- sort
- transform
- accumulate
- fmap
- adder
事实上,C++ 里以 algorithm(算法)[3] 名义提供的很多函数都是高阶函数。
许多高阶函数在函数式编程中已成为基本的惯用法,在不同语言中都会出现,虽然可能是以不同的名字。我们在此介绍非常常见的三个,map(映射)、reduce(归并)和 filter(过滤)。
Map 在 C++ 中的直接映射是 transform(在 <algorithm>
头文件中提供)。它所做的事情也是数学上的映射,把一个范围里的对象转换成相同数量的另外一些对象。这个函数的基本实现非常简单,但这是一种强大的抽象,在很多场合都用得上。
Reduce 在 C++ 中的直接映射是 accumulate(在 <numeric>
头文件中提供)。它的功能是在指定的范围里,使用给定的初值和函数对象,从左到右对数值进行归并。在不提供函数对象作为第四个参数时,功能上相当于默认提供了加法函数对象,这时相当于做累加;提供了其他函数对象时,那当然就是使用该函数对象进行归并了。
Filter 的功能是进行过滤,筛选出符合条件的成员。它在当前 C++(C++20 之前)里的映射可以认为有两个:copy_if 和 partition。这是因为在 C++20 带来 ranges 之前,在 C++ 里实现惰性求值不太方便。上面说的两个函数里,copy_if 是把满足条件的元素拷贝到另外一个迭代器里;partition 则是根据过滤条件来对范围里的元素进行分组,把满足条件的放在返回值迭代器的前面。另外,remove_if 也有点相近,通常用于删除满足条件的元素。它确保把不满足条件的元素放在返回值迭代器的前面(但不保证满足条件的元素在函数返回后一定存在),然后你一般需要使用容器的 erase 成员函数来将待删除的元素真正删除。
17.4 命令式编程和说明式编程
传统上 C++ 属于命令式编程。命令式编程里,代码会描述程序的具体执行步骤。好处是代码显得比较直截了当;缺点就是容易让人只见树木、不见森林,只能看到代码啰嗦地怎么做(how),而不是做什么(what),更不用说为什么(why)了。
说明式编程则相反。以数据库查询语言 SQL 为例,SQL 描述的是类似于下面的操作:你想从什么地方(from)选择(select)满足什么条件(where)的什么数据,并可选指定排序(order by)或分组(group by)条件。你不需要告诉数据库引擎具体该如何去执行这个操作。事实上,在选择查询策略上,大部分数据库用户都不及数据库引擎”聪明”;正如大部分开发者在写出优化汇编代码上也不及编译器聪明一样。
这并不是说说明式编程一定就优于命令式编程。事实上,对于很多算法,命令式才是最自然的实现。
所以,我个人认为,说明式编程跟命令式编程可以结合起来产生既优雅又高效的代码。对于从命令式编程成长起来的大部分程序员,我的建议是:
- 写表意的代码,不要过于专注性能而让代码难以维护——记住高德纳的名言:”过早优化是万恶之源。”
- 使用有意义的变量,但尽量不要去修改变量内容——变量的修改非常容易导致程序员的思维错误。
- 类似地,尽量使用没有副作用的函数,并让你写的代码也尽量没有副作用,用返回值来代表状态的变化——没有副作用的代码更容易推理,更不容易出错。
- 代码的隐式依赖越少越好,尤其是不要使用全局变量——隐式依赖会让代码里的错误难以排查,也会让代码更难以测试。
- 使用知名的高级编程结构,如基于范围的 for 循环、映射、归并、过滤——这可以让你的代码更简洁,更易于推理,并减少类似下标越界这种低级错误的可能性。
这些跟函数式编程有什么关系呢?——这些差不多都是来自函数式编程的最佳实践。学习函数式编程,也是为了更好地体会如何从这些地方入手,写出易读而又高性能的代码。
17.5 不可变性和并发
在多核的时代里,函数式编程比以前更受青睐,一个重要的原因是函数式编程对并行并发天然友好。影响多核性能的一个重要因素是数据的竞争条件——由于共享内存数据需要加锁带来的延迟。函数式编程强调不可变性(immutability)、无副作用,天然就适合并发。更妙的是,如果你使用高层抽象的话,有时可以轻轻松松”免费”得到性能提升。
拿我们这一讲开头的例子来说,对代码做下面的改造,启用 C++17 的并行执行策略 [5],就能自动获得在多核环境下的性能提升:
1 | int count_lines(const char** begin, const char** end) |
我们可以看到,两个高阶函数的调用中都加入了 execution::par,来启动自动并行计算。要注意的是,我把 accumulate 换成了 reduce [6],原因是前者已经定义成从左到右的归并,无法并行。reduce 则不同,初始值可以省略,操作上没有规定顺序,并反过来要求对元素的归并操作满足交换律和结合率(加法当然是满足的)。
17.6 Y 组合子
限于篇幅,这一讲我们只是很初浅地探讨了函数式编程。对于 C++ 的函数式编程的深入探讨是有整本书的(见参考资料 [8]),而今天讲的内容在书的最前面几章就覆盖完了。在后面,我们还会探讨部分的函数式编程话题;今天我们只再讨论一个有点有趣、也有点烧脑的话题,Y 组合子 [9]。第一次阅读的时候,如果觉得困难,可以跳过这一部分。
不过,我并不打算讨论 Haskell Curry 使用的 Y 组合子定义——这个比较复杂,需要写一篇完整的文章来讨论([10]),而且在 C++ 中的实用性非常弱。
17.7 参考资料
[1] cppreference.com, “std::transform”. https://en.cppreference.com/w/cpp/algorithm/transform
[2] cppreference.com, “std::accumulate”. https://en.cppreference.com/w/cpp/algorithm/accumulate
[3] cppreference.com, “Standard library header <algorithm>
“. https://en.cppreference.com/w/cpp/header/algorithm
[4] 袁英杰, “Immutability: The Dark Side”. https://www.jianshu.com/p/13cd4c650125
[5] cppreference.com, “Standard library header <execution>
“. https://en.cppreference.com/w/cpp/header/execution
[6] cppreference.com, “std::reduce”. https://en.cppreference.com/w/cpp/algorithm/reduce
[7] Intel, tbb. https://github.com/intel/tbb
[8] Ivan Čukić, Functional Programming in C++. Manning, 2019, https://www.manning.com/books/functional-programming-in-c-plus-plus
[9] Wikipedia, “Fixed-point combinator”. https://en.wikipedia.org/wiki/Fixedpoint_combinator
[10] 吴咏炜, “Y Combinator and C++”. https://yongweiwu.wordpress.com/2014/12/14/y-combinator-and-cplusplus/
18 | 应用可变模板和tuple的编译期技巧
如何使用可变模板和 tuple 来完成一些常见的功能,尤其是编译期计算。
18.1 可变模板
可变模板 [1] 是 C++11 引入的一项新功能,使我们可以在模板参数里表达不定个数和类型的参数。从实际的角度,它有两个明显的用途:
- 用于在通用工具模板中转发参数到另外一个函数
- 用于在递归的模板中表达通用的情况(另外会有至少一个模板特化来表达边界情况)
18.2 转发用法
以标准库里的 make_unique 为例,它的定义差不多是下面这个样子:
1 | template <typename T, typename... Args> |
这样,它就可以把传递给自己的全部参数转发到模板参数类的构造函数上去。注意,在这种情况下,我们通常会使用 std::forward,确保参数转发时仍然保持正确的左值或右值引用类型。
稍微解释一下上面三处出现的 …:
- typename… Args 声明了一系列的类型——class… 或 typename… 表示后面的标识符代表了一系列的类型。
- Args&&… args 声明了一系列的形参 args,其类型是 Args&&。
forward<Args>(args)...
会在编译时实际逐项展开 Args 和 args ,参数有多少项,展开后就是多少项。
举一个例子,如果我们需要在堆上传递一个 vector<int>
,假设我们希望初始构造的大小为 100,每个元素都是 1,那我们可以这样写:
1 | make_unique<vector<int>>(100, 1) |
模板实例化之后,会得到相当于下面的代码:
1 | template <> |
如前所述,forward<Args>(args)...
为每一项可变模板参数都以同样的形式展开。项数也允许为零,那样,我们在调用构造函数时也同样没有任何参数。
18.3 递归用法
我们也可以用可变模板来实现编译期递归。下面就是个小例子:
1 | template <typename T> |
在上面的定义里,如果 sum 得到的参数只有一个,会走到上面那个重载。如果有两个或更多参数,编译器就会选择下面那个重载,执行一次加法,随后你的参数数量就少了一个,因而递归总会终止到上面那个重载,结束计算。
注意我们都不必使用相同的数据类型:只要这些数据之间可以应用 +,它们的类型无关紧要……
再看另一个复杂些的例子,函数的组合 [2]。如果我们有函数 f 和 函数 g ,要得到函数的联用 g ∘ f ,其满足:
1 | (g ∘ f )(x) = g(f (x)) |
我们能不能用一种非常简单的方式,写不包含变量 的表达式来表示函数组合呢?答案是肯定的。
跟上面类似,我们需要写出递归的终结情况,单个函数的”组合”:
1 | template <typename F> |
上面我们仅返回一个泛型 lambda 表达式,保证参数可以转发到 f。记得我们在 [第 16 讲] 讲过泛型 lambda 表达式,本质上就是一个模板,所以我们按转发用法的可变模板来理解上面的 … 部分就对了。
下面是正常有组合的情况:
1 | template <typename F, typename... Args> |
在这个模板里,我们返回一个 lambda 表达式,然后用 f 捕捉第一个函数对象,用 args… 捕捉后面的函数对象。我们用 args… 继续组合后面的部分,然后把结果传到 f 里面。
上面的模板定义我实际上已经有所简化,没有保持值类别。完整的包含完美转发的版本,请看参考资料 [3] 中的 functional.h 实现。
下面我们来试验一下使用这个 compose 函数。我们先写一个对输入范围中每一项都进行平方的函数对象:
1 | auto square_list = [](auto&& container) { |
我们这儿用了泛型 lambda 表达式,是因为组合的时候不能使用模板,只能是函数对象或函数(指针)——如果我们定义一个 square_list 模板的话,组合时还得显式实例化才行(写成 square_list<const vector<int>&>
的样子),很不方便。
我们再写一个求和的函数对象:
1 | auto sum_list = [](auto&& container) { |
那先平方再求和,就可以这样简单定义了:
1 | auto squared_sum = compose(sum_list, square_list); |
我们可以验证这个定义是可以工作的:
1 | vector v{1, 2, 3, 4, 5}; |
我们会得到:
1 | 55 |
18.4 tuple
上面的写法虽然看起来还不错,但实际上有个缺陷:被 compose 的函数除了第一个(最右边的),其他的函数只能接收一个参数。要想进一步推进类似的技巧,我们得首先解决这个问题。
在 C++ 里,要通用地用一个变量来表达多个值,那就得看多元组——tuple 模板了 [4]。tuple 算是 C++98 里的 pair 类型的一般化,可以表达任意多个固定数量、固定类型的值的组合。
- tuple 的成员数量由尖括号里写的类型数量决定。
- 可以使用 get 函数对 tuple 的内容进行读和写。(当一个类型在 tuple 中出现正好一次时,我们也可以传类型取内容,即,对我们上面的三元组,
get<int>
是合法的,get<string>
则不是。) - 可以用
tuple_size_v
(在编译期)取得多元组里面的项数。
我们已经有了参数的项数(使用 tuple_size_v),所以我们下面要做的是生成从 0 到项数减一之间的整数序列。标准库里已经定义了相关的工具,我们需要的就是其中的 make_index_sequence [5],其简化实现如下所示:
1 | template <class T, T... Ints> |
正如一般的模板代码,它看起来还是有点绕的。其要点是,如果我们给出 make_index_sequence<N>
,则结果是 integer_sequence<size_t, 0, 1, 2, …, N - 1>
(一下子想不清楚的话,可以拿纸笔来模拟一下模板的展开过程)。而有了这样一个模板的帮助之后,我们就可以写出下面这样的函数(同样,这是标准库里的 apply
函数模板 [6] 的简化版本):
1 | template <class F, class Tuple, size_t... I> |
我们如果有一个三元组 t,类型为 tuple<int, string, string>
,去 apply 到一个函数 f,展开后我们得到 apply_impl(f, t, index_sequence<0, 1, 2>{})
,再展开后我们就得到了上面那个有 get<0>
、get<1>
、get<2>
的函数调用形式。换句话说,我们利用一个计数序列的类型,可以在编译时展开 tuple 里的各个成员,并用来调用函数。
完整可运行代码(可以使用 cppinsights 查看下面代码中模板展开的过程):
1 |
|
18.5 数值预算
我们下面看一个源自实际项目的例子。需求是,我们希望快速地计算一串二进制数中 1 比特的数量。举个例子,如果我们有十进制的 31 和 254,转换成二进制是 00011111 和 11111110,那我们应该得到 5 + 7 = 12。
显然,每个数字临时去数肯定会慢,我们应该预先把每个字节的 256 种情况记录下来。因而,如何得到这些计数值是个问题。在没有编译期编程时,我们似乎只能用另外一个程序先行计算,然后把结果填进去——这就很不方便很不灵活了。有了编译期编程,我们就不用写死,而让编译器在编译时帮我们计算数值。
利用 constexpr 函数,我们计算单个数值完全没有问题。快速定义如下:
1 | constexpr int count_bits(unsigned char value) |
可 256 个,总不见得把计算语句写上 256 遍吧?这就需要用到我们上面讲到的 index_sequence 了。我们定义一个模板,它的参数是一个序列,在初始化时这个模板会对参数里的每一项计算比特数,并放到数组成员里。
1 | template <size_t... V> |
注意上面用 sizeof…(V) 可以获得参数的个数(在 tuple_size_v 的实现里实际也用到它了)。如果我们模板参数传 0, 1, 2, 3,结果里面就会有个含 4 项元素的数组,数值分别是对 0、1、2、3 的比特计数。
然后,我们当然就可以利用 make_index_sequence 来展开计算了,想产生几项就可以产生几项。不过,要注意到 make_index_sequence 的结果是个类型,不能直接用在 bit_count_t 的构造中。我们需要用模板匹配来中转一下:
1 | template <size_t... V> |
得到 bit_count 后,我们要计算一个序列里的比特数就只是轻松查表相加了,此处不再赘述。
18.6 参考资料
[1] cppreference.com, “Parameter pack”. https://en.cppreference.com/w/cpp/language/parameter_pack
[2] Wikipedia, “Function composition”. https://en.wikipedia.org/wiki/Function_composition
[3] 吴咏炜, nvwa. https://github.com/adah1972/nvwa
[4] cppreference.com, “std::tuple”. https://en.cppreference.com/w/cpp/utility/tuple
[5] cppreference.com, “std::integer_sequence”. https://en.cppreference.com/w/cpp/utility/integer_sequence
[6] cppreference.com, “std::apply”. https://en.cppreference.com/w/cpp/utility/apply
19 | thread和future:领略异步中的未来
19.1 为什么要使用并发编程?
如果你不熟悉进程和线程的话,我们就先来简单介绍一下它们的关系。我们编译完执行的 C++ 程序,那在操作系统看来就是一个进程了。而每个进程里可以有一个或多个线程:
- 每个进程有自己的独立地址空间,不与其他进程分享;一个进程里可以有多个线程,彼此共享同一个地址空间。
- 堆内存、文件、套接字等资源都归进程管理,同一个进程里的多个线程可以共享使用。每个进程占用的内存和其他资源,会在进程退出或被杀死时返回给操作系统。
- 并发应用开发可以用多进程或多线程的方式。多线程由于可以共享资源,效率较高;反之,多进程(默认)不共享地址空间和资源,开发较为麻烦,在需要共享数据时效率也较低。但多进程安全性较好,在某一个进程出问题时,其他进程一般不受影响;而在多线程的情况下,一个线程执行了非法操作会导致整个进程退出。
我们讲 C++ 里的并发,主要讲的就是多线程。它对开发人员的挑战是全方位的。从纯逻辑的角度,并发的思维模式就比单线程更为困难。在其之上,我们还得加上:
- 编译器和处理器的重排问题
- 原子操作和数据竞争
- 互斥锁和死锁问题
- 无锁算法
- 条件变量
- 信号量
- ……
即使对于专家,并发编程都是困难的,上面列举的也只是部分难点而已。对于并发的基本挑战,Herb Sutter 在他的 Effective Concurrency 专栏给出了一个较为全面的概述 [2]。要对 C++ 的并发编程有全面的了解,则可以阅读曼宁出版的 C++ Concurrency in Action(有中文版,但翻译口碑不好)[3]。而我们今天主要要介绍的,则是并发编程的基本概念,包括传统的多线程开发,以及高层抽象 future(姑且译为未来量)的用法。
19.2 基于 thread 的多线程开发
以下几个地方可能需要稍加留意一下:
- thread 的构造函数的第一个参数是函数(对象),后面跟的是这个函数所需的参数。
- thread 要求在析构之前要么 join(阻塞直到线程退出),要么 detach(放弃对线程的管理),否则程序会异常退出。
- sleep_for 是 this_thread 名空间下的一个自由函数,表示当前线程休眠指定的时间。
thread 不能在析构时自动 join 有点不那么自然,这可以算是一个缺陷吧。在 C++20 的 jthread [5] 到来之前,我们只能自己小小封装一下了。比如:
1 | class scoped_thread { |
这个实现里有下面几点需要注意:
- 我们使用了可变模板和完美转发来构造 thread 对象。
- thread 不能拷贝,但可以移动;我们也类似地实现了移动构造函数。
- 只有 joinable(已经 join 的、已经 detach 的或者空的线程对象都不满足 joinable)的 thread 才可以对其调用 join 成员函数,否则会引发异常。
19.3 mutex
互斥量的基本语义是,一个互斥量只能被一个线程锁定,用来保护某个代码块在同一时间只能被一个线程执行。
目前的 C++ 标准中,事实上提供了不止一个互斥量类。我们先看最简单、也最常用的mutex 类 [6]。mutex 只可默认构造,不可拷贝(或移动),不可赋值,主要提供的方法是:
- lock:锁定,锁已经被其他线程获得时则阻塞执行
- try_lock:尝试锁定,获得锁返回 true,在锁被其他线程获得时返回 false
- unlock:解除锁定(只允许在已获得锁时调用)
你可能会想到,如果一个线程已经锁定了某个互斥量,再次锁定会发生什么?对于 mutex,回答是危险的未定义行为。你不应该这么做。如果有特殊需要可能在同一线程对同一个互斥量多次加锁,就需要用到递归锁 recursive_mutex 了 [7]。除了允许同一线程可以无阻塞地多次加锁外(也必须有对应数量的解锁操作),recursive_mutex 的其他行为和 mutex 一致。
除了 mutex 和 recursive_mutex,C++ 标准库还提供了:
- timed_mutex:允许锁定超时的互斥量
- recursive_timed_mutex:允许锁定超时的递归互斥量
- shared_mutex:允许共享和独占两种获得方式的互斥量
- shared_timed_mutex:允许共享和独占两种获得方式的、允许锁定超时的互斥量
这些我们就不做讲解了,需要的请自行查看参考资料 [8]。另外,<mutex>
头文件中也定义了锁的 RAII 包装类,如我们上面用过的 lock_guard。为了避免手动加锁、解锁的麻烦,以及在有异常或出错返回时发生漏解锁,我们一般应当使用 lock_guard,而不是手工调用互斥量的 lock 和 unlock 方法。C++ 里另外还有 unique_lock(C++11)和 scoped_lock(C++17),提供了更多的功能,你在有更复杂的需求时应该检查一下它们是否合用。
19.4 执行任务,返回数据
如果我们要在某个线程执行一些后台任务,然后取回结果,我们该怎么做呢?
比较传统的做法是使用信号量或者条件变量。由于 C++17 还不支持信号量,我们要模拟传统的做法,只能用条件变量了。由于我的重点并不是传统的做法,条件变量 [9] 我就不展开讲了,而只是展示一下示例的代码。
1 |
|
可以看到,为了这个小小的”计算”,我们居然需要定义 5 个变量:线程、条件变量、互斥量、单一锁和结果变量。我们也需要用 ref 模板来告诉 thread 的构造函数,我们需要传递条件变量和结果变量的引用,因为 thread 默认复制或移动所有的参数作为线程函数的参数。这种复杂性并非逻辑上的复杂性,而只是实现导致的,不是我们希望的写代码的方式。
下面,我们就看看更高层的抽象,未来量 future [10],可以如何为我们简化代码。
19.5 future
我们先把上面的代码直接翻译成使用 async [11](它会返回一个 future):
1 |
|
我们稍稍分析一下:
- work 函数现在不需要考虑条件变量之类的实现细节了,专心干好自己的计算活、老老实实返回结果就可以了。
- 调用 async 可以获得一个未来量,launch::async 是运行策略,告诉函数模板 async 应当在新线程里异步调用目标函数。在一些老版本的 GCC 里,不指定运行策略,默认不会起新线程。
- async 函数模板可以根据参数来推导出返回类型,在我们的例子里,返回类型是
future<int>
。 - 在未来量上调用 get 成员函数可以获得其结果。这个结果可以是返回值,也可以是异常,即,如果 work 抛出了异常,那 main 里在执行 fut.get() 时也会得到同样的异常,需要有相应的异常处理代码程序才能正常工作。
这里有两个要点,从代码里看不出来,我特别说明一下:
- 一个 future 上只能调用一次 get 函数,第二次调用为未定义行为,通常导致程序崩溃。
- 这样一来,自然一个 future 是不能直接在多个线程里用的。
上面的第 1 点是 future 的设计,需要在使用时注意一下。第 2 点则是可以解决的。要么直接拿 future 来移动构造一个 shared_future [12],要么调用 future 的 share 方法来生成一个 shared_future,结果就可以在多个线程里用了——当然,每个 shared_future 上仍然还是只能调用一次 get 函数。
19.6 promise
我们上面用 async 函数生成了未来量,但这不是唯一的方式。另外有一种常用的方式是 promise [13],我称之为”承诺量”。我们同样看一眼上面的例子用 promise 该怎么写:
1 |
|
promise 和 future 在这里成对出现,可以看作是一个一次性管道:有人需要兑现承诺,往 promise 里放东西(set_value);有人就像收期货一样,到时间去 future(写到这里想到,期货英文不就是 future 么,是不是该翻译成期货量呢?😝)里拿(get)就行了。我们把 prom 移动给新线程,这样老线程就完全不需要管理它的生命周期了。
就这个例子而言,使用 promise 没有 async 方便,但可以看到,这是一种非常灵活的方式,你不需要在一个函数结束的时候才去设置 future 的值。仍然需要注意的是,一组 promise 和 future 只能使用一次,既不能重复设,也不能重复取。
promise 和 future 还有个有趣的用法是使用 void 类型模板参数。这种情况下,两个线程之间不是传递参数,而是进行同步:当一个线程在一个 future<void>
上等待时(使用 get() 或 wait()),另外一个线程可以通过调用 promise<void>
上的 set_value() 让其结束等待、继续往下执行。有
19.7 packaged
我们最后要讲的一种 future 的用法是打包任务 packaged_task [14],我们同样给出完成相同功能的示例,让你方便对比一下:
1 |
|
打包任务里打包的是一个函数,模板参数就是一个函数类型。跟 thread、future、promise 一样,packaged_task 只能移动,不能复制。它是个函数对象,可以像正常函数一样被执行,也可以传递给 thread 在新线程中执行。它的特别地方,自然也是你可以从它得到一个未来量了。通过这个未来量,你可以得到这个打包任务的返回值,或者,至少知道这个打包任务已经执行结束了。
19.8 参考资料
- Herb Sutter, “The free lunch is over”. http://www.gotw.ca/publications/concurrency-ddj.htm
- Herb Sutter, “Effective concurrency”. https://herbsutter.com/2010/09/24/effective-concurrency-know-when-to-usean-active-object-instead-of-a-mutex/
- Anthony Williams, C++ Concurrency in Action (2nd ed.). Manning, 2019, https://www.manning.com/books/c-plus-plus-concurrency-in-action-secondedition
- cppreference.com, “std::thread”. https://en.cppreference.com/w/cpp/thread/thread
- cppreference.com, “std::jthread”. https://en.cppreference.com/w/cpp/thread/jthread
- cppreference.com, “std::mutex”. https://en.cppreference.com/w/cpp/thread/mutex
- cppreference.com, “std::recursive_mutex”. https://en.cppreference.com/w/cpp/thread/recursive_mutex
- cppreference.com, “
Standard library header <mutex>
“. https://en.cppreference.com/w/cpp/header/mutex - cppreference.com, “std::recursive_mutex”. https://en.cppreference.com/w/cpp/thread/condition_variable
- cppreference.com, “std::future”. https://en.cppreference.com/w/cpp/thread/future
- cppreference.com, “std::async”. https://en.cppreference.com/w/cpp/thread/async
- cppreference.com, “std::shared_future”. https://en.cppreference.com/w/cpp/thread/shared_future
- cppreference.com, “std::promise”. https://en.cppreference.com/w/cpp/thread/promise
- cppreference.com, “std::packaged_task”. https://en.cppreference.com/w/cpp/thread/packaged_task
03丨实战篇
21 | 工具漫谈:编译、格式化、代码检查、排错各显身手
21.1 编译器
- MSVC(Microsoft Visual C++):MSVC是由微软开发和维护的C++编译器和开发环境
- GCC:GNU 工具链的主要组成部分。
- Clang:Clang是一个开源的C/C++/Objective-C编译器前端,由LLVM编译器基础设施项目开发和维护。
21.2 格式化工具
- Clang-Format
21.3 代码检查工具
-
Clang-Tidy
默认情况下,Clang-Tidy 只做基本的分析。你也可以告诉它你想现代化你的代码和提高代码的可读性:
1
clang-tidy --checks='clang-analyzer-*,modernize-*,readability-*' test.cpp
以下面简单程序为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using namespace std;
int sqr(int x) { return x * x; }
int main()
{
int a[5] = { 1, 2, 3, 4, 5 };
int b[5];
for (int i = 0; i < 5; ++i) {
b[i] = sqr(a[i]);
}
for (int i : b) {
cout << i << endl;
}
char* ptr = NULL;
*ptr = '\0';
}Clang-Tidy 会报告下列问题:
-
<stddef.h>
应当替换成<cstddef>
-
函数形式 int func(…) 应当修改成 auto func(…) -> int
-
不要使用 C 数组,应当改成 std::array
-
5 是魔术数,应当改成具名常数
-
NULL 应当改成 nullptr
前两条我不想听。这种情况下,使用配置文件来定制行为就必要了。配置文件叫 .clangtidy,应当放在你的代码目录下或者代码的一个父目录下。Clang-Tidy 会使用最”近”的那个配置文件。下面的配置文件反映了我的偏好:
1
Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize-*,readability-*,-moderni
使用 Clang-Tidy 还需要注意的地方是,额外的命令行参数应当跟在命令行最后的 — 后面。比如,如果我们要扫描一个 C++ 头文件 foo.h,我们就需要明确告诉 Clang-Tidy 这是 C++ 文件(默认 .h 是 C 文件)。然后,如果我们需要包含父目录下的 common 目录,语言标准使用了 C++17,命令行就应该是下面这个样子:
1
clang-tidy foo.h -- -x c++ -std=c++17 -I../common
-
-
Cppcheck
21.4 排错工具
-
Valgrind
-
nvwa::debug_new
在 nvwa [21] 项目里,我也包含了一个很小的内存泄漏检查工具。它的最大优点是小巧,并且对程序运行性能影响极小;缺点主要是不及 Valgrind 易用和强大,只能检查 new 导致的内存泄漏,并需要侵入式地对项目做修改。
需要检测内存泄漏时,你需要把 debug_new.cpp 加入到项目里。比如,可以简单地在命令行上加入这个文件:
1
c++ test.cpp ../nvwa/nvwa/debug_new.cpp
下面是可能的运行时报错:
1
2Leaked object at 0x100302760 (size 20, 0x1000018a4)
*** 1 leaks found在使用 GCC 和 Clang 时,可以让它自动帮你找出内存泄漏点的位置。在命令行上需要加入可执行文件的名称,并产生调试信息:
1
c++ -D_DEBUG_NEW_PROGNAME=\"a.out\" -g test.cpp ../nvwa/nvwa/debug_new.cpp
这样,我们就可以在运行时看到一个更明确的错误:
1
2Leaked object at 0x100302760 (size 20, main (in a.out) (test.cpp:3))
*** 1 leaks found
21.5 网页工具
-
Compiler Explorer
这个网站,你不仅可以快速查看你的代码在不同编译器里的优化结果,还能快速分享结果。比如,下面这个链接,就可以展示我们之前讲过的一个模板元编程代码的编译结果: https://godbolt.org/z/zPNEJ4
-
C++ Insights
如果你在上面的链接里点击了”CppInsights”按钮的话,你就会跳转到 C++ Insights [24] 网站,并且你贴在 godbolt.org 的代码也会一起被带过去。这个网站提供了另外一个编译器目前没有提供、但十分有用的功能:展示模板的展开过程。
21.6 参考资料
- Visual Studio. https://visualstudio.microsoft.com/
- GCC, the GNU Compiler Collection. https://gcc.gnu.org/
- Clang: a C language family frontend for LLVM. https://clang.llvm.org/
- Jim Springfield, “Rejuvenating the Microsoft C/C++ compiler”. https://devblogs.microsoft.com/cppblog/rejuvenating-the-microsoft-cccompiler/
- Casey Carter, “Use the official range-v3 with MSVC 2017 version 15.9”. https://devblogs.microsoft.com/cppblog/use-the-official-range-v3-with-msvc-2017-version-15-9/
- cppreference.com, “std::regex”. https://en.cppreference.com/w/cpp/regex/basic_regex
- Microsoft, “Concurrency Runtime”. https://docs.microsoft.com/enus/cpp/parallel/concrt/concurrency-runtime
- ISO/IEC JTC1 SC22 WG21, “Programming languages—C++extensions for coroutines”. http://www.openstd.org/jtc1/sc22/wg21/docs/papers/2017/n4680.pdf
- Ulzii Luvsanbat, “Announcing: MSVC conforms to the C++ standard”. https://devblogs.microsoft.com/cppblog/announcing-msvc-conforms-to-the-cstandard/
- Jonathan Adamczewski, “The growth of modern C++ support”. http://brnz.org/hbr/?p=1404
- Vim Online. https://www.vim.org/
- Xavier Deguillard, clang_complete. https://github.com/xavierd/clang_complete
- “libc++” C++ Standard Library . https://libcxx.llvm.org/
- cppreference.com, “C++ compiler support”. https://en.cppreference.com/w/cpp/compiler_support
- Homebrew. https://brew.sh/
- 吴咏炜, “MSVCRT.DLL console I/O bug”. https://yongweiwu.wordpress.com/2016/05/27/msvcrt-dll-console-io-bug/
- ClangFormat. https://clang.llvm.org/docs/ClangFormat.html
- Clang-Tidy. https://clang.llvm.org/extra/clang-tidy/
- Daniel Marjamäki, Cppcheck. https://github.com/danmar/cppcheck
- Valgrind Home. https://valgrind.org/
- 吴咏炜, nvwa. https://github.com/adah1972/nvwa/
- Matt Godbolt, “Compiler Explorer”. https://godbolt.org/
- Matt Godbolt, compiler-explorer. https://github.com/mattgodbolt/compilerexplorer
- Andreas Fertig, “C++ Insights”. https://cppinsights.io/
22 | 处理数据类型变化和错误:optional、variant、expected 和Herbception
22.1 optional
optional
是 C++17 引入的一个非常有用的标准库类型,它用于表示一个可选的值。它可以持有一个值,也可以表示为空。这在很多场景下都非常有用,比如:
1.函数返回值
1 |
|
2.处理可选参数:
1 |
|
在面向对象(引用语义)的语言里,我们有时候会使用空值 null 表示没有找到需要的对象。也有人推荐使用一个特殊的空对象,来避免空值带来的一些问题 [1]。可不管是空值,还是空对象,对于一个返回普通对象(值语义)的 C++ 函数都是不适用的——空值和空对象只能用在返回引用 / 指针的场合,一般情况下需要堆内存分配,在 C++ 里会引额外的开销。
C++17 引入的 optional 模板 [2] 可以(部分)解决这个问题。语义上来说,optional 代表一个”也许有效””可选”的对象。语法上来说,一个 optional 对象有点像一个指针,但它所管理的对象是直接放在 optional 里的,没有额外的内存分配。
构造一个 optional<T>
对象有以下几种方法:
-
不传递任何参数,或者使用特殊参数 std::nullopt(可以和 nullptr 类比),可以构造一个”空”的 optional 对象,里面不包含有效值。
1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
std::optional<int> opt1; // 默认构造,空的 optional 对象
std::optional<int> opt2 = std::nullopt; // 使用 std::nullopt 构造空的 optional 对象
std::cout << std::boolalpha;
std::cout << "opt1 has value: " << opt1.has_value() << std::endl; // 输出 false
std::cout << "opt2 has value: " << opt2.has_value() << std::endl; // 输出 false
return 0;
} -
第一个参数是 std::in_place,后面跟构造 T 所需的参数,可以在 optional 对象上直接构造出 T 的有效值。
1
2
3
4
5
6
7
8
9
10
11
12
int main() {
std::optional<std::string> opt = std::optional<std::string>(std::in_place, "Hello, World!");
std::cout << "opt has value: " << opt.has_value() << std::endl; // 输出 true
std::cout << "opt value: " << *opt << std::endl; // 输出 "Hello, World!"
return 0;
}解释:
std::in_place
是 C++17 中引入的一个特殊的构造函数参数,用于在容器内部直接构造对象,而不需要先创建对象然后再将其移动或拷贝到容器中。在示例中:
1
std::optional<std::string> opt = std::optional<std::string>(std::in_place, "Hello, World!");
这行代码使用
std::in_place
指示std::optional<std::string>
应该在内部直接构造std::string
对象,参数为"Hello, World!"
。 -
如果 T 类型支持拷贝构造或者移动构造的话,那在构造
optional<T>
时也可以传递一个 T 的左值或右值来将 T 对象拷贝或移动到 optional 中。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
std::string str = "Hello, World!";
std::optional<std::string> opt1 = str; // 拷贝构造
std::optional<std::string> opt2 = std::move(str); // 移动构造
std::cout << "opt1 has value: " << opt1.has_value() << std::endl; // 输出 true
std::cout << "opt1 value: " << *opt1 << std::endl; // 输出 "Hello, World!"
std::cout << "opt2 has value: " << opt2.has_value() << std::endl; // 输出 true
std::cout << "opt2 value: " << *opt2 << std::endl; // 输出 "Hello, World!"
return 0;
}
对于上面的第 1 种情况,optional 对象里是没有值的,在布尔值上下文里,会得到 false(类似于空指针的行为)。对于上面的第 2、3 两种情况,optional 对象里是有值的,在布尔值上下文里,会得到 true(类似于有效指针的行为)。类似的,在 optional 对象有值的情况下,你可以用 * 和 -> 运算符去解引用(没值的情况下,结果是未定义行为)。
虽然 optional 是 C++17 才标准化的,但实际上这个用法更早就通行了。因为 optional 的实现不算复杂,有些库里就自己实现了一个版本。比如 cpptoml [3] 就给出 了下面这样的示例(进行了翻译和重排版),用法跟标准的 optional 完全吻合。
cpptoml 里只是个缩微版的 optional,实现只有几十行,也不支持我们上面说的所有构造方式。标准库的 optional 为了方便程序员使用,除了我目前描述的功能,还支持下面的操作:
-
安全的析构行为
-
显式的 has_value 成员函数,判断 optional 是否有值
-
#include <optional> #include <iostream> int main() { std::optional<int> opt = 42; if (opt.has_value()) { std::cout << "opt contains value: " << *opt << std::endl; // 输出 42 } else { std::cout << "opt is empty" << std::endl; } return 0; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- value 成员函数,行为类似于 `*`,但在 optional 对象无值时会抛出异常
- std::bad_optional_access
- ```c++
int main() {
std::optional<int> opt = 42;
try {
int value = opt.value();
std::cout << "opt value: " << value << std::endl; // 输出 42
} catch (const std::bad_optional_access& e) {
std::cout << "Exception: " << e.what() << std::endl;
}
return 0;
}
-
-
value_or 成员函数,在 optional 对象无值时返回传入的参数
-
#include <optional> #include <iostream> int main() { std::optional<int> opt; int value = opt.value_or(100); std::cout << "opt value or 100: " << value << std::endl; // 输出 100 return 0; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- swap 成员函数,和另外一个 optional 对象进行交换
- ```c++
int main() {
std::optional<int> opt1 = 42;
std::optional<int> opt2 = 100;
opt1.swap(opt2);
std::cout << "opt1: " << *opt1 << ", opt2: " << *opt2 << std::endl; // 输出 opt1: 100, opt2: 42
return 0;
}
-
-
reset 成员函数,清除 optional 对象包含的值
-
#include <optional> #include <iostream> int main() { std::optional<int> opt = 42; opt.reset(); std::cout << "opt has value: " << opt.has_value() << std::endl; // 输出 false return 0; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- emplace 成员函数,在 optional 对象上构造一个新的值(不管成功与否,原值会被丢弃)
- ```c++
int main()
{
std::optional<int> opt;
opt.emplace(42);
std::cout << "opt value: " << *opt << std::endl; // 输出 42
opt.emplace(100);
std::cout << "opt value: " << *opt << std::endl; // 输出 100
opt = 666;
std::cout << "opt value: " << *opt << std::endl; // 输出 100
return 0;
}
-
-
make_optional 全局函数,产生一个 optional 对象(类似 make_pair、make_unique 等)
-
#include <optional> #include <iostream> int main() { auto opt = std::make_optional<int>(42); std::cout << "opt value: " << *opt << std::endl; // 输出 42 return 0; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 全局比较操作
- ```c++
int main() {
std::optional<int> opt1 = 42;
std::optional<int> opt2 = 100;
std::optional<int> opt3 = 42;
std::cout << std::boolalpha;
std::cout << "opt1 == opt2: " << (opt1 == opt2) << std::endl; // 输出 false
std::cout << "opt1 == opt3: " << (opt1 == opt3) << std::endl; // 输出 true
return 0;
}
-
-
等等
如果我们认为无值就是数据无效,应当跳过剩下的处理,我们可以写出下面这样的高阶函数:
1 | template <typename T> |
has_value 比较简单,它可以有一个或多个 optional 参数,并在所有参数都有值时返回真,否则返回假。lift_optional 稍复杂些,它接受一个函数,返回另外一个函数。在返回的函数里,参数是一个或多个 optional 类型,result_type 是用参数的值(value())去调用原先函数时的返回值类型,最后返回的则是 result_type 的 optional 封装。函数内部会检查所有的参数是否都有值(通过调用 has_value):有值时会去拿参数的值去调用原先的函数,否则返回一个空的 optional 对象。
这个函数能把一个原本要求参数全部有效的函数抬升(lift)成一个接受和返回 optional 参数的函数,并且,只在参数全部有效时去调用原来的函数。这是一种非常函数式的编程方式。使用上面函数的示例代码如下:
1 |
|
输出
1 | (Nothing) |
22.2 variant
optional 是一个非常简单而又好用的模板,很多情况下,使用它就足够解决问题了。在某种意义上,可以把它看作是允许有两种数值的对象:要么是你想放进去的对象,要么是 nullopt(再次提醒,联想 nullptr)。如果我们希望除了我们想放进去的对象,还可以是 nullopt 之外的对象怎么办呢(比如,某种出错的状态)?又比如,如果我希望有三种或更多不同的类型呢?这种情况下,variant [4] 可能就是一个合适的解决方案。
在没有 variant 类型之前,你要达到类似的目的,恐怕会使用一种叫做带标签的联合(tagged union)的数据结构。比如,下面就是一个可能的数据结构定义:
std::variant
的特点
- 类型安全:
std::variant
确保只能包含定义的类型之一,并且提供了类型安全的访问方法。 - 代替联合体:与传统的 C++ 联合体相比,
std::variant
更加安全,因为它会跟踪当前存储的类型。 - 多态性:通过
std::variant
,可以在单个变量中存储多种类型,且访问时无需动态类型转换。
定义和初始化
可以在 std::variant
中定义多个类型,并通过直接赋值或构造函数初始化。
1 |
|
访问 std::variant
可以通过 std::get
和 std::get_if
来访问 std::variant
中存储的值。
std::get
:直接获取存储的值,但如果类型不匹配会抛出std::bad_variant_access
异常。std::get_if
:返回指向存储值的指针,如果类型不匹配返回nullptr
。
1 | cppCopy code |
访问当前存储的类型
可以使用 std::visit
和 std::holds_alternative
来访问和处理 std::variant
中当前存储的类型。
std::visit
:使用访问者模式处理存储的值。std::holds_alternative
:检查std::variant
是否持有特定类型。
1 | cppCopy code |
举个综合例子
1 |
|
在没有 variant 类型之前,你要达到类似的目的,恐怕会使用一种叫做带标签的联合(tagged union)的数据结构。比如,下面就是一个可能的数据结构定义:
1 | struct FloatIntChar { |
这个数据结构的最大问题,就是它实际上有很多复杂情况需要特殊处理。对于我们上面例子里的 POD 类型,这么写就可以了(但我们仍需小心保证我们设置的 type 和实际使用的类型一致)。如果我们把其中一个类型换成非 POD 类型,就会有复杂问题出现。比如,下面的代码是不能工作的:
1 | struct StringIntChar { |
编译器会很合理地看到在 union 里使用 string 类型会带来构造和析构上的问题,所以会拒绝工作。要让这个代码工作,我们得手工加上析构函数,并且,在析构函数里得小心地判断存储的是什么数值,来决定是否应该析构(否则,默认不调用任何 union 里的析构函数,从而可能导致资源泄漏):
1 | ~StringIntChar() |
这样,我们才能安全地使用它(还是很麻烦):
1 | StringIntChar obj { |
这里用到了按成员初始化的语法,把类型设置成了字符串,同时设置了字符串的值。不用说,这是件麻烦、容易出错的事情。同时,细查之后我发现,这个语法虽然在 C99 里有,但在 C++ 里要在 C++20 才会被标准化,因此实际是有兼容性问题的——老版本的 MSVC,或最新版本的 MSVC 在没有开启 C++20 支持时,就不支持这个语法。
所以,目前的主流建议是,应该避免使用”裸” union 了。替换方式,就是这一节要说的 variant。上面的例子,如果用 variant 的话,会非常的干净利落:
1 | variant<string, int, char> obj {"Hello world"}; |
可以注意到我上面构造时使用的是 const char*,但构造函数仍然能够正确地选择 string 类型,这是因为标准要求实现在没有一个完全匹配的类型的情况下,会选择成员类型中能够以传入的类型来构造的那个类型进行初始化(有且只有一个时)。string 类存在形式为 string(const char*) 的构造函数(不精确地说),所以上面的构造能够正确进行。
跟 tuple 相似,variant 上可以使用 get 函数模板,其模板参数可以是代表序号的数字,也可以是类型。如果编译时可以确定序号或类型不合法,我们在编译时就会出错。如果序号或类型合法,但运行时发现 variant 里存储的并不是该类对象,我们则会得到一个异常 bad_variant_access。
variant 上还有一个重要的成员函数是 index,通过它我们能获得当前的数值的序号。就我们上面的例子而言,obj.index() 即为 1。正常情况下,variant 里总有一个有效的数值(缺省为第一个类型的默认构造结果),但如果 emplace 等修改操作中发生了异常, variant 里也可能没有任何有效数值,此时 index() 将会得到 variant_npos。
从基本概念来讲,variant 就是一个安全的 union,相当简单,我就不多做其他介绍了。你可以自己看文档来了解进一步的信息。其中比较有趣的一个非成员函数是 visit [5],文档里展示了一个非常简洁的、可根据当前包含的变量类型进行函数分发的方法。
平台细节:在老于 Mojave 的 macOS 上编译含有 optional 或 variant 的代码,需要在文件开头加上:
1 |
原因是苹果在头文件里把 optional 和 variant 在早期版本的 macOS 上禁掉了,而上面的代码去掉了这几个宏里对使用 bad_optional_access 和 bad_variant_access 的平台限制。
22.3 expected
std::expected
是一个在 C++23 标准库中引入的类模板,它是用于处理可能失败的操作的一种新工具。std::expected
结合了返回值和错误处理的能力,可以取代诸如 std::optional
和异常处理等传统方法。
std::expected
的基本概念
std::expected
模板类包含两种状态之一:
- 成功状态:包含一个有效值。
- 失败状态:包含一个错误值。
它通过将这两种状态封装在一个对象中,使得函数可以返回一个结果或一个错误,从而提供了一种更为显式的错误处理方式。
头文件
std::expected
定义在头文件 <expected>
中。
1 |
定义和初始化
可以通过多种方式来初始化 std::expected
对象。
1 |
|
访问 std::expected
可以使用成员函数 value
和 error
来访问 std::expected
中的值或错误。此外,还可以使用布尔值上下文检查 std::expected
是否处于成功状态。
value
:获取存储的成功值。如果对象处于失败状态,调用value
会抛出std::bad_expected_access
异常。error
:获取存储的错误值。如果对象处于成功状态,调用error
会抛出std::bad_expected_access
异常。
1 |
|
示例:使用 std::expected
实现简单的文件读取
以下是一个使用 std::expected
实现简单文件读取的示例。如果读取成功,返回文件内容;否则返回错误消息。
1 |
|
解释
- 定义
std::expected
对象:std::expected<int, std::string> success = 42;
:表示成功状态,包含整数值 42。std::expected<int, std::string> failure = std::unexpected("Error occurred");
:表示失败状态,包含错误消息。
- 访问
std::expected
的值和错误:- 使用
value()
函数获取成功状态的值。 - 使用
error()
函数获取失败状态的错误消息。
- 使用
- 检查
std::expected
的状态:- 在布尔上下文中使用
std::expected
对象可以检查它是否处于成功状态。
- 在布尔上下文中使用
和前面介绍的两个模板不同,expected 不是 C++ 标准里的类型。但概念上这三者有相关性,因此我们也放在一起讲一下。
我前面已经提到,optional 可以作为一种代替异常的方式:在原本该抛异常的地方,我们可以改而返回一个空的 optional 对象。当然,此时我们就只知道没有返回一个合法的对象,而不知道为什么没有返回合法对象了。我们可以考虑改用一个 variant,但我们此时需要给错误类型一个独特的类型才行,因为这是 variant 模板的要求。比如:
1 | enum class error_code { |
这当然是一种可行的错误处理方式:我们可以判断返回值的 index(),来决定是否发生了错误。但这种方式不那么直截了当,也要求实现对允许的错误类型作出规定。Andrei Alexandrescu 在 2012 年首先提出的 Expected 模板 [6],提供了另外一种错误处理方式。他的方法的要点在于,把完整的异常信息放在返回值,并在必要的时候,可以”重放”出来,或者手工检查是不是某种类型的异常。
他的概念并没有被广泛推广,最主要的原因可能是性能。异常最被人诟病的地方是性能,而他的方式对性能完全没有帮助。不过,后面的类似模板都汲取了他的部分思想,至少会用一种显式的方式来明确说明当前是异常情况还是正常情况。在目前的 expected 的标准提案[7] 里,用法有点是 optional 和 variant 的某种混合:模板的声明形式像 variant,使用正常返回值像 optional。
下面的代码展示了一个 expected 实现 [8] 的基本用法。
1 |
|
输出是:
1 | unexpected: divide by zero: Are you serious? |
一个 expected<T, E>
差不多可以看作是 T 和 unexpected<E>
的 variant。在学过上面的 variant 之后,我们应该很容易看明白上面的程序了。下面是几个需要注意一下的地方:
- 如果一个函数要正常返回数据,代码无需任何特殊写法;如果它要表示出现了异常,则可以返回一个 unexpected 对象。
- 这个返回值可以用来和一个正常值或 unexpected 对象比较,可以在布尔值上下文里检查是否有正常值,也可以用
*
运算符来取得其中的正常值——与 optional 类似,在没有正常值的情况下使用*
是未定义行为。 - 可以用 value 成员函数来取得其中的正常值,或使用 error 成员函数来取得其中的错误值——与 variant 类似,在 expected 中没有对应的值时产生异常 bad_expected_access。
- 返回错误跟抛出异常比较相似,但检查是否发生错误的代码还是要比异常处理啰嗦。
22.4 Herbception
C++ 中的异常处理(Exception Handling)机制使用的是堆栈展开(stack unwinding),这在某些情况下会导致性能开销和代码复杂性。特别是在高性能或实时系统中,异常处理的不可预测性是一个问题。为了应对这些挑战,Herb Sutter 提出了“Herbception”这个概念。
Herbception 的核心思想
- 零开销异常处理:Herbception 提议的核心思想之一是实现零开销的异常处理。这意味着在不抛出异常时,异常处理机制不应带来任何运行时开销。
- 结构化异常处理(SEH)和契约(Contracts):Herb Sutter 提到可以借鉴其他编程语言的做法,如 C# 的结构化异常处理和契约编程,将其引入到 C++ 中。这将使得异常处理更加高效和明确。
- 静态和动态混合检查:通过静态分析和运行时检查相结合,尽可能在编译期捕获错误,从而减少运行时异常的发生。
提议的改进
[[noexcept]]
默认行为:让所有函数默认情况下都使用[[noexcept]]
,除非显式声明它们可能抛出异常。这将显著减少异常处理的开销,因为编译器可以假设大多数函数不会抛出异常。- 契约编程:引入契约(Contracts)机制,包括前置条件(Preconditions)、后置条件(Postconditions)和不变性(Invariants)。契约编程可以在编译时进行检查,捕获大多数逻辑错误,从而减少运行时异常的需求。
- 改进的错误处理机制:提出一种新的错误处理机制,该机制在性能和可预测性方面优于传统的异常处理。例如,通过返回
std::expected
或std::outcome
类型,显式地处理可能的错误。
示例:使用 std::expected
虽然 std::expected
不是 Herbception 的一部分,但它体现了类似的思想,即通过返回值而非抛出异常来处理错误。以下是一个示例:
1 |
|
23 | 数字计算:介绍线性代数和数值计算库
23.1 Armadillo
23.2 Boost.Multiprecision
23.3 参考资料
- Wikipedia, “Basic Linear Algebra Subprograms”. https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms
- Wikipedia, “LAPACK”. https://en.wikipedia.org/wiki/LAPACK
- Wikipedia, “ARPACK”. https://en.wikipedia.org/wiki/ARPACK
- Zhang Xianyi et al., OpenBLAS. https://github.com/xianyi/OpenBLAS
- Intel, Math Kernel Library. https://software.intel.com/mkl
- Ilya Yaroshenko, mir-glas. https://github.com/libmir/mir-glas
- Conrad Sanderson and Ryan Curtin, “Armadillo: C++ library for linear algebra & scientific computing”. http://arma.sourceforge.net/
- Wikipedia, “Expression templates”. https://en.wikipedia.org/wiki/Expression_templates
- John Maddock, Boost.Multiprecision. https://www.boost.org/doc/libs/release/libs/multiprecision/doc/html/index.html
- The GNU MP bignum library. https://gmplib.org/
- 吴咏炜, “Choosing a multi-precision library for C++—a critique”. https://yongweiwu.wordpress.com/2016/06/04/choosing-a-multi-precisionlibrary-for-c-a-critique/
24 | Boost:你需要的”瑞士军刀”
24.1 Boost 概览
Boost 的网站把 Boost 描述成为经过同行评审的、可移植的 C++ 源码库(peer-reviewed portable C++ source libraries)[1]。换句话说,它跟很多个人开源库不一样的地方在于,它的代码是经过评审的。事实上,Boost 项目的背后有很多 C++ 专家,比如发起人之一的 Dave Abarahams 是 C++ 标准委员会的成员,也是《C++ 模板元编程》一书 [2] 的作者。这也就使得 Boost 有了很不一样的特殊地位:它既是 C++ 标准库的灵感来源之一,也是 C++ 标准库的试验田。下面这些 C++ 标准库就源自 Boost:
- 智能指针
- thread
- regex
- random
- array
- bind
- tuple
- optional
- variant
- any
- string_view
- filesystem
- 等等
当然,将来还会有新的库从 Boost 进入 C++ 标准,如网络库的标准化就是基于 Boost.Asio 进行的。因此,即使相关的功能没有被标准化,我们也可能可以从 Boost 里看到某个功能可能会被标准化的样子——当然,最终标准化之后的样子还是经常有所变化的。
我们也可以在我们的编译器落后于标准、不能提供标准库的某个功能时使用 Boost 里的替代品。比如,我之前提到过老版本的 macOS 上苹果的编译器不支持 optional 和 variant。除了我描述的不正规做法,改用 Boost 也是方法之一。比如,对于 variant,所需的改动只是:
- 把包含
<variant>
改成包含<boost/variant.hpp>
- 把代码中的 std::variant 改成 boost::variant
这样,就基本大功告成了。
24.2 Boost 的安装
在主要的开发平台上,现在你都可以直接安装 Boost,而不需要自己从源代码编译了:
- 在 Windows 下使用 MSVC,我们可以使用 NuGet 安装(按需逐个安装)
- 在 Linux 下,我们可以使用系统的包管理器(如 apt 和 yum)安装(按需逐个安装,或一次性安装所有的开发需要的包)
- 在 macOS 下,我们可以使用 Homebrew 安装(一次性安装完整的 Boost)
24.3 Boost.TypeIndex
TypeIndex 是一个很轻量级的库,它不需要链接,解决的也是使用模板时的一个常见问题,如何精确地知道一个表达式或变量的类型。我们还是看一个例子:
1 |
|
上面的代码里,展示了标准的 typeid 和 Boost 的 type_id 和 type_id_with_cvr 的使用。它们的区别是:
- typeid 是标准 C++ 的关键字,可以应用到变量或类型上,返回一个 std::type_info。我们可以用它的 name 成员函数把结果转换成一个字符串,但标准不保证这个字符串的可读性和唯一性。
- type_id 是 Boost 提供的函数模板,必须提供类型作为模板参数——所以对于表达式和变量我们需要使用 decltype。结果可以直接输出到 IO 流上。
- type_id_with_cvr 和 type_id 相似,但它获得的结果会包含 const/volatile 状态及引用类型。
上面程序在 MSVC 下的输出为:
1 | *** Using typeid |
在 GCC 下的输出为:
1 | *** Using typeid |
我们可以看到 MSVC 下 typeid 直接输出了比较友好的类型名称,但 GCC 下没有。此外,我们可以注意到:
- typeid 的输出忽略了 const 修饰,也不能输出变量的引用类型。
- type_id 保证可以输出友好的类型名称,输出时也不需要调用成员函数,但例子里它忽略了 int 的 const 修饰,也和 typeid 一样不能输出表达式的引用类型。
- type_id_with_cvr 可以输出 const/volatile 状态和引用类型,注意这种情况下模板参数必须包含引用类型,所以我用了 decltype((v)) 这种写法,而不是 decltype(v)。如果你忘了这两者的区别,请复习一下 [第 8 讲] 的 decltype。
显然,除非你正在使用 MSVC,否则调试期 typeid 的用法完全应该用 Boost 的 type_id 来替代。另外,如果你的开发环境要求禁用 RTTI(运行时类型识别),那 typeid 在 Clang 和 GCC 下根本不能使用,而使用 Boost.TypeIndex 库仍然没有问题。
当然,上面说的前提都是你在调试中试图获得变量的类型,而不是要获得一个多态对象的运行时类型。后者还是离不开 RTTI 的——虽然你也可以用一些其他方式来模拟 RTTI,但我个人觉得一般的项目不太有必要这样做。下面的代码展示了 typeid 和 type_id 在获取对象类型上的差异:
1 |
|
输出为:
1 | typeid(*ptr) is NOT shape |
24.4 Boost.Core
Core 里面提供了一些通用的工具,这些工具常常被 Boost 的其他库用到,而我们也可以使用,不需要链接任何库。在这些工具里,有些已经(可能经过一些变化后)进入了 C++ 标准,如:
- addressof,在即使用户定义了 operator& 时也能获得对象的地址
- enable_if,这个我们已经深入讨论过了([第 14 讲])
- is_same,判断两个类型是否相同,C++11 开始在
<type_traits>
中定义 - ref,和标准库的相同,我们在 [第 19 讲] 讨论线程时用过
我们在剩下的里面来挑几个讲讲。
-
boost::core::demangle
boost::core::demangle 能够用来把 typeid 返回的内部名称”反粉碎”(demangle)成可读的形式,看代码和输出应该就非常清楚了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using namespace std;
using boost::core::demangle;
int main()
{
vector<int> v;
auto it = v.cbegin();
cout << "*** Using typeid\n";
cout << typeid(const int).name() << endl;
cout << typeid(v).name() << endl;
cout << typeid(it).name() << endl;
cout << "*** Demangled\n";
cout << demangle(typeid(const int).name()) << endl;
cout << demangle(typeid(v).name()) << endl;
cout << demangle(typeid(it).name()) << endl;
}GCC 下的输出为:
1
2
3
4
5
6
7
8
9*** Using typeid
i
St6vectorIiSaIiEE
N9__gnu_cxx17__normal_iteratorIPKiSt6vectorIiSaIiEEEE
*** Demangled
int
std::vector<int, std::allocator<int> >
__gnu_cxx::__normal_iterator<int const*, std::vector<int,
std::allocator<int> > >如果你不使用 RTTI 的话,那直接使用 TypeIndex 应该就可以。如果你需要使用 RTTI、又不是(只)使用 MSVC 的话,demangle 就会给你不少帮助。
-
boost::noncopyable
boost::noncopyable 提供了一种非常简单也很直白的把类声明成不可拷贝的方式。比如,我们 [第 1 讲] 里的 shape_wrapper,用下面的写法就明确表示了它不允许被拷贝:
1
2
3
4
class shape_wrapper : private boost::noncopyable {
//…
};你当然也可以自己把拷贝构造和拷贝赋值函数声明成 = delete,不过,上面的写法是不是可读性更佳?
-
boost::swap
你有没有印象在通用的代码如何对一个不知道类型的对象执行交换操作?不记得的话,标准做法是这样的:
1
2
3
4{
using std::swap;
swap(lhs, rhs);
}即,我们需要(在某个小作用域里)引入 std::swap,然后让编译器在”看得到” std::swap 的情况下去编译 swap 指令。根据 ADL,如果在被交换的对象所属类型的名空间下有 swap 函数,那个函数会被优先使用,否则,编译器会选择通用的 std::swap。似乎有点小啰嗦。使用 Boost 的话,你可以一行搞定:
1
boost::swap(lhs, rhs);
当然,你需要包含头文件
<boost/core/swap.hpp>
。
24.5 Boost.Conversion
Conversion 同样是一个不需要链接的轻量级的库。它解决了标准 C++ 里的另一个问题,标准类型之间的转换不够方便。在 C++11 之前,这个问题尤为严重。在 C++11 里,标准引入了一系列的函数,已经可以满足常用类型之间的转换。但使用 Boost.Conversion 里的 lexical_cast 更不需要去查阅方法名称或动脑子去努力记忆。
下面是一个例子:
1 |
|
GCC 下的输出为:
1 | 42 |
我觉得 GCC 里 stoi 的异常输出有点太言简意赅了……而 lexical_cast 的异常输出在不同的平台上有很好的一致性。
24.6 Boost.ScopeExit
我们说过 RAII 是推荐的 C++ 里管理资源的方式。不过,作为 C++ 程序员,跟 C 函数打交道也很正常。每次都写个新的 RAII 封装也有点浪费。Boost 里提供了一个简单的封装,你可以从下面的示例代码里看到它是如何使用的:
1 |
|
唯一需要说明的可能就是 BOOST_SCOPE_EXIT 里的那个 & 符号了——把它理解成 lambda 表达式的按引用捕获就对了(虽然 BOOST_SCOPE_EXIT 可以支持 C++98 的代码)。如果不需要捕获任何变量,BOOST_SCOPE_EXIT 的参数必须填为 void。
使用这个库也只需要头文件。注意实现类似的功能在 C++11 里相当容易,但由于 ScopeExit 可以支持 C++98 的代码,因而它的实现还是相当复杂的。
24.6 Boost.Program_options
传统上 C 代码里处理命令行参数会使用 getopt。我也用过,比如在下面的代码中:
https://github.com/adah1972/breaktext/blob/master/breaktext.c
这种方式有不少缺陷:
- 一个选项通常要在三个地方重复:说明文本里,getopt 的参数里,以及对 getopt 的返回结果进行处理时。不知道你觉得怎样,我反正发生过改了一处、漏改其他的错误。
- 对选项的附加参数需要手工写代码处理,因而常常不够严格(C 的类型转换不够方便,尤其是检查错误)。
Program_options 正是解决这个问题的。这个代码有点老了,不过还挺实用;懒得去找特别的处理库时,至少这个伸手可用。使用这个库需要链接 boost_program_options 库。
下面的代码展示了代替上面的 getopt 用法的代码:
1 |
|
略加说明一下:
- options_description 是基本的选项描述对象的类型,构造时我们给出对选项的基本描述。
- options_description 对象的 add_options 成员函数会返回一个函数对象,然后我们直接用括号就可以添加一系列的选项。
- 每个选项初始化时可以有两个或三个参数,第一项是选项的形式,使用长短选项用逗号隔开的字符串(可以只提供一种),最后一项是选项的文字描述,中间如果还有一项的话,就是选项的值描述。
- 选项的值描述可以用 value、bool_switch 等方法,参数是输出变量的指针。
- variables_map,变量映射表,用来存储对命令行的扫描结果;它继承了标准的 std::map。
- notify 成员函数用来把变量映射表的内容实际传送到选项值描述里提供的那些变量里去。
- count 成员函数继承自 std::map,只能得到 0 或 1 的结果。
24.7 Boost.Hana
Boost 里自然也有模板元编程相关的东西。但我不打算介绍 MPL、Fusion 和 Phoenix 那些,因为有些技巧,在 C++11 和 Lambda 表达式到来之后,已经略显得有点过时了。 Hana 则不同,它是一个使用了 C++11/14 实现技巧和惯用法的新库,也和一般的模板库一样,只要有头文件就能使用。
Hana 里定义了一整套供编译期使用的数据类型和函数。我们现在看一下它提供的部分类型:
- type:把类型转化成对象(我们在 [第 13 讲] 曾经示例过相反的动作,把数值转化成对象),来方便后续处理。
- integral_constant:跟 std::integral_constant 相似,但定义了更多的运算符和语法糖。特别的,你可以用字面量来生成一个 long long 类型的
integral_constant,如 1_c。 - string:一个编译期使用的字符串类型。
- tuple:跟 std::tuple 类似,意图是当作编译期的 vector 来使用。
- map:编译期使用的关联数组。
- set:编译期使用的集合。
Hana 里的算法的名称跟标准库的类似,我就不一一列举了。下面的例子展示了一个基本用法:
1 |
|
这个程序可以编译,但没有任何运行输出。在这个程序里,我们做了下面这几件事:
- 使用 type_c 把类型转化成 type 对象,并构造了类型对象的 tuple
- 使用 remove_if 算法移除了 tup 中的指针类型
- 使用静态断言确认了结果是我们想要的
- 使用静态断言确认了可以用 reverse 把 tup 反转一下
- 使用静态断言确认了可以用方括号运算符来获取 tup 中的某一项
可以看到,Hana 本质上以类似普通的运行期编程的写法,来做编译期的计算。上面展示的只是一些最基本的用法,而 Hana 的文档里展示了很多有趣的用法。尤其值得一看的是,文档中展示了如何利用 Hana 提供的机制,来自己定义 switch_
、case_
、default_
,使得下面的代码可以通过编译:
1 | boost::any a = 'x'; |
我个人认为很有意思。
24.8 参考资料
- Boost C++ Libraries. https://www.boost.org/
- David Abarahams and Aleksey Gurtovoy, C++ Template Metaprogramming. Addison-Wesley, 2004. 有中文版(荣耀译,机械工业出版社,2010 年)
25 | 两个单元测试库:C++里如何进行单元测试?
25.1 Boost.Test
1 |
|
从代码里可以看到:
- 我们在包含单元测试的头文件之前定义了 BOOST_TEST_MAIN。如果编译时用到了多个源文件,只有一个应该定义该宏。多文件测试的时候,我一般会考虑把这个定义这个宏加包含放在一个单独的文件里(只有两行)。
- 我们用 BOOST_AUTO_TEST_CASE 来定义一个测试用例。一个测试用例里应当有多个测试语句(如 BOOST_CHECK)。
- 我们用 BOOST_CHECK 或 BOOST_TEST 来检查一个应当成立的布尔表达式(区别下面会讲)。
- 我们用 BOOST_CHECK_THROW 来检查一个应当抛出异常的语句。
- 我们用 BOOST_CHECK_NO_THROW 来检查一个不应当抛出异常的语句。
可以用下面的命令行来进行编译:
- MSVC:
cl /DBOOST_TEST_DYN_LINK /EHsc /MD test.cpp
- GCC:
g++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework
- Clang:
clang++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework
运行结果如下图所示:
我们现在能看到 BOOST_CHECK 和 BOOST_TEST 的区别了。后者是一个较新加入 Boost.Test 的宏,能利用模板技巧来输出表达式的具体内容。但在某些情况下, BOOST_TEST 试图输出表达式的内容会导致编译出错,这时可以改用更简单的 BOOST_CHECK。
不管是 BOOST_CHECK 还是 BOOST_TEST,在测试失败时,执行仍然会继续。在某些情况下,一个测试失败后继续执行后面的测试已经没有意义,这时,我们就可以考虑使用 BOOST_REQUIRE 或 BOOST_TEST_REQUIRE——表达式一旦失败,整个测试用例会停止执行(但其他测试用例仍会正常执行)。
缺省情况下单元测试的输出只包含错误信息和结果摘要,但输出的详细程度是可以通过命令行选项来进行控制的。如果我们在运行测试程序时加上命令行参数 —log_level=all(或 -l all),我们就可以得到下面这样更详尽的输出:
现在额外可以看到:
- 在进入、退出测试模块和用例时的提示
- BOOST_TEST_MESSAGE 的输出
- 正常通过的测试的输出
- 用例里无测试断言的警告
使用 Windows 的同学如果运行了测试程序的话,多半会惊恐地发现终端上的文字颜色已经发生了变化。这似乎是 Boost.Test 在 Windows 上特有的一个问题:建议你把单元测试的色彩显示关掉。你可以在系统高级设置里添加下面这个环境变量,也可以直接在命令行上输入:
1 | set BOOST_TEST_COLOR_OUTPUT=0 |
Boost.Test 产生的可执行代码支持很多命令行参数,可以用 —help 命令行选项来查看。常用的有:
- build_info 可用来展示构建信息
- color_output 可用来打开或关闭输出中的色彩
- log_format 可用来指定日志输出的格式,包括纯文本、XML、JUnit 等
- log_level 可指定日志输出的级别,有 all、test_suite、error、fatal_error、nothing等一共 11 个级别
- run_test 可选择只运行指定的测试用例
- show_progress 可在测试时显示进度,在测试数量较大时比较有用(见下图)
25.2 Catch2
优点:
- 只需要单个头文件即可使用,不需要安装和链接,简单方便
- 可选使用 BDD(Behavior-Driven Development)风格的分节形式
- 测试失败可选直接进入调试器(Windows 和 macOS 上)
拿前面 Boost.Test 的示例直接改造一下:
1 |
|
测试用例的参数:第一项是名字,第二项是标签,可以一个或多个。你除了可以直接在命令行上写测试的名字(不需要选项)来选择运行哪个测试外,也可以写测试的标签来选择运行哪些测试。
这是它在 Windows 下用 MSVC 编译的输出。
终端的色彩不会被搞乱。缺省的输出清晰程度相当不错。至少在 Windows 下,它看起来可能是个比 Boost.Test 更好的选择。但反过来,在浅色的终端里,Catch2 的色彩不太友好。Boost.Test 在 Linux 和 macOS 下则不管终端的色彩设定,都有比较友好的输出。
和 Boost.Test 类似,Catch2 的测试结果输出格式也是可以修改的。默认格式是纯文本,但你可以通过使用 -r junit 来设成跟 JUnit 兼容的格式,或使用 -r xml 输出成 Catch2 自己的 XML 格式。这方面,它比 Boost.Test 明显易用的一个地方是格式参数大小写不敏感,而在 Boost.Test 里你必须用全大写的形式,如 -f JUNIT,麻烦!
BDD 风格的测试一般采用这样的结构:
- Scenario:场景,我要做某某事
- Given:给定,已有的条件
- When:当,某个事件发生时
- Then:那样,就应该发生什么
如果我们要测试一个容器,那代码就应该是这个样子的:
cpp
1 | SCENARIO("Int container can be accessed and modified", "[container]") |
你可以在程序前面加上类型定义来测试你自己的容器类或标准容器(如 vector<int>
)。这是一种非常直观的写测试的方式。正常情况下,你当然应该看到:
All tests passed (12 assertions in 1 test case)
如果你没有留意到的话,在 GIVEN 里 WHEN 之前的代码是在每次 WHEN 之前都会执行一遍的。这也是 BDD 方式的一个非常方便的地方。
如果测试失败,我们就能看到类似下面这样的信息输出了(我存心制造了一个错误)。
如果没有失败的情况下,想看到具体的测试内容,可以传递参数 —success(或 -s)。
25.3 参考资料
- Gennadiy Rozental and Raffi Enficiaud, Boost.Test. https://www.boost.org/doc/libs/release/libs/test/doc/html/index.html
- Two Blue Cubes Ltd., Catch2. https://github.com/catchorg/Catch2
- Wikipedia, “Behavior-driven development”. https://en.wikipedia.org/wiki/Behavior-driven_development
26 | Easylogging++和spdlog:两个好用的日志库
26.1 Easylogging++
事实上,我本来想只介绍 Easylogging++ 的。但在检查其 GitHub 页面时,我发现了一个问题:它在 2019 年基本没有更新,且目前上报的问题也没有人处理。
-
概述
Easylogging++ 一共只有两个文件,一个是头文件,一个是普通 C++ 源文件。事实上,它的一个较早版本只有一个文件。正如 Catch2 里一旦定义了 CATCH_CONFIG_MAIN 编译速度会大大减慢一样,把什么东西都放一起最终证明对编译速度还是相当不利的,因此,有人提交了一个补丁,把代码拆成了两个文件。使用 Easylogging++ 也只需要这两个文件——除此之外,就只有对标准和系统头文件的依赖了。
要使用 Easylogging++,推荐直接把这两个文件放到你的项目里。Easylogging++ 有很多的配置项会影响编译结果,我们先大致查看一下常用的可配置项:
- ELPP_UNICODE:启用 Unicode 支持,为在 Windows 上输出混合语言所必需
- ELPP_THREAD_SAFE:启用多线程支持
- ELPP_DISABLE_LOGS:全局禁用日志输出
- ELPP_DEFAULT_LOG_FILE:定义缺省日志文件名称
- ELPP_NO_DEFAULT_LOG_FILE:不使用缺省的日志输出文件
- ELPP_UTC_DATETIME:在日志里使用协调世界时而非本地时间
- ELPP_FEATURE_PERFORMANCE_TRACKING:开启性能跟踪功能
- ELPP_FEATURE_CRASH_LOG:启用 GCC 专有的崩溃日志功能
- ELPP_SYSLOG:允许使用系统日志(Unix 世界的 syslog)来记录日志
- ELPP_STL_LOGGING:允许在日志里输出常用的标准容器对象(std::vector 等)
- ELPP_QT_LOGGING:允许在日志里输出 Qt 的核心对象(QVector 等)
- ELPP_BOOST_LOGGING:允许在日志里输出某些 Boost 的容器(boost::container::vector 等)
- ELPP_WXWIDGETS_LOGGING:允许在日志里输出某些 wxWidgets 的模板对象(wxVector 等)
-
开始使用 Easylogging++
一个简单的例子:
1
2
3
4
5
6
INITIALIZE_EASYLOGGINGPP
int main()
{
LOG(INFO) << "My first info log";
}结果输出到终端和 myeasylog.log 文件里:
2020-01-25 20:47:50,990 INFO [default] My first info log
-
使用 Unicode
就我们日志输出而言,启用 Unicode 支持的好处是:
-
可以使用宽字符来输出
-
日志文件的格式是 UTF-8,而不是传统的字符集,只能支持一种文字
要启用 Unicode 支持,你需要定义宏 ELPP_UNICODE,并确保程序中有对 std::locale::global 或 setlocale 的调用(如 [第 11 讲] 中所述,只有进行了正确的区域设置,才能输出含非 ASCII 字符的宽字符串)。下面的程序给出了一个简单的示例:
1
2
3
4
5
6
7
8
INITIALIZE_EASYLOGGINGPP
int main()
{
setlocale(LC_ALL, "");
LOG(INFO) << L"测试 test";
}
-
-
改变输出文件名
Easylogging++ 的缺省输出日志名为 myeasylog.log,可以直接在命令行上使用宏定义来修改。只需要在命令行上加入下面的选项就可以:
1
-DELPP_DEFAULT_LOG_FILE=\"test.log\"
-
使用配置文件设置日志选项
Easylogging++ 库自己支持配置文件。我自己使用的配置文件是这个样子的:
1
2
3
4
5
6
7
8
9
10
11
12
13* GLOBAL:
FORMAT = "%datetime{%Y-%M-%d %H:%m:%s.%g} %levshort %msg"
FILENAME = "test.log"
ENABLED = true
TO_FILE = true ## 输出到文件
TO_STANDARD_OUTPUT = true ## 输出到标准输出
SUBSECOND_PRECISION = 6 ## 秒后面保留 6 位
MAX_LOG_FILE_SIZE = 2097152 ## 最大日志文件大小设为 2MB
LOG_FLUSH_THRESHOLD = 10 ## 写 10 条日志刷新一次缓存
* DEBUG:
FORMAT = "%datetime{%Y-%M-%d %H:%m:%s.%g} %levshort [%fbase:%line] %msg"
TO_FILE = true
TO_STANDARD_OUTPUT = false ## 调试日志不输出到标准输出假设这个配置文件的名字是 log.conf,我们在代码中可以这样使用:
1
2
3
4
5
6
7
8
9
INITIALIZE_EASYLOGGINGPP
int main()
{
el::Configurations conf { "log.conf" };
el::Loggers::reconfigureAllLoggers(conf);
LOG(DEBUG) << "A debug message";
LOG(INFO) << "An info message";
}注意编译命令行上应当加上 -DELPP_NO_DEFAULT_LOG_FILE,否则 Easylogging++ 仍然会生成缺省的日志文件。
此外,我也推荐在编译时定义宏 ELPP_DEBUG_ASSERT_FAILURE,这样能在找不到配置文件时直接终止程序,而不是继续往下执行、在终端上以缺省的方式输出日志了。
-
性能跟踪
Easylogging++ 可以用来在日志中记录程序执行的性能数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
INITIALIZE_EASYLOGGINGPP
void foo()
{
TIMED_FUNC(timer);
LOG(WARNING) << "A warning message";
}
void bar()
{
using namespace std::literals;
TIMED_SCOPE(timer1, "void bar()");
foo();
foo();
TIMED_BLOCK(timer2, "a block")
{
foo();
std::this_thread::sleep_for(100us);
}
}
int main()
{
el::Configurations conf { "log.conf" };
el::Loggers::reconfigureAllLoggers(conf);
bar();
}简单说明一下:
-
TIMED_FUNC 接受一个参数,是用于性能跟踪的对象的名字。它能自动产生函数的名称。示例中的 TIMED_FUNC 和 TIMED_SCOPE 的作用是完全相同的。
-
TIMED_SCOPE 接受两个参数,分别是用于性能跟踪的对象的名字,以及用于记录的名字。如果你不喜欢 TIMED_FUNC 生成的函数名字,可以用 TIMED_SCOPE 来代替。
-
TIMED_BLOCK 用于对下面的代码块进行性能跟踪,参数形式和 TIMED_SCOPE 相同。
在编译含有上面三个宏的代码时,需要定义宏 ELPP_FEATURE_PERFORMANCE_TRACKING。你一般也应该定义 ELPP_PERFORMANCE_MICROSECONDS,来获取微秒级的精度。下面是定义了上面两个宏编译的程序的某次执行的结果:
1
2
3
4
5
6
7
82020-01-26 15:00:11.99736 W A warning message
2020-01-26 15:00:11.99748 I Executed [void foo()] in [110 us]
2020-01-26 15:00:11.99749 W A warning message
2020-01-26 15:00:11.99750 I Executed [void foo()] in [5 us]
2020-01-26 15:00:11.99750 W A warning message
2020-01-26 15:00:11.99751 I Executed [void foo()] in [4 us]
2020-01-26 15:00:11.99774 I Executed [a block] in [232 us]
2020-01-26 15:00:11.99776 I Executed [void bar()] in [398 us]不过需要注意,由于 Easylogging++ 本身有一定开销,且开销有一定的不确定性,这种方式只适合颗粒度要求比较粗的性能跟踪。
性能跟踪产生的日志级别固定为 Info。性能跟踪本身可以在配置文件里的 GLOBAL 节下用 PERFORMANCE_TRACKING = false 来关闭。当然,关闭所有 Info 级别的输出也能达到关闭性能跟踪的效果。
-
-
记录崩溃日志
在 GCC 和 Clang 下,通过定义宏 ELPP_FEATURE_CRASH_LOG 我们可以启用崩溃日志。此时,当程序崩溃时,Easylogging++ 会自动在日志中记录程序的调用栈信息。通过记录下的信息,再利用 addr2line 这样的工具,我们就能知道是程序的哪一行引发了崩溃。下面的代码可以演示这一行为:
1
2
3
4
5
6
7
8
9
10
11
12
13
INITIALIZE_EASYLOGGINGPP
void boom()
{
char* ptr = nullptr;
*ptr = '\0';
}
int main()
{
el::Configurations conf { "log.conf" };
el::Loggers::reconfigureAllLoggers(conf);
boom();
}使用 macOS 的需要特别注意一下:由于缺省方式产生的可执行文件是位置独立的,系统每次加载程序会在不同的地址,导致无法通过地址定位到程序行。在编译命令行尾部加上
-Wl,-no_pie
可以解决这个问题。
26.2 spdlog
跟 Easylogging++ 比起来,spdlog 要新得多了:前者是 2012 年开始的项目,而后者是 2014 年开始的。我在 2016 年末开始在项目中使用 Easylogging++ 时,Easylogging++的版本是 9.85 左右,而 spdlog 大概是 0.11,成熟度和热度都不那么高。
功能点:
- 非常快(性能是其主要目标)
- 只需要头文件即可使用
- 没有其他依赖
- 跨平台
- 有单线程和多线程的日志记录器
- 日志文件旋转切换
- 每日日志文件
- 终端日志输出
- 可选异步日志
- 多个日志级别
- 通过用户自定义式样来定制输出格式
-
开始使用 spdlog
1
2
3
4
5
int main()
{
spdlog::info("My first info log");
}代码里看不到的是,输出结果中的”info”字样是彩色的,方便快速识别日志的级别。这个功能在 Windows、Linux 和 macOS 上都能正常工作,对用户还是相当友好的。不过,和 Easylogging++ 缺省就会输出到文件中不同,spdlog 缺省只是输出到终端而已。
spdlog 不是使用 IO 流风格的输出了。它采用跟 Python 里的 str.format 一样的方式,使用大括号——可选使用序号和格式化要求——来对参数进行格式化。下面是一个很简单的例子:
1
2spdlog::warn("Message with arg {}", 42);
spdlog::error("{0:d}, {0:x}, {0:o}, {0:b}", 42);输出会像下面这样:
1
2[2020-01-26 17:20:08.355] [warning] Message with arg 42
[2020-01-26 17:20:08.355] [error] 42, 2a, 52, 101010事实上,这就是 C++20 的 format 的风格了——spdlog 就是使用了一个 format 的库实现 fmt [3]。
-
设置输出文件
1
2
3
4
5
6
7
8
int main()
{
auto file_logger = spdlog::basic_logger_mt("basic_logger", "test.log");
spdlog::set_default_logger(file_logger);
spdlog::info("Into file: {1} {0}", "world", "hello");
}执行之后,终端上没有任何输出,但 test.log 文件里就会增加如下的内容:
[2020-01-26 17:47:37.864] [basic_logger] [info] Into file: hello world
如果同时输出到终端和文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using namespace std;
using namespace spdlog::sinks;
void set_multi_sink()
{
auto console_sink = make_shared<stdout_color_sink_mt>();
console_sink->set_level(spdlog::level::warn);
console_sink->set_pattern("%H:%M:%S.%e %^%L%$ %v");
auto file_sink = make_shared<basic_file_sink_mt>("test.log");
file_sink->set_level(spdlog::level::trace);
file_sink->set_pattern("%Y-%m-%d %H:%M:%S.%f %L %v");
auto logger = shared_ptr<spdlog::logger>(
new spdlog::logger("multi_sink", { console_sink, file_sink }));
logger->set_level(spdlog::level::debug);
spdlog::set_default_logger(logger);
}
int main()
{
set_multi_sink();
spdlog::warn("this should appear in both console and file");
spdlog::info("this message should not appear in the console, only in the file");
}大致说明一下:
- console_sink 是一个指向 stdout_color_sink_mt 的智能指针,我们设定让它只显示警告级别及以上的日志信息,并把输出式样调整成带毫秒的时间、有颜色的短级别以及信息本身。
- file_sink 是一个指向 basic_file_sink_mt 的智能指针,我们设定让它显示跟踪级别及以上(也就是所有级别了)的日志信息,并把输出式样调整成带微秒的日期时间、短级别以及信息本身。
- 然后我们创建了日志记录器,让它具有上面的两个日志槽。注意这儿的两个细节:
- 这儿的接口普遍使用 shared_ptr;
- 由于 make_shared 在处理 initializer_list 上的缺陷,对 spdlog::logger 的构造只能直接调用 shared_ptr 的构造函数,而不能使用 make_shared,否则编译会出错。
- 最后我们调用了 spdlog::set_default_logger 把缺省的日志记录器设置成刚创建的对象。这样,之后的日志缺省就会记录到这个新的日志记录器了(我们当然也可以手工调用这个日志记录器的 critical、error、warn 等日志记录方法)。
-
日志文件切换
在 Easylogging++ 里实现日志文件切换是需要写代码的,而且完善的多文件切换代码需要写上几十行代码才能实现。这项工作在 spdlog 则是超级简单的,因为 spdlog 直接提供了一个实现该功能的日志槽。把上面的例子改造成带日志文件切换我们只需要修改两处:
1
2
3
4
5
6
// 替换 basic_file_sink.h
auto file_sink = make_shared<rotating_file_sink_mt>("test.log", 1048576 * 5, 3);
// 替换 basic_file_sink_mt,文件大
// 小为 5MB,一共保留 3 个日志文件 -
适配用户定义的流输出
虽然 spdlog 缺省不支持容器的输出,但是,它是可以和用户提供的流 << 运算符协同工作的。如果我们要输出普通容器的话,我们只需要在代码开头加入:
1
2前一行包含了我们用于容器输出的代码,后一行包含了 spdlog 使用 ostream 来输出对象的能力。注意此处包含的顺序是重要的:spdlog 必须能看到用户的 << 的定义。在有了这两行之后,我们就可以像下面这样写代码了:
1
2vector<int> v;
spdlog::info("Content of vector: {}", v); -
只用头文件吗?
使用 spdlog 可以使用只用头文件的方式,也可以使用预编译的方式。
-
其他
下面这些功能点值得提一下:
- 可以使用多个不同的日志记录器,用于不同的模块或功能。
- 可以使用异步日志,减少记日志时阻塞的可能性。
- 通过 spdlog::to_hex 可以方便地在日志里输出二进制信息。
- 可用的日志槽还有 syslog、systemd、Android、Windows 调试输出等;扩展新的日志槽较为容易。
26.3 参考资料
- Amrayn Web Services, easyloggingpp. https://github.com/amrayn/easyloggingpp
- Gabi Melman, spdlog. https://github.com/gabime/spdlog
- Victor Zverovich, fmt. https://github.com/fmtlib/fmt
- Andrey Semashev, Boost.Log v2. https://www.boost.org/doc/libs/release/libs/log/doc/html/index.html
- Kjell Hedström, g3log. https://github.com/KjellKod/g3log
- Stanford University, NanoLog. https://github.com/PlatformLab/NanoLog
27 | C++ REST SDK:使用现代C++开发网络应用
C++ REST SDK(也写作 cpprestsdk)[1],一个支持 HTTP 协议 [2]、主要用于 RESTful [3] 接口开发的 C++ 库。