如何让 C++ 二进制文件小巧可爱¶
标题:How to Keep C++ Binaries Small
日期:2024/10/15
作者:Sandor Dargo
链接:https://www.youtube.com/watch?v=7QNtiH5wTAs
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
感谢大家的到来。在接下来的 60 加 90 分钟里,我们将讨论二进制大小的问题。总共是两个半小时,时间很充裕。所以,如果你有任何问题或意见,因为你可能对某些部分更了解,或者有一些自己的想法,随时可以打断我。如果你愿意,这次分享可以非常有互动性。
好的,让我自我介绍一下。嗯哼。我又遇到这个问题了。好吧,没关系,我用空格键。我叫 Sandor Dargo,是 Spotify 的一名高级工程师。 所以我不是在嵌入式领域工作,但我们确实有一些嵌入式相关的业务,只是我不在那个部门。
嗯,我喜欢写作,可能比演讲更喜欢。我写的东西不限于 C++,但主要关于 C++,有时也会写一些关于我读过的斯多葛哲学书籍。我热爱旅行,也热爱一切与美食相关的事物。 举个例子,我喜欢烘焙酸面包,并且去年刚在我居住的法国通过了几个品酒师考试。我不是为了这个才搬到法国的。我有两个活泼可爱的孩子,非常感谢我的妻子照顾他们,这样我才能和大家在这里相聚。
关于我的介绍就到这里。下一个问题是,在2024年,当存储设备越来越便宜、越来越普及的时候,我们为什么还要关心二进制文件的大小呢?为什么? 我认为有三个原因。第一,有些设备的存储空间有限,那些在嵌入式领域工作的人,这主要是你们关心二进制大小的原因。
但这不是我们在 Spotify 关心二进制大小的原因。我们关心是因为有些设备的带宽有限。对于在座的大多数人来说,我们可能都有某种形式的无限流量套餐。我想我的套餐有80GB,我把它看作是无限的,因为我连一半都用不完。但是在其他国家,人们可能会有不同的带宽限制。 我们了解到,我可能不应该透露具体数字,但是你从应用程序、从可下载安装的 APK 中每削减一兆字节(MB),实际上会显著影响到安装和使用该应用的人数。这就是为什么二进制大小对我们很重要。
还有另一个方面。我不太了解具体细节,但我知道我们公司的一些研究人员发现,二进制文件的大小实际上对环境有影响,因为它越小,二氧化碳排放量就越少。所以,这或许也是一个值得考虑的因素。
我个人也有一些动机,想更多地了解二进制大小,这也是我准备这次演讲、写了很多文章并出版了一本小型电子书的原因。我在这里讲的所有内容,甚至更多细节,都可以在这本电子书中找到。别担心,我不是在这里向你们推销东西。 我会在 Discord 频道里发布一个链接,你们可以从那里免费下载。你们已经付费来参加会议了,没有理由再花钱。
我想更好地理解 C++ 的二进制文件。我想识别出一些导致二进制膨胀的模式。并且,我想理解现代 C++ 实践对二进制大小的影响。这些是我学习更多关于二进制文件的个人动机。
那么,我们来看看议程。我们将学习一些关于二进制文件的知识。这部分我认为将是这150分钟里最无聊的部分,但如果我们想优化二进制大小,多了解一点二进制文件可能是值得的,尽管并非百分之百必要。 接着,我们会看到以各种方式影响二进制大小的编码实践。我认为对大多数开发者来说,这是最有趣的部分。这也是我们在 Spotify 负责二进制大小的团队里喜欢花时间的地方,但它并不是影响最大的部分。 我记得当时我们说,“哦,好的,我们在这个不错的 PR(Pull Request)上工作,从库里移除了80KB。”而那个库的大小在20到100MB之间,取决于编译器以及是 release 版还是 debug 版。这让我们非常高兴。但是,当我们改变一些编译器和链接器设置时,你知道,你只需要加一个标志,就能为你赢得数兆字节的空间。所以那部分可能影响更大,但趣味性稍差。
然后我们会看到编码实践和编译器标志的交叉点。你们能猜到会是什么吗?我说的编译器标志和编码实践的交叉点是指什么?是的,完全正确。你找到了那两个:RTTI 和异常。正是这些,它们能带来巨大的变化。
二进制文件基础
让我们来谈谈二进制文件。我又试了一下这个(指遥控器),嗯,还是不行。所以我们从这里开始,先看一个关于我们将要讨论内容的迷你议程。首先,二进制文件是如何生成的。然后,一个可执行文件的主要部分。接着是一些主要特性,通常,你知道,如今的二进制格式都具备所有好的特性,但以前并非如此。我们将更详细地了解一些不同的格式,以及如何检查二进制文件,如何深入探究它们。
好的,我们稍后再回到那个话题。是的,我担心这个可能不太容易阅读,但我保证这是唯一一张这样的幻灯片。一开始,你有一个源代码文件,比如说一个 CPP 文件,main.cpp
。第一步,你会把它传递给预处理器。预处理器会做所有必要的事情,比如,它会包含头文件,这只是一个文本替换。它会解析所有的宏。然后你会得到一个临时文件。 接着,这个临时文件会进入编译器,从编译器出来你会得到汇编文件。我想昨天的闪电演讲中,有人提到如果你用 -S
选项编译,你就会得到那个汇编文件。你可以看看它,这其实相当有趣。如果你想检查二进制文件并理解发生了什么。但为了削减几千字节或几兆字节,你不一定需要这样做。但如果你对更多细节感兴趣,这样做可能很有用。
然后它进入汇编器,你会得到一些目标代码(object code)。接着它进入链接器,链接器会取用这些目标代码和库文件。最后,你会得到一个相当大的可执行文件,或者可能是一个共享库。这取决于情况,这里我以可执行文件为例。
那么一个可执行文件有哪些部分呢?首先是头部(header)。然后是代码段(text section),通常包含代码,并且是只读的。你会有不同的数据段(data sections),取决于其中是否有已初始化或未初始化的数据。你还有只读数据段(read-only data section)。你有一个可能非常大的符号表(symbol table),你可以限制它的大小。你还有重定位信息(relocation information),它会包含当可执行文件加载到内存时如何移动某些部分、如何修改地址的细节。还有一些导入过程链接表(import procedure linkage tables),这在加载时也很重要,以及用于列出外部可用内容的导出表(export table)。还有异常处理信息(exception handling information),我们之后可以移除它,如果我们禁止异常,实际上可以某种程度上移除它。但这并不总是可能或可取的。最后是调试信息(debugging information),这显然更多地会是 debug 构建的一部分,而不是 release 构建。
那么一些主要的特性是什么呢?该格式是只支持一个平台还是多个平台?它如何处理符号解析和链接解析?它是否支持位置无关代码(position-independent code)?如今这些都支持,但在过去并非如此。甚至字节序也是一个特性。也就是说,在最低地址处,你存放的是最高有效字节还是最低有效字节。一个格式的架构可扩展性(architecture extensibility)也曾有所不同,他们设计二进制文件的方式也不同。但情况在变化。
那么,最重要的格式是什么?我们来看几个。有 a.out
。了解它是什么很重要。我想之后我们有了 COFF
。现在我们有 ELF
、PE
、Mach-O
。我们会稍微详细地讨论这些。有些我只会跳过一些幻灯片,但如果你们之后需要这些细节,可以去看看。
所以,a.out
。谁见过 a.out
?你什么时候会看到它?在 NSF 音频输出中。是的。而且我们现在仍然叫它 a.out
。另一方面,输出的其实并不是真正的 a.out
格式了,但出于历史原因,我们仍然这么称呼它。 它被称为“汇编器输出”(assembler output),是70年代的遗产。我说是遗产,因为我们仍然在许多简单的例子中使用它,比如我们会和 a.out
比较。它是一种格式,虽然仍在某些地方使用,但已不再广泛使用。它非常简单,有一个头部、一个代码段和一个数据段,以及一个符号表。基本上就是这样。它不支持例如共享库或位置无关代码。但它是第一个,也许不是第一个,但却是第一个为 C++ 广为人知并广泛使用的二进制格式。
之后我们有了通用对象文件格式(Common Object File Format),缩写为 COFF
。它是 a.out
的替代品。最初在 Unix 上使用,后来他们也将其用于 Windows。它开始支持更大的程序并包含调试信息,但它也已经被其他一些格式所取代。我们会看到,我们可以认为这是真正跨平台的,但现在使用的许多格式则不那么跨平台,或者说这取决于你如何定义跨平台。
让我们从 ELF
开始。这是可执行与可链接格式(Executable and Linkable Format)。它在类 Unix 系统上使用。我们认为它是跨平台的。它不只在一个系统上,而是在多个系统上都支持。我们接下来要谈论的 ELF
、PE
、Mach-O
,这些都支持共享库和位置无关代码。但正如你所见,以前并非如此。这就是为什么我觉得提一下很重要。它支持调试信息,并且它们都喜欢在文档中强调自己是为性能和可扩展性而设计的。
好的。ELF
有一个头部,根据你使用的系统是32位还是64位,它的大小相当小,可能在52或64字节左右。它有 e_ident
字段,这部分有点无聊,我知道。然后它存储文件类型、编码、版本,以及它是一个可执行文件、共享对象还是目标文件。它还存储程序执行的起始地址,以及不同程序头和节头(section headers)的偏移量。这些是你必须存储在头部的信息。
那么常见的节(sections)有哪些呢?有用于可执行代码的 .text
。有用于已初始化数据的 .data
。有用于未初始化数据的 .bss
。有用于只读数据的 .rodata
。有用于符号表的 .symtab
。有用于字符串表的 .strtab
。这基本上就是 ELF
中的内容。
我们可以更深入,但这并没有太大帮助。你还有 Mach-O
(Mach Object),它用于 macOS、iOS 以及任何与苹果相关的系统。在这里,节被分组到段(segments)中。同样,这种格式支持现代二进制格式所需的一切:共享库、位置无关代码和调试信息。他们也声称自己是为性能和可扩展性而设计的,就像所有这些格式一样。
它的头部也包含一些细节,比如架构、文件类型,以及加载命令(load commands)的大小和位置。加载命令在 Mach-O
中扮演着核心角色。Mach-O
中有用于不同段、符号、动态符号表等的不同加载命令。__TEXT
段对一些节进行分组。它是只读的,包含可执行代码和常量数据。它通常是 Mach-O
可执行文件中最大的段。 并且它可以在不同进程之间共享。所以,如果你有多个进程,这部分不必被多次加载,这很好。是的,它包含几个节,比如用于机器码的 __text
节,用于引用外部函数的 __stub_helper
节。你还有包含所有常量数据的 __const
节,甚至还有以 null 结尾的 C 风格字符串的 __cstring
节。
你还有 __DATA
段,这是一个读写区域。在实践中,这意味着每个进程都必须复制它,不像之前的 __TEXT
段。这里有很多节,我不会一一列出。最常见的有 __data
,包含具有静态存储期的已初始化变量;__bss
,包含未初始化的变量。你在这里还有未初始化的外部全局常量,以及需要在 __common
节中重定位的常量数据。这差不多就是 __DATA
段里的内容。
然后我们有可移植可执行文件格式(Portable Executable format),主要用于不同的 Windows 系统。据说它被设计得紧凑而高效。它支持之前提到的所有东西:共享库、位置无关代码,以及如今必不可少的调试信息。有趣的是,PE 头部有关于机器、目标架构的签名,还有很多数据,比如生成时间、不同节的数量,以及我留在这里的一些其他细节,以便如果你以后感兴趣可以查阅。我跳过这部分。它有一个可选头部,包含很多在大多数情况下你不需要的细节。
我们说过 Mach-O
有段,它们将节分组到段中。在用于 Windows 的 PE 格式中,没有段,你只有在节头中指定的节。你有资源节、重定位节、一些调试信息,以及你通常会有的所有其他必要部分,你知道的,.text
、.data
、.bss
,这些都是共通的。
至此,我们成功地完成了这一部分。你可以看到,有为不同平台、不同操作系统(类 Unix、苹果系列和 Windows)量身定制的不同格式。如今,关键特性和设计目标非常相似,但实现方式各不相同。
那么问题来了,我们正在进入更实际的部分:如何检查二进制文件?你们会检查二进制文件吗?你们想深入了解它们吗?你们用什么工具?
嗯,那是一个可以用的工具。是的。还有别的吗?是的,是的,我列出了那些以及另外几个。你有 size
,可以在类 Linux 操作系统上快速概览。它提供关于不同节的信息,所以你基本上会得到大小。 这是一个非常基础的工具,但如果你只是想看看某个小程序,想更好地理解什么东西去了哪里,也许值得一看。
然后你有 objdump
,就像你说的,用来看节的大小和更多一点信息。它是 GNU binutils 套件的一部分。你会得到文件格式、不同的节和大小。你可以看到这个输出更详细一些,更美观。 你有节、大小,甚至地址,还能看出它更像是代码段里的东西还是数据段里的东西。还不错。
你还有 nm
,这是同一套件中的另一个工具。它会列出你的符号,你会看到哪些是可用的。如果你查阅文档,你会看到开头那些不同字母的含义。这对于深入研究某些二进制文件很有帮助。
我最喜欢的是 Google 的 Bloaty
。它是开源的,一个由 Google 开发的命令行工具,我们可以免费使用。它能让你对二进制文件有更详细的洞察。但这还不是它最好的地方。我认为最好的地方在于,你可以捆绑不同的文件。比如,你有一个可执行文件,几个共享库,你可以把它们全部传进去,它会把所有的节分组汇总,让你看到整体情况,而不需要自己去计算。所以你看,组合不同的文件非常容易。在这个例子里没有展示,但基本上你只需要传入多个文件,它就会帮你完成所有的计算。你会得到文件大小,看到它相对于整个文件的比例。这非常实用,尤其当你不想只检查一个文件而是多个文件时。你可以从 GitHub 下载并自己安装。非常酷的东西。
减少二进制大小的编码方法
现在我们要进入我认为最有趣、最享受的部分:通过编码方式来减少二进制大小。但在开始之前,你们有什么问题或意见吗?没有?好的。
那么我们在这里要讨论什么呢?我们将讨论对象初始化、成员排序、特殊成员函数、虚函数、模板、函数传递,以及一点关于 constexpr
函数。这些是我们将要涵盖的主题。
重要的是要注意,并非所有能削减几个字节的方法都是最佳实践。有些是。但有些做法,比如,我甚至在一本书里读到过,那句话的措辞至少可以说是非常糟糕的:“无论如何,你都应该把析构函数声明为虚函数。”我当时就想,不,不,你不应该那么做。你不应该那么做有几个原因,其中之一就是二进制大小,因为如果你引入虚函数、虚析构函数,它会严重地膨胀你的二进制文件。如果你不需要它们,就不应该这么做。而实际上,在任何情况下这都不是最佳实践。但你会看到其他为了减少二进制大小的做法,它们本身并不是最佳实践,你也不应该去做。有些事情只是为了优化二进制大小而做的权衡。但你知道,这和所有事情都一样。如果你来参加一个会议,通常你会看到一个闪电演讲,他们解释——别误会我,那些是最好的闪电演讲之一——“我们如何将打印速度提高100倍”,而这只需要我写100行非常晦涩的代码来打印这个数字。你知道,那些是不错的运行时优化,但不是最佳实践,你不应该那样做。对于二进制大小也是如此。
首先,我们来谈谈对象初始化和二进制大小。让我们从一小段代码开始,但它编译后会相当大。如果你编译这个,你期望的二进制大小大概是多少?你可以用 -O3
或 -Os
编译。
是的,对于几乎什么都没做的代码。对于几乎什么都没做的代码,你会得到将近 100KB。就这么一小段代码。
这有几个原因。好吧,这里我们可以检查确切的大小,你看,差不多是 100KB。但为什么会这样呢?这就是我们在这里要讨论的。嗯,大部分二进制文件都在数据段,这就是为什么你计算出它至少应该是 80KB,因为你有 10000 个对象,每个对象如果我没记错的话包含两个整数,所以它应该大约是80KB(译者注:原文为8KB,应为笔误,10000 * 2 * 4字节 = 80000字节 = 80KB)。是的,你得到的就是这个。这就是我们要求的,我们得到了。Bloaty
告诉我们它有点大,但为什么这么大呢?相对来说很大。嗯,这是因为三件事:容器、存储期和初始值。实际上,我们需要所有这些因素同时存在,才能让二进制文件变得这么大。
我不是说你应该这么做,但也许堆分配会缩小你的二进制文件。它们会的。因为动态分配可能会让你的二进制文件更小,因为它不能在编译时完成。嗯,是的,这里不涉及我们昨天讨论的 constexpr vector
。它显然会减慢你的运行时速度。例如,如果我们将之前的例子改为使用 std::list
或 std::vector
,它们会进行堆分配,那么我们会看到二进制大小缩小。任何 C 风格的容器或 std::array
都可能很大。它们不一定会很大,但可能会很大。而使用 std::list
或 vector
,你就不可能出现这种情况。你可以在这里看到大小:使用 C 风格数组或 std::array
(它只是一个更安全的 C 风格数组的华丽包装),二进制大小是一样的。而如果你使用 vector
,它会下降到 33-40KB,小了很多。你会看到,运行时间高了很多,用 vector
慢得多,因为你必须在运行时进行初始化。那么,如果你需要一个数组,你应该用 vector
替换 array
吗?不,我不是这个意思。但是,了解是什么导致了大的二进制文件可能是值得的。而且你知道,当你尝试优化时,你并不总是做那些在其他方面很有意义的事情。我稍后会分享一些例子。是的,你再次看到,对于 std::array
,我们有一个约 50KB 的代码段,而在数据段有将近 80KB。当我们使用 vector
时,你可以从文件名中看到,数据段变得非常非常小,只有 15KB,而之前是近 80KB。所以是的,它小了很多,我们刚刚节省了相当多的空间。
但这还不是唯一的事情。另一个问题是,我们有静态存储期(static storage duration),这会增加二进制大小。我总是想知道如何以最友好的方式问这个问题——如果有人不熟悉不同的存储期,我们就过一遍。你有自动存储期(automatic storage duration),用于非静态、非 extern、非线程局部的局部变量。你可以看到一些具有自动存储期的东西,比如函数的参数、局部变量,甚至是类中非静态的成员。然后你有静态存储期,用于静态变量、命名空间级别的变量和 extern 变量。它们都有静态存储期。你还有动态存储期(dynamic storage duration),用于堆分配的变量和抛出的异常。还有线程局部存储期(thread-local storage duration),用于线程局部变量。我们今天不讨论多线程编程。所以你有这四种不同的存储期,它们对你的二进制大小很重要。
静态存储期会增加二进制大小。我们看到那个数组是在全局作用域的,它没有被标记为 static
,但它具有静态存储期。这些全局静态变量可能对二进制大小产生巨大影响,因为数据可能最终会直接进入二进制文件中。这就是为什么你正确地计算出大小至少是 80KB。对于具有自动存储期的局部变量,即使它们是 const
,你也不会得到巨大的二进制文件,因为在这种情况下,数据不会最终进入二进制文件,它总是在运行时计算。但如果你使用 constexpr
,情况就会有点不同。你可以在这里看到一些数字。这和我们开始时用的例子一样,我们只是改变了容器的存储期。全局的,也就是原始例子,大约是 100KB。如果你把它变成局部的,它会下降到 33KB。但你再次看到,运行时间上升了,因为那个数组必须在运行时创建,它不再是二进制文件的一部分。如果你使用 local static
或 static const
,这取决于优化级别。用 -O3
和 -Os
,你会得到 60KB,否则它可能和之前差不多大。local constexpr
也是一样。所以,这是导致我们看到那段小代码产生巨大二进制文件的第二个因素。这是原始的例子,数据段有 78KB。当我们有一个局部数组时,它再次缩小到 16KB(译者注:原文此处口误为60k,但根据上下文及前面例子应为16k左右),这几乎和我们用 vector
替换 array
时看到的一样。
对于 local constexpr array
,这取决于你如何编译,用什么优化级别。如果我没记错的话,这是在没有开启任何优化(-O0
)的情况下,你得到 78KB。但是当你激活优化时,编译器足够聪明,基本上可以完全摆脱数据段。数据段变得小得多。
但这只是第二个因素。我们看到了你使用什么容器的重要性,我们看到了存储期在二进制大小方面的重要性,但初始值也很重要。它们可以很重要。让我们拿原始的例子,把值从 1 改为 0。记住,在那个 Node
结构体中,a
和 b
被初始化为 1。现在我们把它们初始化为 0。你觉得二进制大小会改变吗?这是一个非常糟糕的引导性问题。是的,它从 99KB 缩小到了 16KB。这可真是不少啊。所以编译器现在可以优化初始化。如果我们查看汇编代码,你会看到,最初我们有 long 1, long 1, long 1
,重复了80000次。幸运的是,我没有在这里列出全部,否则会花很长时间。但如果你把所有东西都初始化为 0,那么你只需要一条命令,一条针对80000字节的零填充(zero fill)命令,而不是80000条指令。只因为你使用了这些类型的默认值,你就只得到了一条指令。
那么如果我们再加一个成员会发生什么呢?zero-fill
仍然会被使用,所以它不会真正影响二进制大小,即使你再加一个变量。而如果你使用 1 作为初始值,如果你关心二进制大小,那可不是个好主意。所以你看到,数据段仍然没有数据,或者说很少,实际上是 0。而如果你添加一些需要运行时初始化的东西,二进制文件会增长更多,因为你必须为它生成代码。对于字符串,它也可能取决于你使用的不同编译器,但趋势通常是一样的。但你看到,即使在这里我们也可以使用 zero-fill
,但还需要添加更多的指令。但我们仍然相当不错。但在这里,数据段又重新出现了,但只针对那些需要生成一些运行时代码的部分。
我们还将讨论对象成员的排序,但首先我们来看看填充(padding)和对齐(alignment)是什么。填充是不同成员之间的额外字节,而对齐要求基本上是针对内存地址的,它会告诉你某些变量必须在什么样的地址上。
举个例子,一个 int
通常是 4 字节大,那么一个 int
就必须从一个能被 4 整除的地址开始。对于一个大小为 8 字节的 double
,它必须在一个能被 8 整除的地址上。所以如果我们看这个排序不佳的结构体:它先有一个 int
(4字节),然后一个 double
(8字节),再一个 int
(4字节)。你把它们加起来是 16 字节,但当你断言它的大小时,却是 24 字节。这是因为为了满足对齐要求而必须引入填充。因为你不能把那个 double
直接放在那个 4 字节长的 int
后面。考虑一下,如果那个 int
在地址 0,那么 double
就会在地址 4,而 4 不能被 8 整除。所以必须添加 4 个字节的填充,这样 double
才能从地址 8 开始。然后第二个整数 i2
从地址 16 开始。但考虑到你在内存中有这些结构体实例一个挨着一个,最后还会添加 4 个字节的填充,所以最终你得到了 24 字节。而如果你按大小递减的顺序来排列它们,你就可以省掉 8 个字节。因为 double
会从地址 0 开始,第一个 int
会从地址 8 开始(能被4整除),第二个会从地址 12 开始(也能被4整除)。当下一个 OptimalOrder
实例出现时,它不需要任何填充,double
就可以直接从地址 16 开始,这个地址能被 8 整除。
这很重要,但这并不总是会减少二进制大小。这就是为什么我把这些幻灯片放在对象初始化幻灯片之后,因为编译器通常可以优化掉这些东西。它在成员没有被初始化为默认值时才重要。如果我们回到这里,如果我们把这些初始化为,你知道,不是默认的零值,而是显式地将它们初始化为 42 之类的,那么编译器就无法优化掉。那么这些对象的大小是 16 还是 24 就很重要了。但如果你使用默认值,很可能你只会得到一条 zero-fill
指令。但无论如何,按大小递减的顺序排列成员仍然是一个好习惯。这本身就是一个最佳实践,它有助于缓存友好性和你的内存占用。并且,根据你使用的存储期、默认值和容器类型,如果它们在容器中使用,它也可能有助于减小二进制大小。
(观众提问环节)… 为什么 double
必须在8字节边界上对齐?这跟你如何访问内存有关。基本上,你不会从内存中读取单个对象,而总是读取一个“字”(word)。内存中的对象会尝试组织起来,比如说,一个字里有三四个对象,你可以一次性读取三四个对象,并且你确切地知道每个实例应该在哪个地址上。(另一位观众补充)是的,如果你在 ARM 上加载或存储一个未对齐的 double
,它会段错误,除非你用了一些扩展。硬件是经过优化的,是的,它就是为了只加载一次而生成函数机器码,这很烦人。谢谢,谢谢。
好的,我们第一部分还有10分钟,所以我们将开始讨论特殊成员函数和二进制大小。
我们从这段代码开始。我们有一个类,所有特殊成员函数都 default
了。我们还有一个数组。这是我们这次讨论的初始代码。我们将有六个版本的几乎相同的类。我们将有内联的默认(default
)特殊函数,有内联的空(empty
)特殊函数,我们也会遵循零法则(Rule of Zero)。这是三种,但我们会把它翻倍,因为我们会有三种带虚析构函数和三种不带虚析构函数的版本。然后我们会看看二进制大小是如何变化的。我再次提醒你,并非所有有助于减小二进制大小的方法都是你应该在开发中遵循的最佳实践。
这是结果。default
非虚版本和 Rule of Zero
非虚版本有相同的大小。有趣的是,当你使用空实现时,二进制大小更大了。而当你使用虚函数时,行为方式不尽相同。空的虚函数版本是最小的,而 default
和 Rule of Zero
的版本更大。这有道理吗?啊,不,不,这并不总是有道理。这就是为什么我说,这是否意味着你应该用空实现来代替 default
?可能不,不是这个意思。但至少看到这个现象很有趣。另一方面,对于非虚函数部分,是的,最佳实践也对你的二进制大小最有利。
看到这一点很有趣。值得注意的是,当你 default
成员函数时,编译器可能能够将它们隐式声明为 noexcept
。它可能能够执行一些优化,而如果你提供了一个实现,即使是空的,它也无法做到。因为编译器不关心它是空的还是非空的,它只关心“是你提供的实现,而不是我(编译器)生成的吗?”是的,这在你的工作量方面不是个好主意,对编译器的努力也不是。
我们真正看到的是,virtual
关键字影响很大。我回到我之前提到的那本书。我们和一个朋友就此有过争论,他说,你不应该那样解读,认为应该把所有东西都设为 virtual
。他不是那个意思,但那本书的写法真的很容易让人那样理解。所以,不,如果不需要,就不要使用虚析构函数。我的意思是,如果你有任何虚成员函数,当然你应该(使用虚析构函数)。但如果你不打算有任何虚函数,就不要仅仅因为习惯而把你的析构函数设为 virtual
,因为它们总是会带来开销。从这些数字中看,默认实现和空实现可能不同,这很有趣。
但是类很少像这样被使用。所以让我们看一些更现实的例子。因为这里我们只有一个 main.cpp
,类和数组都在里面声明。但这不是真实情况。通常,你的类有自己的头文件和实现文件。让我们看看情况会如何变化。在 main.cpp
中,我们只保留 main
函数。我们会把定义移出到行外(out-of-line),意思是把它们从头文件移到 CPP 文件中。我们会分别比较虚函数和非虚函数的结果,不再放在同一个表格里。
那么发生了什么?对于非虚函数的默认实现,无论是头文件-仅有(header-only)还是分离的,以及空实现,我们看到当它们分离到头文件和实现文件时,默认实现和空实现的大小是一样的。但当它们是头文件-仅有时,这真的取决于优化级别。空实现有时会稍微小一点。这只是微小的差异。仍然不是一个值得使用的最佳实践,但看到这个现象很有趣。
所以,如果我们拿这些数据,我们能对牛顿物理学说些什么呢?我想在这里做一个类比。你知道,它在某些时候是有效的。但如果你进入到非常小的尺度,你需要量子物理学。如果你进入到更大的尺度,你需要相对论。所以,这解释了在小尺度上发生的事情。不是微观,也不是宏观,是小尺度。但在大尺度上,也就是你通常在公司里所处的环境,它描述得并不好。
那么我们应该怎么做?default
还是不 default
?嗯,无论那些数字怎么说,我认为你都应该遵循零法则(Rule of Zero)。这是一个核心指导方针。这是最佳实践,是你能做的最好的事情。否则,就遵循五法则(Rule of Five),我们稍后会看到为什么。顺便说一下五法则。并且尽可能使用 default
。因为最终,编译器比我们大多数人更聪明。但我们应该在哪里使用 default
呢?如果你有一个类,它有一个头文件和一个实现文件,你在实现文件里放一些业务逻辑,你应该把 default
放在哪里?在头文件里?还是在实现文件里?这取决于。取决于什么?
(观众互动环节)好问题。那已经足够了。这里不是经济学课程。“这取决于”… 这取决于研究,就像空的析构函数一样。不,这里,只是 default
。你会把 equals default
放在哪里?在头文件还是实现文件,以及为什么?我们这样做。它会发生。这取决于编译器决定使用的大小。所以你把它放在一个单独的文件里,它只发生一次。你把它放在头文件里,它会在多个地方使用,但它可以优化数组,所以你不用传递它。这是一个非常有趣的答案,我认为我们可以在休息后从这里继续。
我们在这里用这个问题结束。这取决于,我们稍后会看。你们还有什么问题或意见吗?
(观众提问环节)… 我真正想知道的是,在实际应用中,动态链接的开销有多大?因为在我的工作中,我们有很多共享库,很多都是单一用途的,只被一个程序使用,我看着它们就在想,这些重定位表和重定位代码到底增加了多少开销?你研究过吗?不,那不是我研究过的,但这是一个非常有趣的问题。所以,你知道,我开始没有提到,但如果我或者这里没有其他人能回答一些问题,也许其他人可以回答这个问题,我会在接下来的几个月里尝试在一篇博客文章中涵盖它,并分享回 Discord 频道,如果你加入的话。但这很有趣,关于可重用性也很有趣,因为你经常会想,“哦,好的,让我们重用这段代码,我们把它分发成一个共享库”,结果它在别的地方根本没被用上。这相当普遍,对吧?好的,非常感谢。享受休息时间,我希望还能见到你们中的一些人,大多数人。
第二部分
我们用一个问题结束了第一部分。如果你有一个类,你声明了所有的特殊成员函数,并且实际上是用 default
实现来定义的,你应该把 default
放在哪里?这是个问题。你应该把它放在头文件里,还是在实现文件,也就是 CPP 文件里?有人试图用经济学家的方式回答,说:“这取决于。”这实际上是对的,但我们不能就此打住。那么,对这个问题还有别的答案吗?你会把 default
放在哪里?头文件还是实现文件?头文件?为什么?
(观众回答)因为如果你把它放在实现文件里,那么就会有一个额外的链接步骤。我明白你的意思。在这里,我不会称之为链接,但确实,你那时就必须调用函数实现了。你就不能内联一个特殊成员函数的实现了。是的,所以,我曾经因为另一个原因问过这个问题。实际上,我在推特上问了这个问题,并@了一些我知道比我更懂 C++ 的人。比如,我@了 Jason Turner,他说:“你干嘛要把它放在实现文件里?”
因为如果你这样做,你把,嗯,你在做什么?好的,你在头文件里声明了你的特殊成员函数,但那里没有实现。没有 default
。所以你基本上向读者和编译器发出了一个信号,表明这里会发生一些不寻常、有趣的事情。然后你在实现文件里放一个 default
,说:“嗯,我撒谎了。”事实上,编译器在某些时候无法知道它将是 default
的,因此它可能无法做某些类型的优化。而且它也无法内联实现。这就是关键,内联是关键。
(观众补充)也许当默认实现生成了大量代码时,如果你把它放在实现文件里,你可能就只需要它的一份拷贝。完全正确。完全正确。所以,通常来说,把 default
放在头文件里是有道理的,出于显而易见的最佳实践原因:它对我们和编译器都可读,也很有帮助。但有一个理由可以让你做别的事情。是的,那就是为了限制内联。因为如果你把它移到行外,那么编译器就不能再内联函数体了。即使是 default
的函数。然后,同样的代码片段就不会被一遍又一遍地重复。这在足够大的规模下可能会很重要。它总是重要吗?可能不,但如果你真的想为二进制大小优化,你或许可以测试和测量它。
比如,我们讨论过,在每个 pull request 中,我们会生成一些数字。我们测量二进制大小受 pull request、受某些更改在不同平台上的影响。所以如果你在 Spotify 的 C++ 代码库中提交一个 pull request,你会看到,好的,这个更改可能不影响 Windows 的二进制文件,可能不影响 Android 的二进制文件,但它影响了,我随便说个数字,iOS 的二进制文件 40 字节。所以,如果你想做这类优化,是的,你可以尝试并测量。我真的建议在所有关心这个问题的代码库中都这样做。在足够大的项目上,这些行外的 default
实际上可以缩小可执行文件。
现在,如果你这样做,有几点需要注意。我一直在整个分享中重复这个想法:为小尺寸优化并不意味着你在遵循最佳实践。你不应该真的把 default
写在行外,除非你有非常好的理由这样做。我们还看到,有时即使是空的实现也比 default
的要小。你仍然不应该那样做。最好还是让编译器做它的工作,使用 default
实现。不,你不用空实现替换它,但我们应该在 CPP 文件而不是头文件里 default
吗?也许你想要那样,但要确保你测量了效果,而且你还需要做点别的事情。
因为,默认情况下,你应该在头文件中 default
,但如果你在 CPP 文件中做,记住这是反直觉的。会发生什么呢?因为我们说这对读者和编译器都是误导性的。会有人来到你的项目,开始把这些 default
移到头文件中,因为他们认为这是项目中的一个坏习惯,直到有人在 pull request 中说,“嘿,我们这样做是有特定原因的。”但你知道,这可能不会在第一个 pull request 中发生,甚至第二个也不会,因为不是每个人都知道这一点。我经历过。我开始从实现文件中移除这些,因为我还记得我提到的和 Jason Turner 的那个推特帖子,就像,“好的,你不想这样做。”他们说,“嗯,实际上在这里你想,因为我们想这样来限制二进制大小。”所以,如果你这样做,请务必在显眼的地方写下文档,说明你这样做是因为这是一项优化。这不仅仅是一个最佳实践,实际上是一项优化,也许那时人们就不会撤销你的改动了。
谈到特殊成员函数,请记住,如果你没有声明移动操作,它们可能会伤害到你。我之前提到过,如果你不能遵循零法则,你应该遵循五法则。我们来看看这个隐藏的表格。是的。关于前一个话题还有一个问题,因为你现在要继续了。有没有办法检测什么时候这样做是有益的?是特别大的对象,里面有很多大的成员吗?或者你需要寻找什么样的模式来判断这可能是有益的?
(观众提问环节)我会说,这取决于。这取决于你有多绝望,因为如果你在寻找每一个字节,是的,那可能就是,你有什么样的成员,有多少,以及初始化它们有多困难。但否则,我会关注它们被使用的频率。因为你在限制内lining。如果它分布在多个共享库中,或者在许多不同的翻译单元中使用,如果它在更大的数组中使用,那么你可能应该关注那些。但是,我刚才提到的,我会说,尝试自动化这些测量。如果你关心二进制大小,试着在 CI(持续集成)中做。试着在 CI 中测量它们,然后把数字推送到 pull request 中,你就会看到结果。如果我不确定,我通常会这么做。我只提一个很小的 pull request,然后看看它如何影响二进制大小。然后如果你需要更多细节,你知道,你可以自己生成它,然后用我们上一节开始时看到的各种工具来深入研究,比如 Google 的 Bloaty
。那时它就非常有用了。
(观众提问环节)所以,寻找被大量使用的小对象,还是大对象?被大量使用的大对象。是的。是的。是的,我想是这样。谢谢提问。是的。我只是在想,可能行不通,但是如果你用,我不知道,MSVC 和 GCC 有像 attribute
来强制非内联函数。如果你把那个和头文件里的 default
结合起来会怎样?是的。你知道,也许你可以试试,但那又不是,你说的那个,它不是标准 attribute
,这使得在各处编译和保持代码可读性变得更困难。所以我认为,例如,这是我甚至不去尝试的主要原因,因为这样做(移到cpp)更简单。但那也可能行得通。是的。还有其他意见或问题吗?好的。
那么回到那个隐藏的表格。我们看到,在某些情况下,如果你不遵循五法则,移动构造函数和移动赋值运算符将不会被声明。例如,你声明了拷贝构造函数,然后你的移动构造函数和移动赋值运算符就不会被声明了。我不会讲遍所有情况,但重点是,未声明的移动操作可能会在你不知情的情况下伤害你。因为,如果没有可用的移动操作会发生什么?你会自动回退到拷贝。这对你的运行时性能可能不好,但我们这里不真正关心运行时性能。我们讨论的是二进制大小。即使对你的二进制文件来说,这也不一定好。因为拷贝操作通常会比移动操作生成更多的汇编代码。所以你最终可能会得到一个更大的二进制文件。
现在,这并不总是意味着你会获得很多。但是我告诉过你,有时你为你的二进制文件做的事情并不是最佳实践。但这(遵循五法则)本身就是一个最佳实践。它对运行时有好处,对二进制文件也有好处。所以,这只是遵循五法则的又一个理由。不仅是为了运行时,也是为了二进制大小。
现在,让我们转向虚函数。virtual
关键字做了什么?
它允许你在派生类中重新定义一些成员函数,或者实际上只是在那里定义它们。并且,它将函数绑定推迟到运行时。所以,在运行时才会决定哪个函数实现被执行。这里有一个小例子。我们有一个 Car
类,带有一些抽象成员函数:accelerate
, shift
。然后你有这个接口的三个不同实现。
那么它到底是什么样子的呢?嗯,这要看情况。虚函数绑定是如何实现的,我认为标准没有定义,但基本上所有的实现都遵循相同的模式。对于每个类,都会生成一个虚函数表(v-table),这是类级别的。它包含指向每个虚函数的指针。所以你看到,那里有两个不同的对象:CityCar
,Offroader
,SUV
,然后在右边是它们的虚函数表。每个虚函数表都有所有这些指向不同实现的函数指针。对于每个对象,你都有一个虚函数指针(v-pointer)。这基本上就是它的样子。
为什么这很重要?这必须被生成和存储。所以,它显然会影响二进制大小。问题是,如何影响?影响多少?例如,类中有多少个虚方法重要吗?我们的假设是,生成一个虚函数表会显著增加你的二进制大小。嗯,这取决于你所说的“显著”是什么意思。但我稍后会谈到。但是,额外的虚函数只会让二进制大小增加一点点。这就是我所说的“显著”的意思,生成虚函数表本身比再增加一个虚函数要重要得多。
所以,我决定做一些实验。类名叫 VirtualExperiment
。我们有析构函数和三个函数。好的。你知道,通常把这些函数设为虚函数没有意义。这不是重点。只是为了有一些我们可以测量并且能在一屏内显示的东西。所以,我用零个虚函数、四个虚函数,以及三、二、一个虚函数分别编译了它。我想看看大小是如何变化的。
如果你看二进制大小,你会发现当完全没有虚函数时,它只有大约 16KB。但是,一旦我们至少有了一个虚析构函数,大小就变成了大约 281KB。而每增加一个虚函数只增加了一点点的大小。所以,如果你看这里,是48字节。这可能会根据优化级别而变化,但肯定会根据你的编译器而变化。但从这里我们可以看到,更重要的是添加第一个虚函数。
而接下来的那些,只是在表中增加了一行,但生成表本身可能需要很多时间。我没有真正检查时间,但它肯定需要很多空间。这让我们回到了我之前提到的那本书。是的。不要把某个东西声明为 virtual
,如果你不需要它,因为它不仅可能影响你的运行时,还影响二进制大小。所以如果你关心这个,那就省掉那些虚函数吧。
在这里,我把初始值从 0 改成了 1,因为如果我们这样做,我们在上一节已经看到,我们可以避免编译器进行相当多的优化,因为它不能只调用一个 zero-fill
指令来为你用零初始化一大块空间,而是会为每个成员、每个实例化一个一个地进行。即使在这里,我们看到当优化无法完成时,仅仅增加一个虚析构函数就意味着大小从差不多 150KB 增加到了 280KB。这是一个显著的变化,为了什么?嗯,这取决于。是可执行文件,还是目标文件?是可执行文件,最终的二进制文件。是的。因为在我们的例子中,那才是重要的。你最终下载并安装到手机上的东西才是重要的。是的。那个可执行文件很重要。
所以,单个虚析构函数代价很高。但是,你知道,无论如何你都得付出这个代价,即使是在编译器优化良好的情况下(用零作为默认初始值),或者在不利于优化的情况下。代价都很高。但是,一旦你知道由于某种原因,你决定采用多态结构,你至少需要一个虚函数,也就是析构函数,那么剩下的虚函数代价就不那么大了。所以,你可能不会真的遇到那四十几个字节都重要的情况。如果那些字节都重要,你会找到办法甚至避免第一个虚析构函数。如果你已经有了一个虚函数,你可能就不太关心剩下的了。你可以根据需要自由添加函数。
(观众提问)那个初始开销,第一个虚函数的开销,是每个类都有的,还是链接进来的支持代码?那是每个类都有的。是的。这太荒谬了。是吗?
嗯,你看,任何事情都有代价。你得到一些特性,你就必须付出代价。而且,你知道,你必须把这放在上下文中看。一方面,你说,“哇,好吧,我不知道,我们把二进制文件大小翻了一倍。”但这是看问题的一种方式。另一种方式是,“好的,我们增加了 100KB 的额外空间”,这不一定那么糟糕。但是,我知道几个月前,我浏览了代码库,不知怎么地,我列出了所有只有虚析构函数但没有任何其他虚函数的类。然后我把那些虚析构函数都移除了。最后我们得到了什么?我想,我记不清具体数字了。我想是几百KB。但不是很多。幸运的是,我们没有很多这样的类。但至少,你知道,我们不损失任何东西就获得了一些好处。
(观众提问)这跟实现的数量有关系吗?实现的数量有影响吗,还是说虚函数表的大小,无论有多少派生类,都大致相同?我明白了。我想,是的,你有多少派生类是重要的。是的。我不是指派生类的大小,而是虚函数表本身的大小。我不知道。是的,虚函数表的大小。是的,是的,是的。好的。
让我们转向模板和二进制大小。
很重要的一点是,你知道,我可能要说一些显而易见的事情,但理解你何时使用模板非常重要,因为如果你理解了何时使用模板,你就可以开始思考你使用什么样的模板。记住,即使在多年前,我们也有这样的讨论:“哦,我们害怕模板。”我之前公司的一些工程师,虽然他们是很好的工程师,但他们告诉我:“不要用模板。它们很危险,很难理解。不,不,不,就是别用。”我说:“我觉得它们会有帮助。”“不,别用。”好吧,随你。但是,我想现在这样的人越来越少了,随着现代 C++ 的发展,模板变得更容易访问和使用。
但是,即使你说,“哦,我不想用模板”,你很可能还是在用模板。即使你认为你没用的时候,你也在用。可能你知道你在用模板,但你甚至没有想过,那就是通过标准库。几乎可以肯定。即使你只是用 std::string
,它也只是一个别名,是什么来着?我想是 basic_string<char>
。但是,在现代 C++ 中,auto
也可能欺骗经验不足的人。他们可能没有意识到,即使没有 template
关键字,你也可以编写模板。如果你关心二进制大小,这一点尤其重要,需要记住。
所以,第一个,也可能是最重要的建议,我可以给你们,而且相对容易实现,就是使用最小化的模板。如果你写模板。因为每次模板展开都会复制一些代码。所以,你在模板函数体或类中的代码越长,被复制的内容就越多。除非编译器足够聪明,用一些好的设置来优化掉一些部分。但同样,你不想真的依赖编译器来让你的代码变小,如果你自己可以轻松做到的话。所以,保持你的模板小巧。即使它是一个函数或类模板。然后把剩下的部分提取出来。
这是什么意思呢?假设你有一个很长的函数模板。有一部分,你使用了模板类型。但之后,你就不再使用它了。出于某种原因,你就那样写了你的函数。在这种情况下,把那部分不依赖于模板参数的代码提取出来。所以你会有一个提取出来的部分,你只需要调用它。这样,当模板展开发生时,这里的代码就不会被一遍又一遍地复制了。只有对它的函数调用会被复制。通过这种方式,你可以在足够大的代码库中获得相当大的收益。
显然,我在这里不能给你真实世界的数字,因为它真的取决于你有什么。而且要识别出那些 pull request 可能太困难了。你也可以对类这样做。你可以从类中提取出,比如说,一个基类。你可以把不依赖于模板参数的部分提取到一个基类中,然后在你原来的类模板中使用它。这样你就可以避免一些代码的复制。
(观众提问)但这仍然是内联的,不是吗?如果你把它放在头文件里。那么区别是什么?嗯,你知道,它得能放进幻灯片里。是的,但是,但是你指出的这点是对的。是的,但如果你不把信息放到头文件里… 是的,是的,是的,是的,只是在幻灯片上展示起来有点困难。但这是一个非常重要的点。谢谢你提出来。
谁用过 extern template
?好的,三、四个,五个人,我不知道,大概是 20%?好的。这实际上比我预期的要多。
那么 extern template
是做什么的呢?extern
通常表示定义应该由链接器找到,对吧?它告诉你的就是这个。所以,假设你有这段代码。你有一个模板类 Wrapper
,然后你写 extern template class Wrapper<int>;
。这意味着,在这个翻译单元中,不会为 Wrapper<int>
的展开生成任何目标代码。我说对了吗?所以,通过 extern template
,你可以限制为某个特化版本生成代码的次数,这对编译时间也很好。我想 C++ Weekly 上有一期视频专门讲 extern template
。在那里 Jason 明确谈到了他在编译时间上获得的巨大收益,但它对你的二进制大小也可能有用,因为需要生成的代码更少了。
那么如何使用 extern template
呢?我可以提两种不同的方法。一种是在每个实例化 Wrapper<int>
的翻译单元中都放入 extern template class Wrapper<int>;
。但这可能很直接,但有点重复。而且,说实话,很容易忘记这样做。另一种我发现的方法,那些使用 extern template
的人可以分享你们的经验。那就是,如果你有 wrapper.h
,其中有你的模板定义,你可以在那个头文件里放 extern template Wrapper<int>;
。这样,任何包含 wrapper.h
的文件也会得到这个 extern template
。因为,你知道,#include
只是文本拷贝。然后在 wrapper.cpp
中,你可以放这个模板类的显式实例化声明 template class Wrapper<int>;
。所以,必要的代码只会在那里生成一次。然后链接器会找到它。
这是那个例子。你有 wrapper.h
。我们下面有 extern template class Wrapper<int>;
。然后我们到 wrapper.cpp
。在那里你有你的显式模板实例化。然后,你只需要在你任何想用 Wrapper
的地方 #include "wrapper.h"
。链接器会做剩下的事。
它们值得用吗?如果所有事情都发生在一个库里,我发现你能获得的收益相当有限。特别是,例如,如果那个 wrapper.cpp
之前不存在,你只是为了这个而创建的。那收益非常有限。但是如果你跨库使用那个外部化的模板,那你就能获得很多。特别是如果它被广泛使用。值得注意的是,这样做你某种程度上也失去了内联。你可能会失去内联,我们说过我们通常想避免内联,但有时它还是有用的。但如果你特别使用了链接时优化(LTO),那么你又能把它找回来。是的。我这里没有放数字,但我在第一部分提到了有一本小电子书可以免费下载,链接我最后会在 Discord 上分享。那里有更多不适合放在幻灯片上的数字。
还有一件事是避免“臃肿的”类模板。什么是臃肿的类模板?嗯,这要看情况,但即使在标准库里你也能找到一些。但通常,一个模板提供的功能越多,它就会越大。这很合理,对吧?如果二进制大小对你至关重要,在你引入任何更多功能之前都要三思。正如我刚才提到的,std
标准库和 Boost
库的类型也不例外。它们可能相当重。
接下来我们跳转到如何向函数传递函数。
如果你想传递可调用对象(callables),你有很多选择。你可以用 std::function
。说到臃肿的模板。嗯,它相对可读,对吧?std::function
,你用返回类型和参数类型。所以还行。你可以用裸模板(bare templates),但,嗯,这不完全一样,对吧?因为你可以传任何东西进去。所以你可以用不那么裸的模板。你可以用 std::invocable
,例如,或者在这里,我们也用了 requires
子句来限制这个 foo
函数能接受什么样的可调用对象。你可以实现几乎相同的约束,就像 std::function
那样。或者你也可以用函数指针。是的,它的可读性不太好。可能这是它最糟糕的部分。但是,你知道,你可以通过一些类型别名来让它更好看。我觉得那样就好多了。至少你有不同的选择。
不同的选择有不同的优势。我们说过函数指针语法不太可读。这取决于你如何定义“可读”或者你习惯于什么。但就我个人而言,我觉得它们不太可读。std::function
开箱即用,并且对任何类型的 lambda 都适用。意思是,你可以用 std::function
来处理带捕获的 lambda。而你不能把带捕获的 lambda 传给函数指针。然后你有模板,它们可以通过概念(concepts)进行高度定制。我们已经说过模板会显著地膨胀二进制文件。但 std::function
本身也是一个模板。而且,正如我所说,它提供了很多功能,因此,它相当臃肿。
让我们做一些实验。我们有这个 performOperation
函数。这里我们用一个非常漂亮且可读的函数指针。嗯,我们只是执行一些操作,然后测量大小。我们可以修改 performOperation
,让它不再接受函数指针,而是接受一个模板,或者接受一个 std::function
。我在所有地方都用了优化。所以你看到,模板、函数指针和 std::function
之间的二进制大小差异很大。std::function
比用模板或函数指针多出了 5KB。而模板和函数指针基本上没有区别。或者说几乎没有区别,那种区别可能不重要。但 std::function
的差异是显著的。我们通过在我们的代码库中仅仅替换掉 std::function
就获得了相当大的收益。实际上,我们是用了一个自己实现的 std::function
,它可能没做那么多事,但对我们来说已经足够了。
借此,我快速跳转到观察者模式和二进制大小,因为这会和 std::function
的使用联系起来。你会看到为什么。
关于这个模式说几句。这是一个行为设计模式。在这里,一个对象可以通知其他对象其状态的变化。我不会带你们走遍整个代码示例。但这些例子,可以说是 Klaus Iglberger 提供的。他不知道这张幻灯片。但是,当我写一篇关于这个的博客文章时,我问他,我能用你书里的一些例子吗?他说,完全可以。所以我基本上从他的书中拿了两种观察者模式的实现。一种是经典的,基于运行时多态。另一种更基于值语义。
你有这个经典的接口,AddressObserver
继承自一个 Observer
模板。你看到,那里有一个 update
函数。嗯,我们创建一个 Person
,附加一些观察者,改变地址,然后,我们期望发生一些通知。还有现代的接口,有点类似,但又有点不同。你仍然有这个 Observer
模板。但是,当你实例化,比如,这个 PersonObserver
时,你传入一个 onUpdate
函数。更新时应该发生什么?你不用为此创建一个派生类。而是,你把它传给构造函数。
两种不同的方法。一种经典,一种现代。而你如何实现现代版本,会根据你如何传入函数而变化。这里可能有一个令人惊讶的结果。如果我们关注粗体部分,你会看到,现代观察者比经典观察者大得多,超过了10%。尽管编译时间稍微短一些。有趣的是,我用不同的函数传递方式实现了现代观察者。之前的数字是基于 std::function
的。你看到经典观察者在二进制大小方面有很大优势。但是如果你用函数指针,或者用一个函数指针和一个 lambda,你看到,哪个更大哪个更小的关系完全改变了。
所以,我想用这个说明什么呢?嗯,如果你想选择,例如,观察者模式的一个实现。当然,你想走经典路线还是现代路线很重要。但是,仅仅选择正确的设计方法是不够的。因为细节会产生很大的影响。所以,当你深入到一个实现的细节时,你如何实现那个设计非常重要。std::function
是一个相对来说,明确地臃肿的实现。如果你需要大量地传递函数,那就避免使用 std::function
。我会说,我会用模板或者函数指针。因为它们会更快更廉价。可能,它们的可读性不高。这是要付出的代价。我觉得 std::function
相对可读。并且,在未来重新审视这个决定。因为,可能你现在,希望的话,幸运的话,在用 C++20。但是,随着 23、26 的到来,你会有 move_only_function
,你会有 function_ref
。这些可能会改变这些数字。所以,如果你依赖这类东西并且二进制大小对你很重要,那么我建议每隔几年就重新审视一下,看看新标准给我们带来了什么。
让我们谈谈 constexpr
函数。它们可能在编译时执行。好处是它们保证你没有未定义行为,因为编译时不可能存在。这是好事。它们也保证了隐式内联。好的。我们喜欢内联吗?不一定。尽管如此,如果我们用三个版本的代码进行实验,我们会看到一些有趣的数字。一次,我们将代码只在一个翻译单元中使用;第二次,在多个翻译单元中使用;第三个例子,跨不同的库使用。
这是例子。我们有一个函数,只是做一些除法。我们用它。我们可以用 constexpr
,用 inline
,或者什么都不用。有趣的是,对于单次使用,没有大的区别。至少,在我们这里使用的方式下是这样。但通常你不会那样使用函数。所以,值得继续看不同的实验。我们看到,没有大的变化。如果你检查中间的汇编文件,你会发现 constexpr
版本从来没有比其他版本大。在多翻译单元使用时,仍然没有大的区别。所有的大小都在 39KB 左右。无论你用 constexpr
还是不用,甚至这里的优化级别也无关紧要。
我是怎么做测试的?我把那个函数移到了,比如说,一个工具头文件里。我们有三个类使用它。是的,你看到,很小。通常你不会真的关心这种差异。但是,当它被分发到一个共享库时,嗯,情况开始变化了。我们有我们的 .so
文件,.so
文件的大小并没有因为里面的代码,那个函数,是否被声明为 constexpr
而改变。那没有变。但是,它们的用户,那些使用这个工具库的其他库,它们的大小显著改变了。如果你把不同二进制文件的大小加起来,共享库和使用它的库,你会看到,当那个函数被声明为 constexpr
时,你最终得到的东西小得多。因为,事情可以在编译时完成,代码不需要被一遍又一遍地复制。即使改变 fun
的实现,也没有真正影响结果。我试了一个更大的函数,不只是一次除法,我做了一些更复杂的事,但这并没有改变这种关系。
所以,我们看到的是,在二进制方面,constexpr
从来没有真正的坏处。如果你使用任何类型的优化,它根本没有坏处,即使你不使用,也只有非常有限的影响。但它可以带来显著的好处。所以,这是又一个理由,如果可以的话,尝试让你的函数成为 constexpr
。至此,我们结束了这一节。在结束 RTTI 和异常之前,我们将回顾一些编译器和链接器设置。
编译器与链接器设置
再说一下关于编译器和链接器设置。我们在团队里通过不同的编码方式工作时获得了极大的乐趣。我们学到了很多,我们很享受。我们可以吹嘘,“啊,我们通过这个漂亮的修改移除了 8KB。”但当涉及到编译器和链接器设置时,你改一个东西就可能赢得数兆字节,这取决于你的二进制文件有多大。所以,是的,尝试一下它们是很重要的。
我们会看几个。我们不能忽略最简单的那个,-Os
。然后我们会看到链接时优化(LTO)可以在二进制大小方面带来巨大的好处。然后我们会讨论一对选项,-fdata-sections
、-ffunction-sections
和 --gc-sections
,我们必须一起使用它们。我们会讨论为什么。我们还有另一个链接器设置,ICF
。我们可以剥离符号,--as-needed
。有一个有点奇怪的,--inline-threshold
,这需要你去做实验,但我们不能给出任何确切的说法。最后我们会讨论栈保护(stack protector),它不会让你的二进制文件变小,但可能会让它显著变大。了解这一点很重要。
最简单的是 -Os
。O
代表优化。它是优化级别之一。你有 -O0
到 -O3
用于为速度优化。然后你有 -Os
或 -Oz
(取决于实现)用于为空间优化。为空间优化是基于 -O2
的。所以如果你为空间优化,不代表你放弃了运行时优化,而是意味着它会包含 -O2
的所有内容以及一些不会让你的二进制文件变大的其他优化,但有些东西会让你的二进制文件变大。例如,循环展开会让你的二进制文件变大,它不是 -Os
的一部分。简单说,比如你有一个简单的循环,它会调用同一个函数五次。如果你用 -O3
或者用循环展开优化,编译器会把你的循环替换成五次同样的代码,因为那可能对它来说更简单更快,但对二进制大小不好。用 -Os
,那不会发生。这是你想减小二进制大小时最基础的尝试。
如果你已经关心二进制大小,你可能已经这么做了。你也可以做链接时优化(LTO)。我个人不太喜欢这个名字。我觉得过程间优化(inter-procedural optimization)更具描述性。它主要由链接器执行,并且会超越单个的目标文件。我说“主要由链接器执行”,是因为它需要在编译过程中增加一个额外的步骤。所以这不是一个廉价的操作,因为你需要编译器创建一些中间字节码。然后链接器才能提取一些依赖关系,执行一些优化,丢弃一些东西,最终让你的二进制文件小得多。我记得一些团队通过开启 LTO 获得了大约 6% 的收益。如果你在 CI 中有时间执行这些额外的步骤,那么值得一试。
而且值得用你编译器或链接器可用的不同选项来尝试它。你需要检查你的文档,但默认的 LTO 是 -flto
。你可以用 thin
来进行更快但不那么激进的优化。你有 -flto=full
来进行最大程度的优化。这会花相当多的时间,但你可能想试试。
-flto=full
有时也被称为全程序优化(whole program)。这取决于你用哪个编译器和链接器。在这种情况下,每个翻译单元在优化期间都会被当作一个单一的单元来处理。这真的会拖慢速度。而且,由于它会进行激进的内联,甚至可能导致更大的二进制文件。所以,这和任何类型的优化都一样:不要盲目地进行优化。测量效果。只有当你认为某个设置能带来你期望的效果时,才去使用它。
这是一个有趣的组合:-fdata-sections
、-ffunction-sections
和 -Wl,--gc-sections
。前两个选项会把每个全局数据和函数放到它自己的节里。在这一点上,你的二进制文件正在变大,大了一点点。所以你不想单独使用这些选项。你想把它们和 -Wl,--gc-sections
结合起来。GC
我想代表垃圾回收,我没验证过,但它帮助我记忆。它会移除未使用的节。在那个时候,你最终可能会得到小得多的东西。当我们说未使用的节和未使用的代码时,它们并不一样。
事情是这样的。假设你链接一个共享库,链接器会包含整个代码段。所以它可能包含你没用到的库代码,但其他人还在用。从这个意义上说,它不是未使用的代码。它是库,你没法真正知道。我认为这是一个重要的说明,否则你可能会想,“好吧,如果没用,为什么不完全移除它?”嗯,它只是在那个上下文中未使用。
同样,别忘了测量和测试,因为这些额外的设置不仅会增加你的编译链接时间,还会给你的设置增加额外的复杂性。有人得维护那个设置,有人得读懂那个设置,搞清楚为什么要有这个东西。如果某个东西没给你带来任何好处,或者只是微不足道的好处,那就扔掉它。也许在注释里提一下你试过但效果不好,所以它不在那里。这样可以为未来的人节省一些时间。
还有 ICF
。我想我没提到,当某个东西前面有 -Wl,
时,它表示这是给链接器的设置,编译器只会传递它。如果你单独调用链接器,就不需要 -Wl,
。我会说 ICF
,代表相同代码折叠(Identical Code Folding),对我来说,这就像是把“不要重复自己”(DRY)原则应用在了链接器层面。它有两个设置:all
和 safe
。一些谷歌的实验报告说,仅仅使用这个设置,他们就在某些二进制文件上获得了大约 6% 的收益,这相当不错。默认情况下,我会用 ICF=all
,但它可能不安全。
那么什么时候它可能不安全呢?什么时候从你的二进制文件中移除相同的代码会不安全?有什么想法吗?是的。(观众回答)如果你比较函数指针。是的。那会出错。嗯。那可能会出错。是的。所以我们为相同的函数生成代码,然后比如用函数作为键。那甚至可能。是的。那会是个问题,而且你不会注意到它。或者,你会注意到,但为时已晚。所以如果你做这类事情,那就用 ICF=safe
版本。它可能仍然会给你带来相当多的好处。我在想我们用的是哪个。我想我们用的是 all
,对我们来说没有问题。
还有一些可以做的。-s
或者它的长版本 --strip-all
,它会移除符号、调试信息以及对执行不是必需的信息。所以对你的 release 构建,比如在生产环境、嵌入式系统中,不是必需的。我认为使用这个会非常有用,但它会让调试变得更具挑战性。是的,我知道,这真的取决于你何时能这样做。我提到过为什么更小的二进制大小对我们在 Spotify 很重要。所以对我们来说,如果 debug 二进制文件很大不是问题,因为用户不会依赖它。但是,在某些像嵌入式环境中,我知道大的 debug 构建可能会是个问题。同样,我一直在重复,在你决定做某件事之前先测量。
还有 --as-needed
选项。默认情况下,你传递给链接器的所有指定库都会被链接。但如果你用 --as-needed
,它会排除不必要的库依赖。嗯,如果你需要它,可能也意味着你的设置在更新、移除不再需要的依赖方面有点草率。但这个选项会帮你做这件事。链接时优化可能会影响这个。所以,如果你组合使用许多这些选项,情况可能会改变。所以值得重新审视和测试它们。
还有一个有趣的。--inline-threshold
,或者在 LLVM/GCC 中叫 --inline-limit
。看,默认数字是144。我不知道为什么,我没找到原因,但这会影响内联。对我来说,这有点奇特,但你可以玩一玩,然后尝试测量。我在 Spotify 的共享库上试过。我给它一些无意义的数字,结果二进制大小疯狂增长了数兆字节,如果我给的数字太大。但即使我给的数字太小也是如此。所以我会说这是值得玩味的东西,但我发现很难把它搞对。
我想提一下栈保护。什么是栈保护?它会让你的二进制文件变大,但会给你带来一些安全特性。它帮助你防止一些缓冲区溢出攻击,你可能想要或者必须使用它。但如果你用它,你有不同的设置,这会改变你在二进制大小方面获得的收益。它做什么呢?它在返回地址前的栈上放一些随机值。这就是它限制某些攻击的方式。它有不同的设置。如果 stack-protector
是默认的,你可以用 strong
选项。那么栈保护会应用到更多的函数上,你的二进制文件会比不用时更大。stack-protector-all
会应用到所有函数上。显然那会让你的二进制文件更大。但你可以在 GCC 中用 stack-protector-explicit
,然后给某些你想要应用栈保护的函数添加属性。这给了你对你将获得的尺寸增益很好的控制。但是,我想是你之前提到了不同的属性。同样,你知道,这我想只适用于 GCC。所以如果你想跨不同编译器工作,你最终会得到一些可读性较差的代码。
编码与编译器设置的交叉点
至此,我们还有大约9分钟(译者注:原文为90分钟,应为口误)来总结,并讨论如何使用编码和编译器设置来减小二进制大小。我们将在这里讨论 RTTI 和异常。
我们从 RTTI 开始。二进制大小和 RTTI。什么是 RTTI?有人不熟悉 RTTI 吗?问这个问题总是错的,但谢谢你举手。它是运行时类型信息,对于每个多态类都可用,比如你有虚函数的类。它帮助确定一个对象的动态类型,你可以在运行时查询它并基于此做一些决定,或者甚至打印一些信息。它是一个默认开启的语言特性。如果你什么都不做,RTTI 就会被生成。你可以在 GCC 和 Clang 上用 -fno-rtti
关闭它,在 MSVC 上用 /GR-
选项。
它给了我们什么?它给了我们 typeid
函数,它返回 std::type_info
。你可能用它来比较两个对象的类型。你可能想在调试时打印它,当你不用调试器,只是想在调查期间加一些日志。我觉得没有这个你也能活得很好,大多数项目都可以。但它也给了我们 dynamic_cast
,它可以在继承体系中安全地转换指针和引用。如果你用它,你经常会在代码里看到它和一大堆 if
语句一起出现,如果它被不正确地使用的话。我个人认为,在大多数情况下,它都被不正确地使用了。你可能会看到像这样的东西。显然这有点夸张,但我想你们很多人会认出这种模式。你尝试 dynamic_cast
到一个派生对象,然后检查指针是不是 nullptr
,然后做点什么。否则你尝试做类似的事情,一遍又一遍。这不会让你的代码更好,也不会让你的代码更可读。
那么,这好吗?这是一种代码异味(smell)吗?我知道,有些人对此有不同意见。我认为这更像是一种异味而不是什么好东西。我认为没有 dynamic_cast
,也就是没有运行时类型信息,你可以得到一个更清晰的接口,在那里你更多地依赖于一个漂亮、整洁的多态对象接口,而不是试图手动转换东西。那样的话,你会让运行时去做分发,而不是手动编码。但我们从中得到了什么?这是个问题。我认为我们获得了清晰性。我们得到了更好的代码,更干净的代码。而且我们肯定节省了代码行数的空间。这是否必然意味着我们会有一个更小的二进制文件?可能吧。是的。可能如此。
我试了一些小例子,你看到,用 dynamic_cast
,-O3
优化,我的例子大小是 37.3KB。不用 dynamic_cast
但 RTTI 仍然开启,是 37.2KB。然后如果我关掉 RTTI,我能省更多一点。这多吗?不多。但它也让你的代码更干净,让你的编译快了一点。所以我认为有很好的理由这样做。有很好的理由在很多情况下禁止 RTTI,不是所有情况,但很多情况下是这样。
为什么?因为我认为 dynamic_cast
通常被滥用,而且有更好的选择。我和另一家公司的人讨论过,他分享了几个他认为 dynamic_cast
是必要的例子。但是,例如,他甚至也同意,通常它是被滥用的。好吧。有些情况下它们是必要的,但如果不是,那也许你可以尝试关掉它。在你从头开始写的项目里做这事很容易。如果你在一个已经有 dynamic_cast
的大项目里工作,就不那么容易了。我知道那是一个巨大的努力。是的。我不能告诉你这在我们的代码库里花了多少时间,或者它带来了多少确切的收益,因为那是在我加入之前,我没有找到相关的文档。但总的来说,人们对它的效果非常满意。所以为什么不用它呢?
让我们花几分钟,我们还剩12分钟,来谈谈异常和二进制大小。
正如你所见,不是每个 CPU 操作都是一样的。有些东西非常廉价,特别是如果你从 CPU 内部或最近的缓存中读取东西。但当涉及到异常时,它在那个列表的底部。异常相当昂贵,对吧?但这重要吗?因为很多人说,异常是零成本的。嗯,它们在不被抛出时是零成本的,对吧?但即使那也不完全正确。是的。如果不抛出异常,运行时成本是零。而且你经常可以说,嗯,是的,异常在被抛出时,在运行时性能方面是昂贵的,但这不重要,因为你可能已经在一个“不愉快”的路径上了,在那里,如果它们被正确使用,运行时性能可能就不那么重要了。
但即使你只考虑运行时成本,编译时间也必须更慢,因为你有更多的代码要编译。所以,你必须生成如果抛出异常时在运行时应该发生什么。这必须被生成,并且它必须被存储起来。所以,二进制大小也会随着异常表和展开信息(unwind information)而增长。
那么如果我们能关掉异常呢?那么异常就不能被抛出。如果它们仍然被抛出,因为你用的库抛出了异常,std::terminate
会被立即调用。所以,希望你的程序会结束。嗯,这是个问题吗?看情况。这取决于。所以我说,好吧,让我们试试一个非常小的例子。我们很久以前有那个类,所有特殊成员函数都 default
了。如果你用关闭异常的选项编译会发生什么?我们得传 -fno-exceptions
。嗯,什么都没发生。完全什么都没发生。因为它们已经是 noexcept
的了。没有异常可以被抛出。编译器已经知道了。所以什么都不需要生成。你一无所获。嗯,这真不是我们想找的数字。它没有证明我们的观点,对吧?那么一个更复杂的例子呢?我们从 Klaus 那里拿来的观察者模式。嗯,在那里,没有异常,我们获得了一些东西。多吗?不多。但是,仍然,这是一个小小的差异。
如果你用异常编译并检查你的汇编代码,你会找到一些异常表,一些相关的指令,关于应该做什么。否则,如果你关掉异常,它就不会被生成。这就是你获得收益的地方。用 noexcept
,你可以表明,这个不应该抛出任何东西。这对编译器意味着,它不应该生成任何与异常抛出相关的信息。否则,如果某个异常仍然以某种方式逃离了那个函数,std::terminate
会被调用。所以这是你可以用的。但是,看,我在观察者模式里到处都放了 noexcept
,结果得到了一个更大的二进制文件。这怎么可能?… 这是个好猜测,但不是。这是一个 bug。到处都有异常表。
结果发现这只是一个 bug。我用 Apple Clang 试了这个。经过一番谷歌搜索,我在 llvm.org 上找到了一个 RFC,他们在抱怨这个问题。所以我想通过这个传达什么信息?测量。因为你知道应该发生什么,但也许它不会发生。因为,编译器也是软件。它们可能有 bug。它们有 bug。这很正常。所以,不要只是认为,你知道,我做一些有意义的事,结果就会很好。测量它。
所以,接近尾声了,在我们一起度过这段时间后,主要的建议是什么?
你如何写代码非常有趣。而且,如果你能重构一下,这里那里移除几KB,会给你带来很多乐趣。但是,首先,检查你的编译器和链接器设置。因为它们能给你带来多得多的东西。并且根据你的上下文,可能它们已经能带来足够的收益了,或者说,不是收益,而是相反,你可以通过使用正确的设置就从你的二进制文件中削减足够多的体积了。
如果大小对你仍然重要,尝试阻止内联。这就是我们看到的,比如,在实现文件中 default
。但是,这不是一个通用的最佳实践。你得尝试看看。
你是否想用异常,在开始时就考虑一下,而且要三思。你可以节省相当多的空间。这里虽然很少,但其他一些能用无异常方式编译更大项目的人报告了更显著的收益。对我们来说这是不可能的。我们不能以一种合理的方式移除异常。我们可能需要它。但是,你知道,尽可能地使用 noexcept
,但是要测量。因为根据你的编译器,你可能得不到你想要的结果。但用其他的,例如,我们仅仅通过使用 noexcept
就获得了很多。
不要白白地使用虚函数。因为它们会让你的代码可能更慢,肯定会更大。如果你有更好的选择,如果你不需要它们,那就别用。而且我会认真考虑关掉 RTTI。因为,我认为在大多数情况下,RTTI 是不需要的。它只会给你手里一个工具,帮助你把代码写得更糟。如果你能不用它,那就很好。你甚至会得到一个更小的二进制文件。
再说一次,思考模板和你如何传递函数。要三思。因为如果你用 std::function
模板,它可能会让你付出很高的代价。而且摆脱它相当容易。这样做可以获得显著的好处。
那么用所有这些信息做什么呢?至少你应该意识到什么会以及如何影响你的二进制大小。你可能不在乎,但至少如果你理解了,那也是好的。这只是一种优化方式。你知道,你可以为很多东西优化:编译时间性能、运行时性能、二进制大小,等等。这只是又一个可以优化的东西。并且,尝试避免我们看到的那些陷阱。如果你回到你的团队,发现了一些你可以使用的有趣部分,那就分享知识。这实际上是你在任何团队里能做的最好的事。非常感谢大家参加这两节分享。非常荣幸。再见。拜拜。