同样的 C++,更快的构建终点

标题:Same C++, but quicker to the finish line

日期:2026/03/30

作者:Daniela Engert

链接:https://www.youtube.com/watch?v=9ijIocn_xzo

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


早上好,各位。欢迎来到关于 C++ 的又一个激动人心的日子。今天上午我们的第一位演讲者是 Daniela Engert(Dani)。Dani 非常好心,已经是连续第三年在这里发表演讲了。所以,非常感谢你的到来。她是我心目中的模块(modules)专家,能邀请到她真是太棒了。而且,我想你以前从未在这里讲过模块。不过这次演讲中会有模块的痕迹,大家做好准备。好的,非常感谢你的到来,Dani,舞台交给你了。

非常感谢这番友好的介绍。欢迎来到我今天的演讲。在进入演讲的核心内容之前,我先简单介绍一下自己。我是一名电气工程师,从事硬件和软件开发已经几十年了。在为 FMT 格式化库贡献了多年代码之后,我受邀加入了德国国家语言机构,随后又加入了 C++ 标准委员会。

今天我将谈谈本次演讲的动机,以及我们在语言中用来实际改善构建时间的工具。然后,我会展示在我们公司真实的生产代码库中进行的一些测量数据,并分享我们在这些测量过程中学到的经验教训。

动机与背景

20 年前,我受聘加入了一个团队,开发一台在钢厂运行的机器。三年后,我在之后成立的一家公司做员工,当时另一个团队正在开发这台机器的改进版(第二代机器)。在经历了大量的维护工作并满足了客户的额外需求后,代码的构建时间不断攀升,直到最后,在现场进行常规的维护工作变得不再可能。我们甚至已经无法承受引入客户关于改进这些机器的额外需求。

五年后,客户带着开发第三代机器的需求又来了。面对如此长的构建时间,乍一看,重用现有的代码库似乎很容易,但这么高的构建时间让我们根本无法对这台机器进行进一步的改进和开发。所以,作为开发人员,我们面临着一个严重的问题。

两年前,我发表了关于模块的第一次演讲。其中有一张原始的幻灯片,对我来说,它承诺了一个构建时间快得多的美好未来——尽管这可能是在未来才会发生的事情。但这仅仅是一个承诺。我们还能做些什么来增加或减少项目中的构建时间呢?

当然,我们可以购买更快、性能更强的构建机器。我们可以看看是否能找到更快的链接器和更快的编译器。在我担任项目经理期间,我学到了一点:我可以告诉团队,写更简单的代码,也许编译得会更快。但你需要注意不要落入错误信念的陷阱。此外,你还可以重新协商项目中实际需要做的事情,从而减少构建时间。在所有这些之外,我们应该始终努力找出项目中那些本质上不可避免的工作量,并消除不必要的工作。这样我们就能更早地完成我们必须做的工作。这个术语叫做效能(efficacy)

编译阶段与 Include 机制

现在,我们必须找出不必要的工作可能隐藏在我们代码的哪个角落。看看编译过程中发生了什么,里面有八个阶段。最有趣的是第 4 阶段(预处理),在这里我们合并和选择文本包含(textual inclusion)的部分,可能会通过宏展开进行修改。以及第 7 阶段,这是实际进行我们通常所说的“编译”和生成目标文件的工作的地方。

说到 include(包含),每当我们指定一个头文件要被包含到一个翻译单元(translation unit)中时,被指定的头文件的文本内容就会被引入到这个翻译单元中。为了能够在不遇到问题的情况下多次进行这种 include 指定,我们使用了包含卫士(include guards)。

当我们一次又一次地指定包含同一个头文件时,会有两种可能的结果。一种是糟糕的结果:我们迫使编译器实际上把同样指定的代码一直编译到第 4 阶段,然后再通过条件编译把它扔掉。这是可以避免的浪费。更好的结果是:编译器识别出我们想要再次指定同一个头文件,于是编译器不再去看这个文件。这被称为包含卫士优化(include guard optimization)。编译器有一种特定的方式来格式化包含卫士,以便对其进行优化;但它也有其他技术上可行的头文件指定编写方式,只是某些编译器无法对其进行优化,所以这种优化也就无法实现。关于这个特定主题的更多细节,我建议参考幻灯片顶部关于特定 include 的网站。

名字查找(Name Lookup)

下一个要谈论的话题是名字查找。在左边,我们看到我们的翻译单元;在右边,我们有几个想要在这里使用的库。在这段代码中,我们两次使用了指向某个操作的标识符。对于右边,我们有五个可能的候选者。

当我们实际上实例化调用一个 受限(qualified) 的函数时——即我们以一种受限的方式写出可调用对象的标识符——通过这种限制(指定去哪个作用域查找),唯一可想象的候选者只剩下一个。所以这(查找起来)是非常廉价的。

但是,如果你想在这里实例化第二个函数(调用 无限定的(unqualified) 函数),即指向可调用对象的标识符没有受到限制,会发生什么呢?除了我们在前一种情况中进行的常规名字查找之外,现在编译器中还会进行第二次名字查找,编译器会去查找额外的命名空间——即那些由函数参数类型所驻留的命名空间。此外,编译器还会查看类本身以寻找隐藏的友元(hidden friends)。我们在昨天的演讲中讨论过隐藏友元的存在。

在收集了所有这些可调用标识符的潜在候选者之后,编译器将不得不弄清楚潜在候选者中哪一个是最合适的。如果重载集合(overload set)中只剩下一个最合适的候选者,编译器就会选择这一个。否则,我们的编译就会失败。

在模板的情况下,我们可以利用在翻译单元后面的部分指定模板特定实现的优势。然后我们必须迫使编译器将名字查找推迟,直到这些模板特化的参数真正变得已知的那一点。所以,这里起作用的不仅是括号中的函数参数,还有尖括号中的模板参数。在这个特定的例子中,剩下来的候选者实际上是结构体 B 中的隐藏友元,而不是前一个受限情况中的那个。这是我在大约十年前为了在代码中实际找到这个特定问题而进行调查的原因。

所以这里的结论是:每当我们有名字查找时,尽可能使用受限名称(qualified names),以便我们在翻译单元中首次出现名称时就将名称绑定并查找到对应的实体。我们也看到了,这种限制有助于名字查找。只有在你真的打算创建一个定制点(customization point)时才使用无限定名称(unqualified names):例如你想搜索隐藏友元,你想利用第三次名字查找的优势,并且你能确保你的候选集合非常小,小到编译器要做的工作相当少。

模板与元编程

第三个话题是模板的使用,尤其是不幸的模板使用。我问一下在场的听众,谁使用过模板?或者更具体一点,模板元编程(template metaprogramming)?好,有不少人举手了。谁意识到自己在没有刻意去用的情况下使用了模板元编程?没有人?我想大家没听懂。谁在使用 std::move?请举手。我想每个人都在用,因为如果我们查看标准,我们会了解到 std::move 是一个函数模板,其返回类型是一个基于类型计算(type-based calculation)的结果。所以我们在几乎所有的代码中都在使用模板元编程。

说到模板,在 C++17 基于类型的元编程的鼎盛时期,有很多关于这个特定主题的演讲。其中有一场是 Odin Holmes 做的演讲,他谈到指派他的一名实习生去调查使用模板的代价。他将这次调查的结果总结为一个列表,并称之为“HEAL 规则”(HEAL 是那个实习生的名字)。在顶部,我们看到了 SFINAE 和函数模板的实例化。在底部,我们看到的是已经特化了一个模板然后再去查找它的情况。

SFINAE 的代价对我们来说非常重要,因为它涉及到前面关于名字查找和重载决议(overload resolution)的话题。SFINAE 使用模板来生成函数签名的一部分,这样每当模板的特化失败时,这不是编译本身的失败,相反,重载集合中的那个特定候选者会被移除。这意味着我们做了大量的模板特化和解析工作,而这些工作仅仅是为了被丢弃和浪费。

因此,我们了解到,在后来的 C++ 版本中,我们应该避免使用 SFINAE,而应转而使用概念(concepts)和 requires 表达式。事实上,语言中所有后来的新增功能对编译器实现本身来说都变得更容易了。所以在这一点上,我们或许应该使用更现代的 C++ 版本。

当我们思考基于类型的元编程(type-based metaprogramming)时,我们也看到我们可以使用 基于值的元编程(value-based metaprogramming) 来代替。使用基于值的元编程的好处是,每当我们完成计算结果后,编译器可以释放计算期间需要的所有资源,而不是像基于类型和模板的元编程那样,需要一直保留之前所有的计算结果。所以我要说的是:只有在无法使用基于值的元编程时(例如你所选择的语言标准中尚未提供该功能),才去使用基于类型的元编程。

预编译 API:PCH 与模块(Modules)

现在让我们换个话题。在过去几年的演讲中,我总是被问到这个问题:预编译头文件(pre-compiled headers, PCH)怎么样?它们是缩短构建时间的一种方法。那么它们与模块相比如何呢?我以前总是无法回答这个问题,因为我不知道如何得出这种性能比较。但关于 API 预编译的主题,就像预编译头文件一样,我们需要稍微绕个道,看看这到底意味着什么。

让我们拿一小段代码,把它放在一个头文件中,并把它和其他头文件的指定一起放在一个翻译单元中,使得文本包含构成了我们整个编译单元。在翻译单元中,我们能找到语言实体、宏,当然还有编译器标志和编译指示(pragmas)。实体是我们真正感兴趣的东西,这是我们翻译单元的意义所在。宏组合并选择翻译单元文本表示的部分,可能会通过宏展开进行修改。标志、编译器选项和编译指示则决定了二进制输出。

所以我们要讨论三个集合:实体集合、宏集合和标志集合。宏和标志构成了编译环境,它们与语言实体一起,在翻译单元的每一个点上构成了编译状态(compilation state)。所以我们从这些集合相对较小或为空开始,在编译整个翻译单元的过程中,它们根据需要不断增长和变化。

因此,我们有一个初始的编译状态,在我们库头文件开头有一个状态,在库头文件结尾有一个状态,以及在翻译单元结尾的最终状态。翻译单元开头的翻译状态和我们头文件开头的状态之间的区别,我在这里称之为前缀(prefix)。

当有多个翻译单元时,我们就有多个 include 包含。这意味着我们有多个翻译状态。如果我们的头文件是以一种不受以前编译状态影响的方式编写的,我们可以把头文件重新排序,比如放到最前面,这样翻译单元的初始翻译状态就和头文件本身的状态一样了。这意味着我们可以把这个头文件包含提取出来,放到一个特定的翻译单元中,并给编译器一些特定的标志,让它不生成目标文件,而是生成一个预编译头文件的制品(artifact)。这就是我们所说的预编译头文件(PCH)。在里面,我们记录了初始的编译状态,也记录了最终的编译状态。

当然,这引入了一个编译依赖。当预编译头文件中记录的初始翻译状态与我们在翻译单元中使用的状态相匹配时,我们就可以通过导入预编译头文件而不是包含头文件来优化我们的构建。这当然也意味着,现在我们在当前展示的翻译单元和为了生成预编译头文件而进行翻译的翻译单元的结果之间,产生了一个“编译到编译(compile-to-compile)”的依赖关系。更仔细地观察,我们会发现最终的翻译状态中潜在地包含了非空的实体集合。

但随着 C++20 的到来,我们现在有了头文件单元(header units)。是的,头文件单元。头文件单元和预编译头文件非常相似,不同之处在于,我们不需要为了预编译我们的 API 而去创建一个特定的翻译单元。相反,我们指示编译器将这个头文件本身作为一个独立的翻译单元来处理。根据模块的规则,这也意味着我们在那里可能有一组略微不同的语言实体。我们将结果写出为 BMI(二进制模块接口),而 BMI 不再包含我们的编译器选项、标志和编译指示。

如果我们再进一步,会注意到我们还可以使用命名模块(named modules)。在这种情况下,API 不再以头文件形式存在,而是一个独立的翻译单元。再一次,在生成的 BMI 中记录了略微不同的语言实体集合,此外,在翻译结束时的宏也不会传递给模块的导入者。

事实上,我们有四种将 API 引入翻译单元的方法,从传统的头文件到命名模块。我在一张表格中总结了它们。我不会深入讲细节,因为自 2019 年以来我已经就模块发表了多次演讲,大家可以在那里查找细节。从左到右,我们可以看到优势的增加、权衡的变化以及我们可用选项的增多。但对我们今天来说最重要的是最后两行。

第一个问题是,我们是否真的能将不同的 API 和预编译制品组合在一起?对于头文件和模块来说,答案是肯定的,它们就是这样设计的。而对于预编译头文件(PCH),如前所述,很明显我们在每个翻译单元中只能有一个。我们稍后也会了解到,使用 PCH 和模块预编译 API,在后续使用时比包含头文件要快得多。

