吴咏炜:现代C++大局观
本文摘录自Boolan首席咨询师、著名C++专家吴咏炜老师主讲分享的《现代C++大局观》直播讲座。

C++的远景
成功语言的烦恼
C++是一门很成功的语言但也有很多问题。我们会说C++太复杂了,要简化;但同时需要新功能,希望C++能够演进;还有不管怎么变,不能把语言搞砸了。
这三点实际上是不可兼得。
Bjarne洋葱原则可以部分解决这个问题即简单的事情简单做。抽象层次是分层的,如果并不需要最极致的性能,可以采用比较简单的做法。但是如果你是个专家,可以层层深入,但是越往里难度越大,切的越深,哭的越多。
现代C++的功能和惯用法
01 RAII——自动、安全的资源管理
C++最重要的特性是 } 。原因在于到了 } 的时候所有本地变量都会被析构。析构函数和RAII是C++最基本惯用法。

假想一下string的实现。在默认构造时可能会对于指针、长度、容量清零;然后传进去一个string,我们就要把这个string复制一份,把长度、容量存起来。重要的是在析构的时候用free把内存空间释放掉。
用RAII惯用法可以管理的不仅仅是内存,也可以用来管理“锁”。

对于这样一个锁,用lock_guard锁住之后,可以做很多同步的工作。然后到了 } 直接停止,自动把锁释放掉。而不使用RAII的情况下,需要手工lock、unlock,中间有异常或者提前返回就会出问题。
RAII是个递归的操作。如果定义了一个数的节点,这个节点上有string,有string的value,有string的name,还有子节点的智能指针。节点销毁的时候,会释放所有子节点的引用;如果是唯一的对子节点的引用或者是最后的对子节点的引用,所有的子节点也都会被释放。所以从管理的角度来讲非常非常的方便。
RAII带给我们的就是自动、安全的资源管理。
02 移动语义——高效的大对象传递
引用语义和值语义
首先讲一下值语义的优缺点
优点:
行为简单,符合直接
不容易发生竞争
数据嵌套时内存相邻,性能高
对象本身可以分配在栈上(栈上内存分分配释放开销极低)
缺点:
容易不小心发生内存复制
临时对象的情况下,函数如果返回一个对象,它是临时对象,在执行完当前语句就会被销毁。如果要获得这个临时对象的资源,可以通过移动的方式。我们需要一个能够区别临时对象和其他类型对象的机制。这就是C++里右值引用和移动语义引用的原因了。
右值引用的基本规则

我们产生了右值引用本身时没有发生移动,实际移动操作需要有一个匹配的函数来完成,比如说下面这个string对象。

核心要点在于有一个匹配右值引用的移动构造。在这个移动构造里面,会把右边右值里面的数据全部拿过来,然后把原始数据清空,确保析构的时候不会把已经移动的内存释放掉。
标准库已经全面支持了移动语义,带来一个小小的副作用就是像这样简单的临时字符串拼接操作。

如果从C++98的年代来看,老程序员会说这样是错误的,低效的,不可以接受的。但是到C++11这个代码就是正常的,没有额外开销的。
移动语义带给我们的是高效的大对象传递。
03 对象返回和异常——简洁可读的代码和完备的错误处理
这样的一个矩阵乘法:R=A x B+C,然后把这个结果赋给一个变量。
传统C风格的代码会用错误码,永远会返回一个错误,代码非常的啰嗦,有很多检查,不检查或者漏检查就会出问题。
Armadillo库是现代C++的风格,有很多现代C++的技巧,也可以把乘法矩阵实现。

返回对象的优势是非常明显的。
· 代码直观,容易理解
· 乘法和加法可以组合在一行里写出来,无需中间变量
· 性能高,没有不需要的复制发生(依赖移动语义和返回值优化)
当然这里面也有很多的细节需要注意。
· 内存分配失败将导致matrix构造失败
· 矩阵大小不匹配将导致矩阵运算失败
· 任何本地matrix变量分配的内存,将在变量离开作用域时被释放
· 异常安全可以不需要任何显式的try...catch
异常当然也是有一些问题的。
· 只要启用异常,代码就会有膨胀(约5-15%)
· 异常路径上的性能损失较大,比错误码大很多
· 异常可能导致错误发生点和错误处理变得不明显(泛型的要求,模板代码通常无法预知什么情况下会有异常)
很多地方是不使用异常的,有很多原因有可能是历史的原因,有可能是对未知的恐惧等等,如果你所在的项目不使用异常的情况下,你需要问一下到底是为什么。
不使用异常有哪些的后果

