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

论坛 期权论坛 期权     
CSDN   2019-6-9 21:24   784   0

[h1]
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++ 模块化,究竟是问题多多的无用尝试,还是如期待般能带来其承诺的性能升级呢?


[/h1]作者 | 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 。
3. 在编译 bar.cpp 时,如何确保 #include 被解析?这很简单:确保 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
分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:65
帖子:240
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP