C++ 可算是一种声名在外的编程语言了。这个名声有好有坏。从好的方面讲,C++ 性能非常好,哪个编程语言性能好的话总忍不住要跟 C++ 来单挑一下。从坏的方面讲,它是臭名昭著的复杂、难学、难用。
不管说 C++ 是好还是坏,不可否认的是,C++ 仍然是一门非常流行且非常具有活力的语言。继沉寂了十多年后发布语言标准的第二版——C++11——之后,C++ 以每三年一版的频度发布着新的语言标准,每一版都在基本保留向后兼容性的同时提供着改进和新功能。
虽然在语言领域,也有Rust这样的新语言在向 C++ 发起挑战,但是,不可否认的是,C++ 仍然是面向性能的领域里的编程语言王者。我甚至不认为 C++ 在性能方面次于 C——在极致追求速度时,C++ 可以比 C 更强,而 C 相比 C++ 的主要优点是更加简单:不管是学习、使用,还是产生的二进制代码的体积上。
今天,我们就来大略讨论一下,C++ 是如何做到高性能的。
Bjarne 老爷子认为 C++ 最主要的特点在于以下两方面的关注:
● 语言构件到硬件设施的直接映射
● 零开销抽象
跟 C 语言一样,C++ 提供非常底层的数据操作能力,为开发者提供了灵活性。跟“高级”语言一样,C++ 提供了强大的抽象能力(可以说超越了大部分语言)。而且,相比 C,C++ 要安全得多。在语言诞生的初期就是如此,现在就更不用说了。
C++ 的类型系统比 C 更加严格,因此虽然一直有 C++ 是 C 的超集的说法,这个说法严格来说从来就没成立过。最近(2023 年)碰到过一个程序崩溃的案例,简化来讲,就是开发者使用了一个 char 的二维数组(char names[MAX_NAMES] [MAX_NAME_LEN]),然后把它传给了一个接收 char** 参数的函数……这代码当然是错的,但 C 编译器虽然给了个告警,但编译还是没有失败。如果这是 C++ 代码的话,那编译器就会直接报告错误,不给通过了。
而第二点,零开销抽象,对于 C++ 的性能至关重要。我们有很多的抽象机制,同时,使用这些抽象机制并不会带来额外的开销。在某些情况下,使用这些机制,反而有“负开销”—— “使用者”可以非常安全地使用这门语言,即可获得极高的性能。同时,C++ 还给予 了“定制者”根据自己的需求来写出更贴近使用场景的库的能力,可以进一步方便“使用者”。
当然,定制对程序员的技能有非常高的要求。初学 C++ 的更需要掌握 C++ 的标准库的使用——用好标准库,就能获得非常不错的性能。正如高德纳大神的名言的完整版:
我们应当忘记小的效率问题,比如说在97% 的时间里:过早优化是万恶之源。
就在同一篇论文的同一页上,高德纳还写下了:
在成熟的学科里,对于12% 的提升,如果易于获取的话,那绝不会被认为是微不足道;我相信,在软件工程里,相同的观点也会占上风。
而 C++ 已经提供相当多的机制,可以允许我们很容易地获取高性能,在很多场景下远远超过高德纳所说的 12%。
我经常举的一个例子是 C++ 标准库的sort和 C 标准库的qsort:在关闭优化时,我在某一测试场景下得到了 1:2.5 的性能差异,C++ 似乎要慢不少;但一旦打开 -O2(允许内联)时,两者的性能差异突变成 3.5:1,C++ 的性能比 C 高出了好几倍!这就是所谓的“负开销”了。C++ 的代码比 C 的更简单、更直观,性能还更高。原因自然就是 C++ 的函数对象和模板机制允许编译器更好地进行内联,从而产生更加高性能的代码。
因此,学会用好 C++ 的第一步是用好 C++ 的基本机制和标准库,了解标准库的不同机制的性能开销,包括时间和空间。
任何情况下学习 C++,第一需要了解的就是析构函数和 RAII(resource acquisition is initialization)惯用法。对,虽然 C++ 诞生时名字是“带类的 C”,但类和面向对象并不等同,对面向对象编程的支持并不是 C++ 的最重要特性。C++ 的自定义类型的最特别之处不在多态,而在对其行为的定制上——最重要的就是对象销毁时应该做些什么。析构函数和析构函数带来的 RAII 惯用法,是 C++ 里最重要的特性,也是用 C++ 进行资源管理的关键。
重载是另外一个非常重要的 C++ 特性。除了你不用在名字上区分 process_char、process_string、process_int 带来的方便性外,它对泛型编程也很重要,还对现代 C++ 的一个基本特性“移动语义”非常重要。刨除语法上的细节,本质上来说,移动语义就是让程序员可以方便地区分会继续使用的对象和以后不再使用的对象,允许对后者使用构造函数和赋值运算符的重载来“窃取”其中的资源。对于一个普通的 vector,拷贝的开销是 O(n) 或更高(如果 vector 成员是容器或其他具有高拷贝开销的对象),但移动开销通常(是,只是通常;不过通常你也不会遇到这种例外的特殊情况)是 O(1),常数复杂度。这就是我们在 C++ 里高效传递对象的一种常见方式了。
C++ 标准库里最常用的组件恐怕就是 string 和各种容器了。它们都对移动进行了优化。当然,除了这个基本的性能点外,容器都有各自的特殊性能点,比如不同情况下的插入性能差异。这些都是需要学习的地方。
比如,vector 在尾部插入性能比较好,在中间插入性能比较差。不过,更进一步的是,你需要知道,尾部插入性能好的前提条件是元素的类型对移动有很好的实现,并且移动构造函数声明成了 noexcept!如果你实现了开销为 O(1) 的移动构造函数,但忘了把它声明为 noexcept,那仍然是白搭,vector 的尾部插入仍然有性能问题。
又如,list 不管从开头、结尾还是中间插入,都具有很高的性能。但是,对于相同元素的 list 和 vector,list 的遍历性能可能要差一个数量级。这个原因就不完全是 C++ 的知识点了,而是跟硬件的缓存组织相关。如果我们关心性能的话,这些都是需要了解的地方。
前面我们已经提到过模板,而 string 和容器也都是模板,行为可以通过模板参数来进行定制,并允许高效的内联优化。模板当然是 C++ 里比较复杂的一个地方,但基本的使用则相当简单:vector 就是一个放 int 的 vector,用起来跟一个普通的类没有区别——只是模板创建者的工作简单多了,不需要手工为不同的类型创建不同的类。
用好 C++、在项目中获得令人满意的性能 当然不止上面这一些。最基本的,我们还需要了解标准库算法,并合适地使用并发和并行来充分利用硬件。在本文中我们暂且就不展开了。
当我们用熟了 C++ 之后,慢慢地,我们就会不再满足于 C++ 标准库这一“制式武器”。我们会寻找适合自己的第三方库,甚至自己造轮子来满足项目的特定需求。此时,我们就需要进一步了解 C++ 的高级特性。我们需要了解模板的进一步细节,尤其是特化。我们需要了解 SFINAE 和模板元编程。我们需要了解 constexpr 和它带来更方便的编译期编程。C++ 的使用者也许可以暂时不关心这些问题,但定制者,或者说项目里的框架搭建者和工具提供者,必须去了解 C++ 的这些高级特性,为你的项目提供扎实的基础。
举个例子,C++ 的标准库提供了 list,双向链表。这个库没啥问题,但在某些使用场景下,它的时间和空间开销都不令人满意,比如我们的对象除了正常的管理,还需要一个额外的 LRU(least recently used)算法来抛弃其中最老的项。你当然可以使用 list,但每次插入操作都需要插入一个对象,除了有堆内存分配开销,你还需要考虑在这个 list 里到底存什么。也许用智能指针?情况是不是越搞越复杂了?
这种情况下,最合理的选择是使用某种 intrusive_list,侵入式的链表,不需要在每次插入或删除时进行内存管理。C++ 标准库没有提供这个功能。你可以使用 Boost 里提供的容器,或者自己写一个新的。对于这个例子,Boost 多半就足够好了。但总可能出现一些现成库解决不了的问题的,这时候,利用 C++ 的高级特性来自己造轮子就是一件非常自然的事。我们可以做到既有合适的定制,同时用法又跟已有的容器相似,没有额外的学习成本。
或者,也许你希望使用分配器来创建一个容器内存池,来提供对内存的使用效率。这在 C++ 里也是非常容易完成的,只要你了解合适的定制机制。根据洋葱原则,你可以不管这些定制点,直接用 C++,这样最简单;也可以把标准库“切开”,以自己最喜欢的方式来拼接定制使用——当然,这种做法确实跟切洋葱一样,很容易就会哭鼻子的。但它确实能帮助你获得最高的可能性能
当然,关于性能,该讨论的还远远不止上面这些。有些话题跟 C++ 有关系,有些则跟硬件相关,有些是通用的软件问题,跟语言无关。目前我们只是管中窥豹,浅浅地进行了一些初步的探讨。如果有兴趣的话,欢迎来参加我的 C++ 性能优化培训课程,对这些问题进行更加深入的探讨。
C++,作为一门多范式的通用编程语言,适用的领域非常广泛。要对C++程序进行性能优化,牵涉到的方方面面也非常多。本课程就是以现代C+ +程序为中心,讨论如何对C++程序进行优化。课程中有跟语言强相关的内容,也有跟语言关系较少、但在实践中经常伴随C+ +程序出现的问题
吴咏炜老师的《C++性能优化高端培训》课程是 Boolan技术赋能培训的品牌课程,在华为、博世西门子、银科、大疆等很多著名企业内训都获得高度认可,得到参训学员一致好评