C++20,说说 Module 那点事儿

几天前,C++20 草案已经获得了标准委员会的全票通过,C++2a 草案讨论的几个重要内容,比如“概念(concept)”、“范围库(Ranges Library)”、“协程(Coroutines)” 和 “模块(Module)” 都加到 C++20 的标准中了,剩下的就是看编译器厂商的支持速度了。目前看 CLANG 、GCC 和 Microsoft 是比较积极的三家,语言特性和库支持的最快的是 GCC,其次是 CLANG 和 Visual C++,估计最快到年底就能看到支持全部 C++ 20 特性的编译器了。

图(1)C++ 20 的 Big Four

毫无疑问,在目前主流的编程语言中,C++ 是最难掌握的编程语言,没有之一。我常常和朋友调侃,C++ 是最适合做高校考试用的语言,因为 C++ 的内容繁杂,知识点多,当然,“坑”也多,非常适合出题考试。从易到难,大学四年考题都不会有重复的。除了对初学者门槛太高,传统的 C++ 语言特性上支持的也很弱,与其他编程语言相比,做同样的功能,C++ 往往需要写更多的代码。不过从 C++11 开始,一直到 C++17,这种情况开始有了明显地改善,各种之前被 C++ 社区讽刺为“语法糖”的语言特性逐步被添加到 C++ 语言规范中,比如 lambda 表达式,比如基于范围的 for 循环。应该说,这都是被 Python “逼”的,其实不仅仅是 C++ 在学 Python,就连 Java 这个浓眉大眼的家伙,也是学得各种“真香”呢。

这次 C++20 新增的四大语言特性,最让我碎碎念的就是 Module 了。之前曾经有传言,说 Module 可能会被推迟到 C++23 发布,不过好在 Module 最后还是“挤”进了C++ 20 。为什么这么期待 Module,这得从 C++ 的几个痛点说起。

首先是头文件的各种问题。C/C++ 语言使用头文件和源文件分离方式迫使程序员养成接口与实现分离的设计习惯的哲学实验事实上是失败了,除了极少的优质项目,大部分的C++项目的源代码组织并没有有效地实施这个原则,反而引入了一大堆头文件难题,extern关键字的“巧用”更是让这个问题雪上加霜。对初学者来说,找不到头文件是最大的“痛”。它就在那儿,为什么编译器就是“视而不见”?那是因为你的包含路径设置不正确,什么绝对路径,什么相对路径,让很多还没入门的初学者过早地体会了“码”生的艰难。学习 C++,从入门到放弃,也许 include 一个“永远”找不到的头文件就够了。

对于一个大一点的项目来说,头文件之间那种“剪不断、理还乱”的关系最让人头疼不已。头文件之间的循环依赖、互相依赖,你中有我,我中有你。“要用这个接口,只要包含那个叫 A 的头文件就行了,easy!”,你的同事们是否都这样信誓旦旦地跟你说过类似的话?大部分情况下你会发现这个叫 A 的头文件并不单纯,它可能还有“七大姑八大姨”分别位于不同的代码目录中,如果你不设置好头文件的搜索路径,编译器会告诉你人生有多难。当 Java 的程序员在软件架构层面上考虑对象之间的依赖关系时,C++ 的程序员还在为了解决编译问题而试着调整各个头文件的包含顺序,唉,说多了都是泪。

C++ 的另一个痛点就是编译速度了。相对于 C++ 的各种“难用”,编译速度太慢才是 C++ 最大的诟病。本人曾在一个大型 C 语言项目中引入了一个 C++ 模块,编译的时候编译器对 C 的源文件和 C++ 的源文件的处理速度明显不在一个数量级上。GCC 在编译 C 语言的源文件的时候,处理速度飞起,输出信息刷屏的速度眼花缭乱。但是在处理 C++ 的源文件的时候,基本上就是以每秒 1 - 2 个这样的目视可见的速度推进。C++ 因为引入了泛型和模板库,支持重载,这使得它的预处理需要做很多事情,慢也是必然的。但是对于一个大型项目来说,这种慢会严重拖累自动化编译、测试和部署的速度,换句话说,就是不“敏捷”。

现在看看 C++20 的 Module 能给我们带来什么。在 Modernes C++ 网站上,可以窥得一些细节,C++20 的 Module 承诺了五个目标:

  • 更快的编译时间(Faster compile times)

C++ 的编译器在处理一个源代码文件的时候,首先要做的就是用相应的头文件的内容替换 #include 预编译指令。这就存在一个问题,对每一个源代码文件编译器都要重复一遍内容替换,这会占用大量的处理器时间。替换完的源文件长度也会膨胀数倍,一个源代码文件的处理速度不仅要看这个文件中有多少代码,还要看它包含了多少头文件。使用 Module,只需要 #import 一次(编译器只需要对导入的 Module 做一次解析处理),就可以在所有的源代码文件中使用,没有头文件的替换动作,也不需要重复解析处理 Module,这对编译时间将是一个巨大的优化。

  • 宏的隔离(Isolation of macros)