模拟测试与测量

回到之前关于 PCH 和模块对比的请求,去年底我终于找到了一种实际进行这种比较的方法。我想在这里展示的,就是如何实现它的技术。

受预编译头文件的启发,我想尝试模拟同样的过程。例如,我想预编译 ASIO 库。我拿现有的头文件,也许我可以配置它们,使得我可以将其作为一个“头文件单元”来编译,加入一点包含卫士和一点样板代码。然后我可以指示所有主流编译器将这个特定的头文件编译成 BMI 并放入缓存中。

但是如何将它带回翻译单元中使用呢?对于 PCH,我们有特定的编译器选项来做到这一点。但我们无法对模块 BMI 这样做。所以我唯一的选择是将“导入模块”的指定包装进一个头文件中。然后我可能不得不保护这个模块指定,以防那些尚未准备好支持模块的工具报错。也许我的实现并不是真正符合标准的,我需要找到一个权宜之计,那么我就会把它放在这里。再一次加入一些包含卫士和样板代码。这就是模块指定的包装器,我可以在命令行上指示编译器将其引入翻译单元中。

这里有一个例子。也许我们有一段使用了部分 Boost 库的代码,它有 Boost 的 include 文件并且知道在哪里找到它们。我们可以改为指示编译器:“请从我的命令行强制注入模块的指定。另外,编译器,这是我存放预编译 BMI 的地方。”

为了演示这项技术,我创建了一个简短的演示,其中我使用了一个在中间部分展示了模拟库的翻译单元。我还在将这个库包装成一个 include 文件的同时对其进行预编译,方法是提供特定的编译器选项,使用幻灯片底部给出的名称。然后,我将使用 main 函数两次编译顶部的翻译单元。第一种情况是通过包含头文件来指定库;第二次是通过指定我之前预编译好的 BMI 来引入它。提醒一下,这来自于一个头文件单元。正如我们学到的,头文件单元有能力将宏带入翻译单元,并影响翻译单元下游的后续编译。所以,包含卫士及其优化在此适用。

我应该使用哪种编译器呢?如你所见,我预构建了模块,并在其中添加了额外的追踪信息,以查看编译过程中实际发生了什么。接着,我使用头文件构建了程序,它花费了一定的时间。在第三种情况下,我通过模块指定并注入 BMI 来构建程序。注入 BMI 和包含卫士优化让我有机会用预编译的 BMI 替代头文件的指定。你能看到这样做的好处。同样的方法在使用其他编译器时也是可行的,在某些情况下收益甚至更大,比如在 Visual Studio 26(注:可能是指特定的 VS 预览版或口误,通常为 VS 2022 v17.x)中,提升更为显著。

生产环境代码库的真实数据

确信这项技术有效后,我查看了我们整个代码库,查找了在我们内部项目中使用的所有第三方开源库。它们的数量不是很多,但体积都相当大。特别是 Boost,它本身就是一个相互依赖的巨大库集合。此外,我们还必须考虑随我们编译系统实现附带的库(如 STL)以及操作系统绑定的库。

观察这些库,我了解到它们几乎都以预编译的形式提供(实现交付为对象或库文件),并附带头文件供我的代码实际使用。其中一些库是“仅头文件(header-only)”库,极少数有两者结合的情况(Boost 就是一个例子)。还有一些库为用户提供了根据自己需求进行配置的机会。所以,从我的代码的用户角度来看,我会依赖它们全部或其中一部分。

STL 在这方面有点特殊,因为自 C++20 以来,所有供应商都以模块化的形式提供标准模板库(标准库)。当然,我该如何处理我其余的依赖项呢?我同样也想把它们全部预编译。显然,标准库是所有其他库的依赖项,但经过仔细检查,即使是标准库也有依赖项,即与操作系统的绑定,前面提到的一部分其他库也是如此。如果我能从中获益那就太好了。事实上,我想要预编译我所有的依赖项,甚至是最底层的依赖项。

在继续之前,我首先需要设定一个基准(baseline)。为此,我创建了一个单一的头文件,里面包含了所有我的依赖项的 API,以及一个小型测试程序,我可以通过头文件、预编译头文件或模块的形式将这些依赖项引入进去。我还可以观察编译器在这个测试中到底在做什么。

这是我读取所有这些依赖项并写出生成的制品的结果。我们看到差异并不大。很明显,我查看的是相同的一组 API,编译器要做的工作是相同的。差异主要在于最终必须写出哪种制品的区别。这是我在 Microsoft 编译器实现上看到的结果。你可以在前端、后端、写入时间以及挂钟时间(wall-clock time)上看到差异。

之前看到的预编译的第二部分是,实际引入所有这些依赖项需要多长时间。我的参考点是通过使用头文件来引入它们——我把这视作挂钟时间和编译器所需努力的 100%。再看看以预编译形式引入同样这些 API 所需的时间,我之前在表格中提到的所有预编译版本和选项的耗时都有相当显著的下降。

另一个指标是,在空间和存储方面引入所有这些依赖项需要多少成本。读取所有头文件,或者使用等效的预编译头文件(PCH),在编译器中所占用的 RAM 是使用模块的 20 倍。具体来说,在这个特定案例中,在实际编译我自己的代码之前,仅仅引入这些依赖的 API 就需要 2GB 内存;相比之下,使用模块引入预编译的 API 只需要 88MB。这意味着,只有当我们提供的构建机器性能足够强悍时,并行构建才会成为一个挑战。

下一步,我选取了我们内部代码库中的一个真实程序作为例子。这也是我每次在研究构建系统、编译器、新库或新语言特性时首选的程序。这个测试程序也安装在客户的机器上,用于他们现场特定硬件部件的远程维护和故障排查。换句话说,这绝不是一个玩具例子。

观察构建它所需的挂钟时间和所需的 CPU 时间,这是我这里的参考点。然后我发现,在整个构建过程中,我有一大块工作是预编译、预生成代码和数据。例如,为 UI 表单或为客户现场用户所说本地语言的字典生成数据。我把这一部分从方程式中剔除了,因为我同样可以提前预编译和预生成这些部分并保留它们。所以,这就是我在新基准下看到的改进。但真正的收益来自于以模块形式引入预编译的 API,我认为这个优势是非常巨大的。

接下来,我专门查看了我不生成代码或数据的地方。我还观察了编译器本身完成的工作,把它分成了几个部分:在前端引入 API、编译整个翻译单元的其余部分、在后端花费的时间,以及最终的链接时间。这是对该程序整个构建过程的分析。比较构建过程中所做的这些工作部分,我看到在绝对百分点上,(使用模块带来的)优势相当显著。

然后我问编译器:在构建整个程序(总共 49 个翻译单元)期间,你具体做了什么?这里比较了两种情况:一种是完全按代码库和构建系统原样使用代码;另一种是不改变源代码,而仅通过预编译 API 所能获得的收益。结果表明,预编译 API 不仅加快了命名指定和引入 API 到翻译单元的工作速度,也加快了剩余的工作,特别是在 前端(front-end) 的工作。

这就回到了演讲开头提到的:在名字查找、重载决议、模板特化中所做的部分工作,可以转移到预编译阶段。这就是我们这样做的收益所在。

看到这些数字后,我也收集了内部代码库中尚未自行使用模块的其他所有项目的类似性能数据。将它们合并并浓缩成一张幻灯片,你会发现我之前讲的特定测试案例并不是个例,所有的结果都在同一个范围内。所以,预编译 API 确实有着实实在在的好处。

结论:我们学到了什么

到目前为止,从这次演讲中我们学到了什么:

  1. 当我们编写代码时,应该清楚我们在要求编译器做什么。

  2. 我们应该尽可能多地使用受限名称(qualified names)

  3. 我们应该使用基于值的元编程(value-based metaprogramming)

  4. 我们不应仅仅预编译库的实现,还应该预编译 API

  5. 我们应该以一种 可组合(composable) 的形式来做这件事。预编译头文件(PCH)不可组合,但头文件和模块可以,它们被设计成可组合的。

  6. 通过将工作转移到预编译阶段,我们现在有更多机会执行并行构建。这就是挂钟时间告诉我们的。

  7. 随着越来越多需要在编译时已知实体的语言特性(如常量求值、反射等)的出现,我们现在的头文件越来越大。因此,我认为将这些头文件预编译为模块是完美的搭配。

