欢迎光临散文网 会员登陆 & 注册

吴咏炜:现代C++大局观

2021-12-07 17:12 作者:Boolan博览  | 我要投稿

本文摘录自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++白皮书》线上发布会

等你来参与


吴咏炜:现代C++大局观的评论 (共 条)

分享到微博请遵守国家法律