跳到主要内容 C++20 模块用户视角下的最佳实践 | 极客日志
C++
C++20 模块用户视角下的最佳实践 本文从用户视角探讨 C++20 模块的最佳实践,涵盖编译速度提升、ODR 违规规避及 API 可见性控制等优势。文章介绍了模块接口文件后缀规范,详细阐述了为基于头文件的项目提供模块包装器的两种策略:导出用风格和导出 extern"C++"风格,并对比了它们在 ABI 兼容性、维护性及性能上的差异。同时讨论了编译器对模块内联的处理及混合机制对符号冲突的影响,旨在帮助开发者在语言层面更好地组织和迁移至 C++20 模块。
C++20 模块用户视角下的最佳实践
在当前的技术环境下,关于 C++20 模块的文章、演讲和介绍已比比皆是。它们大多集中在工具链的介绍或抱怨上,而在用户层面的探讨相对较少。
一方面,工具链确实非常重要,是大范围使用 C++20 模块的基础。另一方面,从语言特性角度看,C++20 模块与协程、概念、反射及合约等特性相比,可以说非常简单。
即便 C++20 模块在语言层面已非常简单,共享一些使用经验依然有价值。
标题中的'用户视角',指的是不关心选择什么编译器、构建系统,编译器如何实现模块,以及不同编译器的行为差异等事项。而是作为一个 C++ 库的维护者或项目的最佳实践管理者,在语言层面该如何使用 C++20 模块。
这里强调'用户视角'的原因是我想总结一些很有价值但还没说的东西,并不是指现在工具链一切都就绪了。
内容可分为两部分:如何为当前使用头文件的项目提供 C++20 模块包装器(但依然使用头文件开发),以及在模块中本地地组织代码。
本文各节保持独立,兴趣不同的读者可跳过不感兴趣的内容。如如果你想开始在全新项目中使用 C++20 模块,只看'模块本地'相关节即可,这其实是最简单的部分。
C++20 模块的好处
在介绍实践方式前,先介绍下 C++20 模块的好处,为之后介绍不同实践方式的原因做铺垫。C++20 模块的设计目的主要有:
更快的编译速度
避免 ODR 违规
控制 API 可见性
避免宏污染
其中更快的编译速度和避免 ODR 违反两个目的,都是用 C++20 模块可为每一个声明提供唯一一个归属的 TU(翻译单元)来达到的。
更快的编译速度(和更小的代码体积)
之前有人认为 C++20 模块不过是标准化的 PCH 或标准化的 Clang 头模块。这都不对。PCH 或 Clang 头模块避免不同 TU 重复的预处理/语法分析以减少编译时间。
而 C++20 模块在此之上,还可避免相同声明在编译器后端的重复优化与编译。而对很多项目而言,编译器后端的优化和编译才是耗时的主要来源。
例如:
inline void func_a () { ... }
该写法会让每一个包含 a.h 且引用到了 func_a() 的 TU 都对 func_a() 做优化及生成代码。
而使用模块的写法:
export module a;
export int func_a () { ... }
无论有多少 TU 引用了 func_a(),编译时,这些 TU 都不会再对 func_a() 做重复的优化和生成代码。
这是 C++20 模块相比于 PCH 或 Clang 头模块能提升更多编译速度的一个点。
比起全局函数,更常见的是类内内联函数,即:
class A {
public :
void a () { ... }
};
C++20 标准规定,在命名模块中的类内内联函数不再是隐式内联。即当 在命名模块中时,只应该在命名模块对应的目标文件中放置 的定义,而不会被不同客户重复优化/编译。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
A::a()
A::a()
而除了这样显式的函数定义外,如虚表和调试信息等,都应该遵守相同原则,即此类信息应该只在相关定义对应的命名模块中生成,避免在各个客户中都生成一遍,即浪费时间还浪费空间。
实际上发现,应用 C++20 模块不但可减少编译时间,对减少构建产物的体积也有显著帮助。
避免 ODR 违反 ODR(一个定义原则)指的是一个程序中每个实例都应该只有一个相同定义。当一个实例有多个不同定义时,该程序就违反了 ODR,叫 ODR 违反,此时程序是有问题的。
实践中,若一个实例的多个定义是强符号,则会在链接时报错并提示'多个定义'。而如果一个实例的多个定义全是弱符号,则会在链接时任意挑选一个定义,实践上链接器一般会选择遇见的第一个定义。
忽略一个强符号多个弱符号的情况,它一般是特意设计的。两种情况相比,在链接时报错比在运行时报错要强很多,安全很多。
头文件机制因为其自身不是 TU 却要被多个 TU 共享的特征,天然地将头文件内的几乎所有符号都按弱符号设计,为 ODR 安全埋下了很大的隐患。
当一个大项目因为各种原因引入了同一个三方库的不同版本时,可能就进入了 ODR 违反的潜在危机中。
而 C++20 模块基于每一个实例都有唯一的物主 TU 的原则,会为每一个实例都提供强符号,天然地可避免这类 ODR 违反。
此外 C++20 模块还引入了独特的混杂机制,为命名模块中的每个实例添加和模块名强相关后缀,可避免不同库之间不经意的重名冲突。如:
export module M;
namespace NS { export int foo () ; }
NS::foo() 的链接名在解混杂后按 NS::foo@M() 显示。进一步降低和其他模块中的 foo() 函数重名的概率。
至于模块的重名,C++20 模块要求每个模块单元都生成一个模块初化器来初化其内部状态(哪怕该模块内部实际上不需要初化任何东西),该模块初化器是一个强符号。
模块接口后缀名 Clang 推荐模块接口文件以 .cppm 为后缀名。MSVC 推荐 .ixx。GCC 则没有特殊偏好。
大多数用户一般通过构建系统和编译器交互,而构建系统实际上清楚哪些文件是模块接口而哪些不是。所以不少用户觉得它并不重要,随意就行。
但我还是推荐大家使用 .cppm 或 .ccm,此后缀作为模块接口文件的后缀。原因一个是工具友好,另一个是可读性也更好。
对工具,如代码行统计此类工具,用后缀名统计可给出更直观的结果。
哪怕是对 clangd 这样更复杂的工具,如果 clangd 可假设所有模块接口都以 .cppm 结尾,就可提升 clangd 的处理速度。如 CLion 就假设了只有 .cppm 结尾的文件才是模块接口文件。
另如在代码可读性方面,.cppm 此特殊后缀对可读性也有帮助,如:
.
├── network.cpp
└── network.cppm
当看到此文件结构时,可很简单地认识到 network.cppm 声明了接口而 network.cpp 声明相关实现。
所以我推荐大家使用 .cppm 或 .ccm 作为模块接口文件的后缀。
为基于头文件的项目提供 C++20 模块 Interface
#pragma once
#include <cstdint>
namespace example {
class C {
public :
std::size_t inline_get () { return 42 ; }
std::size_t get () ;
};
}
#include "header.h"
std::size_t example::C::get () { return 43 + inline_get (); }
为了 ABI 兼容,假设其会分发一个导出的符号为如下 libexample.so:
$ nm -ACD libexample.so
libexample.so: w __cxa_finalize
libexample.so: w __gmon_start__
libexample.so: w _ITM_deregisterTMCloneTable
libexample.so: w _ITM_registerTMCloneTable
libexample.so: 0000000000001130 W example::C::inline_get()
libexample.so: 0000000000001110 T example::C::get()
W 表示 Weak,指 example::C::inline_get() 为弱符号。T 表示 example::C::get() 为强符号。
对仅头库作者和只分发源码不分发二进制的库作者而言,该例可能依然复杂了些,但只要理解了该简单情况,相信为其他更简单的示例封装模块包装器也不成问题。
导出用风格 导出用风格是为头文件提供 C++20 模块接口最简单的办法,包括 libc++, libstdc++ 和 MSSTL 使用的都是该办法。
当前看到的大部分支持 C++20 模块的库使用的也是该办法。
类似:
module ;
#include "header.h"
export module example;
namespace example {
export using example::C;
}
即在全局模块碎片中插入此项目的所有头,然后在模块预览中导出用语句导出对外可见的声明。
因为没有侵入性,在应用中发现其他三方库不支持模块但又希望导入这些三方库时,可在自己的项目中为这些三方库添加包装器。
但该方式的缺点主要是模块包装器与原先头文件中的实现在不同文件中,维护者可能在维护头文件时新增/删除/修改了原先导出的接口,但忘记更新 example.cppm 导致中断。
对支持 C++20 模块的三方库使用导入 若项目使用的第三方库使用了 C++20 模块,应该在模块接口中应使用导入引入该三方库而非 #include。
这对提升用户的编译速度有帮助。因为 Clang 编译器当前的实现限制,在存在导入时避免使用 #include 可带来更大的编译加速。
而可将基础库看作最普遍的第三方库,所以对上述示例,可重写 header.h 为:
#pragma once
#ifdef USE_STD_MODULE_IN_HEADER
import std;
#else
#include <cstdint>
#endif
namespace example {
class C {
public :
std::size_t inline_get () { return 42 ; }
std::size_t get () ;
};
}
module ;
#define USE_STD_MODULE_IN_HEADER
#include "header.h"
export module example;
namespace example {
export using example::C;
}
导出用风格的 ABI 注意,如果你的项目会分发二进制,你需要将 example.cppm 编译到你分发二进制中,此时 libexample.so 中导出的符号应为:
$ llvm-nm -ACD libexample.so
libexample.so: w _ITM_deregisterTMCloneTable
libexample.so: w _ITM_registerTMCloneTable
libexample.so: 0000000000001050 T initializer for module example
libexample.so: 0000000000001140 W example::C::inline_get()
libexample.so: 0000000000001120 T example::C::get()
libexample.so: w __cxa_finalize@GLIBC_2.2.5
libexample.so: w __gmon_start__
使用 llvm-nm 而非 nm 因为低版本 nm 不能解混杂 C++20 模块相关混杂规则。
与之前的版本相比,这里导出的符号多了模块示例的初化器。
类似,哪怕项目不分发二进制,但如果你的项目中存在源文件,你的构建脚本中,应该在同一个库文件中编译 example.cppm 此模块接口和你的源文件。
对仅头的库而言,在一个库(哪怕你不真分发二进制)中添加 example.cppm 此非模块接口,对用户来说是最方便的。
但如果如前单纯地只分发 example.cppm 的源码,那用户需要自己处理 example.cppm 对应的目标文件。
如果此用户是终端用户,没有更下游的代码用户,那问题还算简单,只需要编译 example.cppm 到目标文件然后链接在一起即可。
而如果此用户依然是库用户,其有更下游的代码用户,那可能最好是不要将 example.cppm 编译到目标文件,将该任务延迟到最后的二进制用户。
这里的关键在于,如果 example.cppm 在一开始的库中没有被指派到库中,那该源文件在二进制层面缺乏了真正的物主,只能希望最终的可执行文件的用户去处理它。
导出 extern"C++"风格 对所有头文件中的 #include,用一个宏控制,如:
#pragma once
#ifndef IN_MODULE_WRAPPER
#include <cstdint>
#endif
#ifdef IN_MODULE_WRAPPER
#define EXPORT export
#else
#define EXPORT
#endif
namespace example {
EXPORT class C {
public :
std::size_t inline_get () { return 42 ; }
std::size_t get () ;
};
}
然后用以下形式在 example.cppm 对其封装模块包装器。
module ;
#include <cstdint>
export module example;
#define IN_MODULE_WRAPPER
extern "C++" {
#include "header.h"
}
export module example;
import std;
#define IN_MODULE_WRAPPER
extern "C++" {
#include "header.h"
}
该形式下,在完成前期的安装后,后续在 example.cppm 中只需要在文件级维护即可,用导出宏在声明处控制不同声明的可见性,相比导出用风格,大大提高了可维护性。
example.cppm 中的 extern"C++" 很关键,它用来保护库的 ABI 一致。将 extern"C++" 去掉则变成更进一步 ABI 的破坏风格。
所以导出外"C++"风格也可当作为后续更激进的改造做准备。
此外相比于导出用风格,导出外"C++"风格在头文件存在内联全局函数和内联全局变量,特别是该变量存在动态初化时,可选择性内联,令其应用有更高的编译性能。
#pragma once
#include <cstdint>
namespace example {
inline int func () { return 43 ; }
inline int init () { return 43 ; }
inline int var = init ();
}
#pragma once
#ifndef IN_MODULE_WRAPPER
#include <cstdint>
#endif
#ifdef IN_MODULE_WRAPPER
#define EXPORT export
#else
#define EXPORT
#endif
#ifndef IN_MODULE_WRAPPER
#define INLINE inline
#else
#define INLINE
#endif
namespace example {
INLINE int func () { return 43 ; }
INLINE int init () { return 43 ; }
INLINE int var = init ();
}
example.cppm 实现不变,这可算按导出外"C++"风格更具维护性的一个侧面示例。
此时,example.cppm 的客户不会重新编译函数。
注意该改造其实更改了 ABI,让原先的弱符号变成了现在的强符号。
这在良好定义的项目,即不存在 ODR 违反的项目中没有关系。
但如果本来就有这几个符号的 ODR 违反,那此改造可能会改变现有行为。如果担心它,可修改 header.h 的实现为:
#pragma once
#ifndef IN_MODULE_WRAPPER
#include <cstdint>
#endif
#ifdef IN_MODULE_WRAPPER
#define EXPORT export
#else
#define EXPORT
#endif
#ifndef IN_MODULE_WRAPPER
#define INLINE inline
#else
#define INLINE __attribute__((weak))
#endif
namespace example {
INLINE int func () { return 43 ; }
INLINE int init () { return 43 ; }
INLINE int var = init ();
}
此时哪怕在模块中,example::func, example::init 和 example::var 依然会是一个弱符号。
这降低了触发本来 ODR 违反的可能性,但再次强调,若项目在这几个符号上本来就 ODR 违反状态,这样修改也只是降低了触发的可能性,依然可能会改变程序现有行为。
编译器忽略模块单元中的所有内联链接 这里,还想谈一下编译器中的处理。在实现过程中,有多人向我建议,编译器应该忽略模块单元的内联标识,直接生成强符号或对应的弱符号,而不是现在的内联链接。
这样可在模块中避免 C++ 早期的一些设计问题(按我理解,现在很多 ODR 问题的原因之一便是当年头文件的设计)。
但我还是觉得兼容非常重要,应该尽量把选择权留给用户。
导出外"C++"风格的 ABI 在 ABI 上,如果不重写上述内联,导出外"C++"风格的 ABI 应与导出用风格的 ABI 完全一致。