最后,回到演讲开头那个激励我的例子——我们必须开发的第三代机器。在应用了我在演讲前半部分提到的所有提示和技巧,购买了性能更强的构建机器,并且在 2021 年实际开始开发前花了几周时间后,我们的构建时间大约提高了 2 倍。也就是说,我们回到了 15 年前开发第一代机器时的原点。当然,这并不是一个开发要求翻倍的第三代机器的理想状态。

在审视所有这些代码时,我注意到,巨大的构建时间来自于代码的结构。核心应用中的很大一部分代码,虽然感觉像是库,却没有以库的形式来编写。因此,通过将模块作为外观(facades)放置在这些现有代码之前,将这些代码片段转变为具有有意义接口的“虚拟库”,我们提供了一种利用这些类似库的部分的替代方法。核心功能外部通过这些模块外观来使用这些“虚拟库”。

最终,我取得了真正的突破!构建时间再次提高了 10 倍!所以,我不再需要处理那一团乱麻、像戈尔迪之结(Gordian knot)一样的意大利面条代码了,现在我有了可用的模块外观。这使我们能够开发出这台机器,它去年底成功运行并被客户验收。

在这里你可以找到一些你可能想看的资源:我经常在网上出没的地方,联系我的方式,以及可以获取这些幻灯片 PDF 版本的二维码。非常感谢大家的聆听。

(主持人:谢谢。非常感谢,Dani。我想大家肯定有问题。)

问答环节

提问 1: 请问你能回到幻灯片第 23 页(或 22 页)吗?就是你展示编译不同部分性能提升的那一页。我理解模块在编译的前端部分引入了巨大的加速,但我不明白的是,为什么 后端(back-end) 也变快了?

Dani: 我同样对这个观察结果感到惊讶。在我看来,这是因为在后端生成的 调试器符号(debugger symbols) 数量减少了。我看到 Gabby 正在点头,所以我的观察和假设似乎是正确的。


提问 2: 关于内存利用率的那张图表。我想确认一下我是否理解对了,这里显示模块的空间需求是 1 倍,而其他方式是 20 倍?换句话说,编译器内存使用率有了极其巨大的降低?

Dani: 在这个特定的实现中,是的。我不能谈论 Clang 和 GCC 的实现,因为我没有测试它们,我没有手段,也没有那个意愿去测,因为我想看的是真实的代码表现,而不是举例子。

提问 2 追问: 所以这只是 MSVC 编译器的结果?但这已经非常惊人了。如果在其他编译器上也有测量结果会很有趣,因为我知道我们的编译往往更多受限于内存,而不是处理器。

(Gabby 在台下补充):关于这个 10 倍左右的因素,在微软内部(比如 Office 或 Windows 团队)已经持续观察到了。Office 团队的人甚至写过相关的博客并做过演讲。所以我可以确认,这不是偶然,这是一致的现象。


提问 3: 在演讲开始时,你提到编译器解析受限的(qualified)函数调用比无限定的(unqualified)调用成本更低。请问函数是完全受限的,还是仅仅通过一个命名空间或一个类名来限制,这有区别吗?只要能缩小编译器查找范围就能加速编译吗?

Dani: 是的。这取决于编译器为了寻找候选者必须要去哪里查找名称。任何能够缩小编译器查找作用域的方法都会加速编译。

提问 3 追问: 你有关于这种提升能有多显著的粗略数据吗?比如在整体编译中占百分之几?

Dani: 相信我,我花了很多时间试图去测量它。但是 MSVC 编译器提供给我的工具无法让我得出这样(精确)的数字。你可以去问问 MSVC 的实现者。

(提问者感叹:我今天学到了新东西。我一直提倡使用受限命名空间,也提倡使用 concepts,现在我各自又多了一个新的理由。)


提问 4(评论): (来自 Bjarne Stroustrup 或 Gabby):你有一张幻灯片说“模块和编译时编译是天作之合”。我想稍微反驳一下,它其实是在德克萨斯州(Texas)诞生的。当时 Gabby 和我试图解决老式模板元编程中错误和编译时间的问题。如果你读过我的书,你会发现我基本不用“模板元编程”这个词,因为它导致了复杂性。如果你能做编译时编译并使用概念(concepts),你的表现会好得多。

Dani: 非常感谢你们两位(Bjarne 和 Gabby)提出了 IPR(Internal Program Representation),它为 MSVC 编译器中 BMI 的重大改进奠定了基础。

(Gabby 补充评论):这算是一个评论。人们通常对微软编译器使用格式所获得的速度和空间增益感到惊讶。实际上,IFC(模块接口文件)格式,其实就是 IPR 的磁盘表示形式。IPR 是我和 Bjarne 在德克萨斯州时开发的。核心思想是我们需要一种紧凑、简单、通用的 C++ 内部表示。IPR 通过给一系列不规则的代码包裹上一层圆润的结构,使得我们在处理这些不规则性时更加可预测。这极大地减少了你需要写入磁盘的数据量或不同类型的结构数量。


提问 5: 关于构建系统的问题。请问现在有哪些构建系统能真正跨不同编译器来使用模块?这种东西存在吗?

Dani: 我在整个演讲中完全搁置了构建系统的问题。这里的展示不涉及具体的构建系统。针对具体问题,我知道有几个构建系统可以充分利用模块,比如 MSBuild,还有 XMake。我不确定 Bazel 在这方面提供了什么。总之是有一些优秀的例子的。


提问 6: 谢谢你的演讲。既然我们在讨论现代特性,我想退一步,退回到 90 年代。C/C++ 开发者有一招老技巧叫 Unity Builds(统一构建),你尝试过吗?你探索过这个机会吗?

Dani: 我试过,并且败得很惨。为了正确进行 Unity Build,你需要准备好你的翻译单元,使其真正能被这样构建。而我在这里所做的是将现有的库预编译为模块。

提问 6 追问: 你在 Unity Builds 中遇到了什么挑战?

Dani: 比如我们会遇到同一实体的多次定义。如果使用包含卫士,这种情况不会发生。但现有翻译单元的编写方式通常并未考虑作为 Unity Build 的一部分被编译。如果你能容纳并修改它,确实引入 API 的工作量会减少,这是个好处。但在我看来,将工作转移到预编译空间和时间,比使用像 Unity Builds 这样的“拐杖”更有益。

提问 6 补充: 但有时感觉容纳 Unity Builds 实际上提高了代码库的健康度,比如移除了重复的实体和纯粹的复制粘贴代码?

Dani: 是的,但你还必须考虑到,使用 Unity Builds 你实际进行并行构建的机会就变少了。

(Gabby 补充评论):关于 Unity Builds,就像 Daniela 指出的那样,如果你想实践它,你需要提前设计代码。因为它不容忍语言整体或某些惯用的构造。给你一个具体的例子:如果你使用版本控制系统,通常会有一个字符串变量(例如修订号),这个变量通常是对当前文件静态局部的(local static)。当你进行 Unity Build 时,把一大堆源文件包含在一个大文件中,你就会产生冲突。即使它们原本在不同的文件里,被包含在同一个大文件后,它们就成了同一个翻译单元的一部分,你就会得到同一源文件中的多重定义错误。所以它与语言标准和现有的实践是不兼容的。

我再讲个讽刺的小故事。当我在 MSVC 中开始编写模块代码库时,不幸的是我使用了大量的模板。我的一些队友对模块文件耗时太长感到恼火,所以他们决定采用 Unity Builds,尽管我极力反对。后来随着模块变得更加成熟,我们想在编译器中使用它们,结果我们不得不花了好几个星期去“撤销”当年的 Unity Build 改造。所以从我的角度来看,那是负生产力。


Dani 的最后总结: 也许允许我对这里所做的工作做个最后的评论。我从头到尾都在使用头文件单元(header units)。我不推荐使用头文件单元。请优先使用命名模块(named modules)。 但这是我能用来利用模块模拟预编译头文件(PCH)的唯一方法。因为我可以利用包含卫士及其优化。我们也许应该改变语言,以便提供一个只包含宏的辅助 BMI,这样可以在第 4 编译阶段结束时带来预编译剩余部分的好处。现在由于头文件单元,我们必须在第 4 阶段进行所有的工作,而不是将它们带入第 7 翻译阶段。我们应该将这些特定的关注点分离到不同的翻译阶段中。这是我个人的看法。也许看看 C++29 会给我们带来什么。很遗憾我没能看 Jeff 的演讲,也许他在里面谈到了这个。

(主持人:) 好,让我重复一个昨天问过的问题。你们中有多少人的软件还停留在 C++17 或以下版本?……你们升级到 C++20 的截止日期可是“昨天”哦!好了,Dani,非常感谢你!

Dani: 谢谢。非常感谢。