C++ 开发者怒了:这个无用的模块设计最终会害死 C++!

2018 年年底,C++ 标准委员会历史上规模最大的一次会议在美国 San Diego 召开,讨论了哪些特性要加入到

C++20

中。其中,Modules 便是可能进入 C++ 20 的一大重要特性:“一直以来 C++ 一直通过引用头文件方式使用库,而其他90年代以后的语言比如 Java、C#、Go 等语言都是通过 import 包的方式来使用库。现在 C++ 决定改变这种情况了,在 C++20 中将引入 Modules,它和 Java、Go 等语言的包的概念是类似的,直接通过 import 包来使用库,再也看不到头文件了。”

然而就是这一特性,前段时间在 Twitter 上引发了不小的讨论。再加上诸多其他问题,“

C++ 20 还未发布就已凉凉

”的论调也早有苗头。C++ 模块化,究竟是问题多多的无用尝试,还是如期待般能带来其承诺的性能升级呢?

C++ 开发者怒了:这个无用的模块设计最终会害死 C++!

作者 | vector-of-bool

译者 | 苏本如

责编 | 仲培艺

出品 | CSDN(ID:CSDNNews)

以下为译文:

C++ Modules(模块化)被视作 C++ 自诞生以来最大的变化,其设计有几个基本目标:

1. 自顶向下隔离:模块的“导入程序”不能影响正在导入的模块的内容。导入源中编译器(预处理器)的状态与导入代码的处理无关。

2. 自下而上隔离:模块的内容不会影响导入代码中预处理器的状态。

3. 横向隔离:如果两个模块由同一个文件导入,则它们之间不会“串扰”。导入语句的顺序无关紧要。

4. 物理封装:只有模块显式声明为导出的实体才会对使用者可见。模块中未导出的实体不会影响其他模块中的名称查找(除了 ADL 可能有一些不同之处【依赖实参的名字查找】,但这就说来话长了)。

5. 模块化接口:强制任何给定模块的公共接口在称为“模块接口单元”(MIU)的单个 TU 中声明。模块接口子集的实现可以在称为“分区”的不同 TU 中定义。

如果你期望 Modules 可以像 C++ 的许多其它功能一样经久不衰,那么你会注意到上面这个列表中缺少了“编译速度”。然而,这是 C++ Modules 模块最大的承诺之一。模块带来的速度提升可能就是归功于上面的设计。

下面我列出从 Modules 设计中受益匪浅的 C++ 编译的几个方面,按照从最明显到最不明显的顺序:

1. 标记化缓存(Tokenization Caching):由于 TU 的隔离,当模块后面导入另一个 TU 时,可以缓存已经标记化的 TU。

2. 解析树缓存(Parse-tree Caching):和标记化缓存一样。标记化和解析是 C++ 编译中开销最大的操作之一。我自己的测试显示,对于具有大量预处理输出的文件,解析可能会占用高达 30% 的编译时间。

3. 延迟重编译(Lazy Re-generation):如果 foo 导入了bar,然后我们修改了 bar 的实现,我们可以不需要对 foo 立即重新编译。只有对 bar 接口修改后才需要重新编译 foo。

4. 模板专门化:这一点比较微妙,可能需要更多的工作来实现,但潜在的加速是巨大的。简而言之,模块接口单元中出现的类或函数模板在经过专门化处理后可以在磁盘上缓存并供后续需要时加载。

5. 内联函数代码复制缓存:内联函数(包括函数模板和类模板的成员函数)的代码复制结果可以缓存,然后由编译器后端重新加载。

6. 内联函数省略代码复制:extern template 允许编译器省略对函数和类模板执行代码复制,这对编辑器的代码去重操作非常有益。模块允许编译器隐式执行更多的 extern template-style 优化。

看上去模块设计相当不错,不是吗?

但是我们都忽略了一个非常可怕且极为糟糕的缺陷。

还记得…… Fortran 吗?

FORTRAN 实现了与 C++ 的设计有点相似的模块系统。几个月前,SG15 工具研究小组在圣地亚哥提交了一篇文章(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1300r0.pdf),据我所知,这篇文章迄今为止没有得到任何相关人士的讨论和评论。

文章要点摘录如下:

1. 我们有模块 foo 和 bar,分别由 foo.cpp 和 bar.cpp 定义。

2. bar.cpp 里有 import foo; 语句。

3. 在编译 bar.cpp 时,如何确保 import foo 被解析?当前的设计和实现有一个为 foo 定义的所谓“二进制模块接口”(简称BMI)。这个 BMI 是文件系统中描述模块 foo 导出接口的文件。我就叫它 foo.bmi, 文件扩展名在这里无所谓。

4. foo.bmi 是编译 foo.cpp 的副产品。编译 foo.cpp 时,编译器将生成 foo.o 和 foo.bmi。因此,必须在 bar.cpp 之前编译 foo.cpp!