对象返回和异常带给我们的就是简洁可读的代码和完备的错误处理。
04 C++的易用性改进——更简单的代码,即使语言在变复杂
统一初始化
在C++98和C仅用 { } 来初始化数组和结构体。到了C++11开始就可以统一使用 { } 来对变量进行初始化。

用这种语法可以解决一个问题,叫最令人恼火的语法分析。是说一些情况下,写出的语法有可能让编译器认为这是一个函数声明,例如下图:

解决这个问题可以用 { } 统一初始化规避:
ifstream ifs{utf8_to_wstring{filename}};
这样编译器就会认为这是一个变量声明而不是一个函数声明。
总的来讲,我们是推荐使用统一初始化,但如果一个类使用初始化列表的构造函数时,则只应在利用初始化列表构造时使用。
类数据成员的默认初始化

这里的语法在声明成员变量的时候在结尾加上 { } 初始化代表对这些变量清零,配对使用的语法就是一个默认声明的默认构造函数,声明point的时候默认里面清零。
对于string可以写成这样的语法

同时也可以进一步写成这样

利用用户定义字面量可以写出非常直观的代码。

这些都是非常方便使用的,同时这个语法我们自己也可以用来定义字面量,唯一的限制是非标准的用户定义字面量必须以下划线打头,如下图:

这些易用性改进带给我们更简单的代码,即使语言在变复杂。
05 模板——安全,高性能的代码复用
模板我们讨论最常用的示例sort。sort一般来讲最后一个参数可以是函数对象、可以是lamda表达式,也可以是一个函数指针。不管怎么样,代表的意思都是怎么进行比较。

这两种写法是等价的,都是比较两个整数是否满足小于关系。
在C++里面用sort打开O2优化情况下,sort比qsort快了好几倍。

模板有好处也有坏处,好处是零开销抽象,高性能;类型安全的代码复用。但是出错信息不友好,动辄可达数百行。
怎么对模板参数进行约束

传统上我们可以用SFINAE检测编译器成员,C++17里面有些不错的改进,C++20也进一步简化。

可以定义一个概念叫reservable;然后定义两个存在,reservable C和typename C,C++的编译器会自动帮你选择你该用哪一个。
模板带给我们的就是安全,高性能的代码复用。
06 编译期编程——灵活性和新的可能,无额外运行期开销
接下来我们来讨论一个烧脑的话题,编译期编程。这是用编译期编程来写阶乘,产生的汇编代码是这样的。


然后我们可以写出编译期的条件语句,用一个布尔值用作模板参数来代表条件,然后用类型作为when 和else的分支。可以产生这样的结果。

C++从C++11开始就有constexpr,也在不断演化。目的就是明确标明编译期的常量和编译期计算函数。用传统的C++式的语法来写编译期计算代码,同样的阶乘,可以写成这样一个函数。

这个函数factorial可以执行计算,采用了递归式的调用但是用循环写也是完全可以的。可以直接声明算出的结果是编译期的常量,然后就可以输出和前面一样的编译结果。
然后还有if constexpr可以简化前面的写法,可以把代码并成一个分支。如果你的C容器满足reserve条件的,编译的时候就会产生对reserve的调用。
编译期编程带给我们的就是灵活性和新的可能,无额外运行期开销。
展望C++20
C++是一种成熟的古老的语言,这个语言本身虽然不完美,但是在变得越来越好。C++会越来越靠近完全的类型和资源安全。既能对新手更友好,还能对专家保留强大的定制能力,达到极致的性能。


12月9日晚20:00
《现代C++白皮书》线上发布会
等你来参与