只要你使用 C++ 的时间足够长,你就会碰到这样的代码:

 #ifdef XXXX
 #undef XXXX
 #endif
 ​
 #define XXXX  ...

初次看到这样多此一举的代码,心里肯定会想这是什么鬼?其实见怪不怪,肯定是某个家伙的也定义了一个同样名为 XXXX 的宏,只是值不一样而已。对于同名的宏,如果值也一样,编译器一般“睁一只眼闭一只眼”就放过去了。但是如果值不一样,大多数编译器的默认选项也只是提示一个编译告警,如果你碰巧忽略了这个编译告警,等待你的很可能是痛苦的 Debug 过程。菜鸟们经常抱怨:“我定义的宏的值明明是 5,为什么赋值给变量后变成这个奇怪的值?” 那是因为别人也定义了同名的宏,并且恰巧头文件处理顺序在你的前面。要么调整头文件的包含顺序,要么用上面那段代码,这就是 C++ 程序员的日常工作内容。Module 的使用,可以缓解这种困惑。首先,从语义上讲,可以用 module_name.XXXX 来隔离宏,其次,Module 只需要导入一次,同名宏的先后关系与只与导入顺序一致,不会因为头文件的包含顺序混乱让对某个宏的值你摸不着头脑。

  • 表达代码的逻辑结构(Express the logical structure of the code)

从设计层面上考虑,头文件其实包含了过多的实现细节,并不适合向外展示,Module 则很好的规避了这一点。首先,作为一个二进制的包,你不想展示给用户的内容,都可以隐藏起来,只将必要的部分标记为 export 即可。其次,你可以按照代码的实现逻辑设计各个 Module,它们可以自由组合形成不同功能的逻辑包提供给客户,这都比给客户一堆平面表达的头文件,然后让客户自己选择组合使用更能满足客户的需求。

  • 让头文件成为“过去式”(Make header files superfluous)

一个 C++ 项目只要对同一使用的 Module 管理规范,可彻底解决各种包含路径问题。当你想使用某个接口的时候,只要 #import A就行了,再也不用关心该死的头文件搜索路径了,谁说这不“香”呢?毫无疑问,有了 Module,再也不会有人走回头路用头文件了。话又说回来,这也让 C++ 的代码与 Java 代码的相似度又增进了一层,搞不好以后要看源代码文件的扩展名才能区分。

  • 摆脱丑陋的宏环境(Get rid of an ugly macro workarounds)

前面讲过,C++ 的编译器在对 #include 指令包含的头文件的处理方式是原地展开替换,假如一个源文件包含了头文件 A 和头文件 B,而头文件 B 又不巧间接包含了头文件 A(这种情况在一个大型的 C++ 项目中几乎是常态),那么 A 就会在这个源文件中被展开替换两次。确实是这样的,编译器不仅浪费时间做了展开替换,还会抱怨符号重复定义的错误。所以你会看到每个头文件几乎都是这样用宏环境包裹着自己:

 #ifndef THIS_HEAD_FILE_H
 #define THIS_HEAD_FILE_H
 ​
 .....
 #endif //THIS_HEAD_FILE_H

也许看的时间长了,觉得这样的代码很规范,其实这是一种多么无奈的选择,还是上面那句话:不用头文件,就不用再浪费时间给每个头文件写这种宏环境了。

最后,说说C++20 Module 特性让我失望的地方,那就是没有统一语言层面上的包管理器。从标准上看,依然是准备留给编译器厂商或第三方机构做这个事情。这不仅让我想起来 C++ 到现在都没有一个语言层面上的 UI 库,虽然第三方的 UI 库倒是不少,但是基本上处于“生殖隔离”的状态,互不通用,编程方法千差万别,也各有优缺点,常常让 C++ 程序员无所适从。对于包管理器,微软推出了 NuGet,但是由于跟自家的 IDE 捆绑太紧密,并没有得到 C++ 社区的广泛支持。Python 之所以好用,是因为 Python 语言的缔造者和整个 Python 社区共同维护一个统一的包管理器,对各种库提供统一的包装接口,使用简单,学习也简单。从现在开始到 C++23 发布还有不到三年时间,希望 C++ 标准委员会能领导整个社区弄一个统一的包管理器,对各种库提供一致的接口封装,避免出现同一个库有多个 Module 封装版本的情况出现,让 C++ 程序员再次无所适从。

猜你喜欢

转载自blog.csdn.net/orbit/article/details/108680567