趁着警铃还没有拉响,我们来讨论一下我们目前使用头文件的工作方式:

1. 我们有一个模块 foo,由 foo.cpp 和 foo.hpp 定义; 和另一个模块 bar,由 bar.cpp 和 bar.hpp 定义。

2. bar.cpp 中有 #include <foo.hpp>。

3. 在编译 bar.cpp 时,如何确保 #include<foo.hpp> 被解析?这很简单:确保 foo.hpp 存在于 header 搜索路径列表的目录中。我们不需要做任何额外的预处理。

4. 对模块 foo 和 bar 的编译没有次序要求,可以并行处理。

并行化可能是提高 build 性能最重要的方面。优化 build 时,你无需再考虑并行化,因为它已经存在了。

模块改变了这一点。模块的导入导致了一个编译时间的依赖项,这在 #include 语句中并没有体现。(关于模块编译的次序问题,可参考:https://vector-of-bool.github.io/2018/12/20/build-like-ninja-1.html)。

Rene Rivera 最近在《Are modules fast?》(https://bfgroup.github.io/cpp_tooling_stats/modules/modules_perf_D1441R1.html)一文中探讨了这种设计的后果。

剧透一下 Rene 文章的结论:答案是否定的,或者更准确一点来讲,这很微妙,但大多数情况下答案仍然是不。这篇文章中使用的当前模块实现是非常原始的,但仍然在了解哪些模块看上去对性能有帮助这方面有一定的参考价值。可以期待,随着硬件并行性的提升,header 的引导模块变得越来越重要,而且与 DAG 深度(即互相导入的模块链的长度)也有关系。随着 DAG 深度的增加,模块会越来越慢,而 header 则保持相当稳定,即使是对于接近 300 的“极端”深度。

一个徒劳的扫描任务

假设我有下面的源文件:

import greetings;
import std.iostream;
int main() {
 std::cout << greeting::english() << '
';
}

这很简单。因为我们导入了一些模块,所以我们需要先编译 greetings 和 std.iostream,然后才能编译这个文件。

那么,让我们来……

emmm……

怎么啦?

我们只有一个包含两个 import 的源文件,仅此而已,别无他物。我们不知道 greetings 是在哪里定义的,我们需要找到这个包含 module greetings; 语句的文件。

在银河系另一侧的 talk.cpp 文件看起来很可能是:

module;
#ifdef FROMBULATE
#include <hello.h>
#endif
#ifndef ABSYNTH
export module something.pie;
#endif
import std.string;
export namespace greeting {
std::string english();
}

它定义了我们想要的 greeting::english 函数。但是我们怎么知道这是正确的文件呢?它并没有 module greetings; 这一行!

但它某些时候确实是我们要的。当我们使用 -DFROMBULATE 编译时,文件 hello.h 会被粘贴到源文件中。让我们看看 hello.h 里面有什么?

#ifdef __SOME_BUILTIN_MACRO__
# define MODULE_NAME greetings
#else // Legacy module name
# define MODULE_NAME salutations
#endif
export module MODULE_NAME;

Oh no!

好吧好吧……别担心。我们需要做的就是……运行预处理器来检查文件中是否出现 module salutations 或 module greetings。

这是可以的,但是有 4201 个文件可以定义可以被导入的模块,其中任何一个都可能有 module greetings;。

另外,我们还不能使用自己的预处理器实现,需要精确地运行编译这段代码的预处理器。看到 __SOME_BUILTIN_MACRO__ 了吗?我们不知道那是什么。如果我们没有正确地对它进行编译,编译就会失败。更糟的是,我们甚至可能会错误地编译此文件。

那么我们能做什么呢?我们可以在预处理完所有文件后缓存所有模块的名称,对吗?那么,我们在哪里存储这个映射表呢?当我们想用一个不同的编译器编译,生成不同的映射表时会发生什么?如果我们添加需要扫描的新文件怎么办?为了检查任何模块是否添加、删除或重命名了,我们是否需要在每次构建时搜索这些包含了数千个源文件的所有目录?在那些启动进程和/或访问文件需要较大开销的系统上,这些成本也将会叠加上去。

可能的解决方案

这两个问题虽然不同,但却是相关的,我(和许多其他人)认为模块设计的一个改变可以解决这两个问题, 那就是模块接口单元的位置必须是确定的。

有两种备选方案可以实施:

1. 强制从模块名称派生 MIU 文件名。这模拟了头文件名的设计,它与如何从 #include 指令中找到头文件名直接相关。

2. 提供一个“manifest”或“mapping”文件,描述基于模块名的 MIU 文件路径。此文件需要用户提供,否则我们将同样遇到上文描述的扫描问题。

有了确定且易于定义的 MIU lookup(查询),我们就可以进入下一个必要步骤:必须延迟生成模块的 BMI。

TU 之间的编译顺序将扼杀 module adoption 的进程。即使是相对较浅的 DAG 深度也比与头文件相同的深度慢得多。唯一的答案是 TU 编译必须是可并行的,即使是导入其他 TU 时。

在这方面,C++ 最好模仿 Python 的导入实现:当遇到新的导入语句时,Python 将首先找到对应于该模块的源文件,然后以确定性的方式查找预编译的版本。如果预编译版本已经存在并且是最新的,就使用它;如果不存在预编译版本,则将编译源文件,并将生成的字节码写入磁盘。然后加载此字节码。如果两个解释器实例同时遇到同一个未编译的源文件,它们将竞争写字节码。不过,竞争并不重要,它们都会得出相同的结论,并将相同的文件写入磁盘。

为了方便 DAG 中 TU 的并行编译,C++ 模块必须以相同的方式实现。提前编译 BMI 是不可能的。相反,当编译器第一次遇到有关模块的 import 语句时,应该延时生成 BMI。Build 系统根本不应该与 BMI 有关。

只有当一个 MIU 的位置对于编译器是确定的时候,以上这些才能实现。

前景渺茫

前段时间,Twitter 上发生的事让人心烦意乱。Kona 会议前的邮件列表在 1 月 25 日开放了。在发布的许多文章中,有一篇《关注模块的工具能力(Concerns about module toolability)》(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1427r0.pdf),其作者和贡献者名单中很多是来自业界的系统和工具构建工程师。我想呼吁权威人士的关注,但我觉得这份名单中的人才是最有资格提供 module toolability 反馈的人。

这篇文章的诞生源于许多工具作者和合作者(并不局限于论文中所提及的,包括我自己)的关注,因为大家都深深感到自己长久以来对于模块的关注都被忽视了。

SG15 之外的人一直热衷于反驳关于 module toolability 问题的讨论,他们声称 SG15 缺乏必要的实现经验,无法对模块这个话题提出有用的建议。

SG15 只搞过面对面的会议,上次在圣地亚哥的会议也没起到什么作用,因为主席不在,而且大家急急忙忙参会,没时间进行任何有用的讨论。由于在官方的 WG21 会议之外没有安排 SG15 会议,因此其成员很难保证更新并协同工作。此外,SG15 曾多次尝试重提已经被拒绝的问题,被拒绝的原因是因为他们提出的问题被认为“超出了 C++ 语言范围”。

关于 Kona 会议前邮件列表的推文催生了关于 C++ 模块化的讨论:关于 module toolability,该相信谁?(https://twitter.com/horenmar_ctu/status/1089542882783084549)。

这场讨论最终以要求 SG15 “他妈的闭嘴”而告终,除非 SG15 能够提供代码示例来证明它们所提到的问题。但是这个示例代码,无法在当前的任何编译器中实现,也不能在任何当前的构建系统中实现。所以即使这些问题确实存在,这个要求也只能得出一个否定的结论,因为这是一个无法凭经验完成的任务。也就是说,要求 SG15 提供代码根本是一个无法永远完成的任务。

这些问题没有继续讨论下去,也没有被推翻。甚至没有人再提到 《关注模块的工具能力》中列出的问题。我们只是被简单地告知要相信一些大人物比我们更了解 C++ 模块(这里我要再次呼吁权威人士介入)。

支持目前模块设计的人尚未证明模块能适应大规模生产环境,但是他们却要求 SG15 提供模块不能满足大规模生产的证据。尽管已有的模块部署并没有使用当前的设计,也没有使用真实环境中构建实际系统所需的自动模块扫描。

如果模块被合并,结果发现它们不能以良好的性能和灵活的方式实现,那么人们就不会使用模块。如果一个 broken module 建议被合并到 C++ 中,后果可能是不可弥补恢复的,C++ 也将永远得不到模块设计承诺带来的好处。

至于针对当前模块设计的改进方案能成功解决这些问题呢?我不能给出确定的答案,但我和许多人都认为 C++ Modules 有重大问题需要解决。

然而,从其他人的做法来看,SG15 怎么想似乎并不重要,他们的提议总是被缺乏 C++ 工具经验的人否决, 他们在整个讨论中没有任何发言权,提出的任何问题都被认定为“未经证实”和“超出范围”而不予考虑。

我不太敢指责这种行为的后果,我也并不热衷“人际冲突”。然而,我更担心 C++ 这个无用的模块设计最终会害死自己。

原文:https://vector-of-bool.github.io/2019/01/27/modules-doa.html

本文为 CSDN 翻译,如需转载,请注明来源出处。作者独立观点,不代表 CSDN 立场。

相关推荐