作者:许传奇
ABI破坏风格
以导出外"C++"风格为例,在example.cppm中去掉外"C++"就得到了ABI破坏风格的模块接口:
cpp
//`example.cppm`
export module example;
import std;
//及其他三方库的`模块`(如果有的话)
#define IN_MODULE_WRAPPER
#include "header.h"
去掉外"C++"后,header.h中的声明就属于示例模块了,和之前的包装器有本质区别.
此时示例模块中的header.h的声明不能复用前面src.cpp中的定义,需要为其提供新定义.
cpp
//`src.cpp`
#ifndef IN_MODULE_IMPL
#include "header.h"
#endif
std::size_t example::C::get() {
return 43 + inline_get();
}
//`src.module.cpp`
module example;
#define IN_MODULE_IMPL
#include "src.cpp"
此时按libexample.so链接src.cpp,src.module.cpp及example.cppm对应的目标文件,然后查看其导出的符号:
cpp
$llvm-nm -ACD libexample.so
libexample.so: w _ITM_deregisterTMCloneTable
libexample.so: w _ITM_registerTMCloneTable
libexample.so: 0000000000001060 T initializer for module example
libexample.so: 0000000000001150 W example::C::inline_get()
libexample.so: 0000000000001130 T example::C::get()
libexample.so: 0000000000001180 T example::C@example::inline_get()
libexample.so: 0000000000001160 T example::C@example::get()
libexample.so: w __cxa_finalize@GLIBC_2.2.5
libexample.so: w __gmon_start__
可见libexample.so中同时存在example::C::inline_get()和example::C@example::inline_get()及example::C::get()和example::C@example::get()两套ABI.
所以这也可叫双ABIMode.这类似GCC5libstdc++在C++11上那次有名的ABI破坏.
虽然库自身依然兼容两套ABI,但对你的库用户来说,当其选择了使用基于模块的ABI后,他的ABI也会破坏.如你的用户代码中存在:
cpp
#include "header.h"
namespace user {
void user_def(example::C& c) {
}
}
然后你的用户的libuser.so暴露的符号可能如下:
cpp
$llvm-nm -ACD libuser.so
libuser.so: w _ITM_deregisterTMCloneTable
libuser.so: w _ITM_registerTMCloneTable
libuser.so: 0000000000001100 T user::user_def(example::C&)
libuser.so: w __cxa_finalize@GLIBC_2.2.5
libuser.so: w __gmon_start__
而当用户选择使用你提供的ABI破坏风格的模块后:
cpp
import example;
namespace user {
void user_def(example::C& c) {
}
}
其对应的ABI变成了
cpp
$llvm-nm -ACD libuser.so
libuser.so: w _ITM_deregisterTMCloneTable
libuser.so: w _ITM_registerTMCloneTable
libuser.so: 0000000000001100 T user::user_def(example::C@example&)
libuser.so: w __cxa_finalize@GLIBC_2.2.5
libuser.so: w __gmon_start__
可见用户代码中user_def生成的符号(解混杂后)从user::user_def(example::C&)变为了user::user_def(example::C@example&).
该现象和GCC5libstdc++在C++11上的ABI破坏也是一样的.不过这里的ABI破坏是由用户自己控制的,所以不必担心.
ABI破坏风格相比于导出外"C++"风格,除了ABI变化外,其生成的代码对编译器而言也更有效率.
如前,类内内联函数在命名模块中不再是隐式内联的.如示例:
cpp
//`header.h`
#pragma once
#include <cstdint>
namespace example {
class C {
public:
std::size_t inline_get() { return 42; }
std::size_t get();
};
}
现在双ABI中生成的符号是:
cpp
$llvm-nm -ACD libexample.so
libexample.so: w _ITM_deregisterTMCloneTable
libexample.so: w _ITM_registerTMCloneTable
libexample.so: 0000000000001060 T initializer for module example
libexample.so: 0000000000001150 W example::C::inline_get()
libexample.so: 0000000000001130 T example::C::get()
libexample.so: 0000000000001180 T example::C@example::inline_get()
libexample.so: 0000000000001160 T example::C@example::get()
libexample.so: w __cxa_finalize@GLIBC_2.2.5
libexample.so: w __gmon_start__
可见C::inline_get在传统ABI中是一个弱符号,而在模块里就变成了Texample::C@example::inline_get()强符号.
此外像上述的用宏控制头文件中的内联实例的技巧在此也有帮助.
ABI破坏风格的另一个好处是,现在用户在不知情时想要混用你项目的#include和导入更困难了.如,如果用户无意间写出了此代码:
cpp
#include "header.h"
import example;
namespace user {
void user_def(example::C& c) {
}
}
编译器会自动提示:
cpp
$clang++ -std=c++23 -fPIC user.cpp -c -o user.o -fprebuilt-module-path=.
有问题,问题略.
这对帮助用户用更佳的实践也有帮助.
选择什么方式来为你的头文件库提供模块接口
首先根据期望,你的库之后是否会出现基本的ABI破坏更改,特别是你是否准备为你的库引入模块相关的ABI破坏更改.
如果是,在你可接受为所有实现文件都提供对应的模块支持时,ABI破坏更改是最好的.当然你完全不关心ABI,那ABI破坏更改在你可接受对所有实现文件都提供对应模块版本时也是最好的.
其次,如果你关心ABI,或不想为库的所有实现文件都提供的模块版本,那你可选择导出用风格或导出外"C++"风格,根据想如何控制符号的可见性.
最后,建议在所有头文件中,用#ifdef宏控制所有的第三方库(包括标准库)的#include,这样可节约很多时间.
为你的模块包装器选择/提供一个ABI物主
如上,所有模块单元都会在目标文件中生成至少一个初化器,此时就需要考虑该把该包含初化器的目标文件放于何处.
对自身就会分发二进制的库/项目而言,把模块单元的目标文件直接封装在二进制中是最简单的.如果不分发二进制,就需要在构建脚本中说明该如何构建该目标文件并放在什么库中.
然后用户可根据该构建脚本构建和链接.
之前有一些按C++20模块转换仅头项目.还有人引入了仅接口的概念.但我感觉太复杂了.
C++20命名模块单元本质只是可导入的翻译单元而言.在二进制层面,C++20命名模块单元和一般的翻译单元是一样的.
即一个项目原先是仅头的,引入了C++20命名模块后,该项目自身在二进制层面就应该是相应模块单元的物主.
如async_simple一般是一个仅头的库.async_simple也提供了C++20模块接口:async_simple.cppm.
但用户希望使用async_simple提供的模块时,需要用额外的CMake选项ASYNC_SIMPLE_BUILD_MODULES,从头构建包含C++20模块的libasync_simple:
cpp
message(STATUS "Fetching async_simple")
# 下载并集成 async_simple
FetchContent_Declare(
async_simple
GIT_REPOSITORY https://github.com/alibaba/async_simple.git
GIT_TAG f376f197e54d4921a7f0d8e40ad303e41018f7c2
)
set(ASYNC_SIMPLE_ENABLE_TESTS OFF CACHE INTERNAL "")
set(ASYNC_SIMPLE_DISABLE_AIO ON CACHE INTERNAL "")
set(ASYNC_SIMPLE_BUILD_DEMO_EXAMPLE OFF CACHE INTERNAL "")
set(ASYNC_SIMPLE_ENABLE_ASAN OFF CACHE INTERNAL "")
set(ASYNC_SIMPLE_BUILD_MODULES ON CACHE INTERNAL "")
FetchContent_MakeAvailable(async_simple)
(https://github.com/ChuanqiXu9/socks_server/blob/main/CMakeLists.txt)
即对仅头库,为了兼容,可用CMake选项的选入的打开模块能力,保证此库默认依然是仅头的库.
但当用户需要C++20模块时,用户要可从源码构建C++20模块所对应的版本的库.
模块本地
一些背景知识可在此查看:https://clang.llvm.org/docs/StandardCPlusPlusModules.html#background-and-terminology,这里
一个项目只声明一个模块,需要有多个TU时使用模块分块单元
如此结构:
cpp
.
├── common.h
├── network.h
└── util.h
想把它改造为模块时,不应把它改造为commonmodule,networkmodule及utilmodule.这样引入了三个模块,而且这样命名还很容易重名.
应该只为项目引入一个模块,暂且称其为示例模块.然后将common.h,network.h和util.h改造后声明为example:common,example:network和example:util三个模块单元.
此改造方式有两个好处:
1,直接避免前向声明问题.
2,可更好的控制符号可见性.
前向声明问题,当前网上模块,抛开工具链,在语言层面讲最多的就是前向声明问题了.
如C++模块和循环类引用.但如果把当前该项目/库看作是一个模块的话就不会有它了.这在逻辑上也非常合理,你的库自身就是一个内聚的模块.
这里
符号可见性.在模块前,分发二进制时,使用-fvisibility=hidden -fvisibility-inlines-hidden将按隐藏标记所有符号,只对希望对外可见的声明标记__attribute__((visibility("default"))),如:
cpp
void __attribute__((visibility("default"))) Exported()
{
//...
}
在引入模块后,会将这两者关联在一起.如Clang中的该问题.即要求编译器提供一个选项,不要按隐藏设置默认导出的符号.
但即使编译器无此选项,在用户视角下提供此宏也是很合理,自然的:
cpp
#define EXPORT export __attribute__((visibility("default")))
EXPORT void Exported() {}
而可以这样的前提,则是一个库为一个模块.此时的导出含义即是对库外可见.而当为每一个头文件都声明一个模块时,这些模块中导出的含义变为了对其他文件可见.
这样导出就失去了库层面的可见性的含义.
使用模块实现分块单元而不是模块实现单元来实现接口
当在一个库中只使用一个模块时,很可能会引入大量的模块接口单元.而此时如果使用模块实现单元来实现接口,如
cpp
//`network.cpp`
module example;
//将隐式导入示例,
//定义网络接口`...`
//`common.cpp`
module example;
//会隐式导入示例
//定义通用接口`...`
//`util.cpp`
module example;
//将隐式导入示例
//定义工具接口...
此时可以发现所有的*.cpp文件都依赖了示例模块的主接口.而一般,模块主要接口会依赖该模块的所有接口,如:
cpp
export module example;
export import :network;
export import :common;
export import :util;
这里问题是,当修改接口分块单元时,如network.cppm,因为依赖传导,会重编译所有的*.cpp文件,这里包括common.cpp和util.cpp.
这是不可接受的.尤其当项目中接口和实现文件数上升时,它实际上是不可接受的.
最好是使用模块实现分块单元来实现接口.如:
cpp
//`network.cpp`
module example:network;
//定义网络接口`...`
//`common.cpp`
module example:common;
//定义通用接口`...`
//`util.cpp`
module example:util;
//定义`工具接口...`
这样,起码模块下的文件级依赖起码比起头文件版本,不算是回归了.
不过实际上,对CMake的用户而言,该做法有个小问题,因为现在CMake要求所有模块实现分块单元都必须在CXX_MODULESFILES中,这导致CMake会为所有模块实现分块单元生成BMI.
但这只是在浪费时间.如上示例,network.cppcommon.cpp和util.cpp在设计上不会有任何其他单元导入他们,它是你可保证的,这也是意图.
但在CMake下,所有此模块实现分块单元都需要额外生成BMI,有额外成本.它在C++20模块有额外开销.
如果别人发现了类似时,很欢迎在该问题中注释.
使用模块实现分块单元编写单元测试
这一条是上一条(使用模块实现分块单元按实现文件)的自然放大.
问题是在单元测试时,常常需要测试项目内部的API,但这些API不一定是对外导出的.可在模块实现分块单元中编写单元测试来避免该可见性的问题.
因为在一个模块中所有模块级的声明都是可见的.
另一个小的点是,在模块实现分块单元中编写主函数时,需要加上外"C++".
之前的ISO标准认为主函数不应属于任何命名模块,而禁止了该用法,后来委员会修复了它,只需要加上外"C++"即可.
不过影响不大,因为据我所知编译器之前的行为也是符合期望的,最新版本的编译器可能会对命名模块中不在外"C++"``提示警告.
使用模块实现分块单元改写,不对外暴露的头文件
模块实现分块单元的怪点在,可以导入它.我在初次接触这块内容时,这让我很难理解模块实现分块单元和模块接口分块单元的区别.
我一开始以为模块实现分块单元对应传统头文件中的细节名字空间.但现在看这不对,传统头文件中的细节名字空间,实际上就是模块接口分块单元中没有被导出的部分.
cpp
//`detail.h`
namespace detail {
...
}
//`详细.CPPM`
export module example:detail;
//这里没有导出
namespace detail {
//...
}
而模块实现分块单元除了上述作为实现文件的用处外,在可导入时,角色更类似现在项目中不对外暴露的头文件.
以Clang为例(Clang/LLVM除编译器外自身也是个库),
cpp
clang
├── AreaTeamMembers.txt
├── bindings
├── cmake
├── CMakeLists.txt
├── docs
├── examples
├── include
├── INSTALL.txt
├── lib
├── LICENSE.TXT
├── Maintainers.rst
├── NOTES.txt
├── README.md
├── runtime
├── test
├── tools
├── 单元tests
├── utils
└── www
其中包含文件保存对外可见的头文件,而库中以实现文件为主,但在库中也存在头文件,如
cpp
clang/lib/Serialization/
├── ASTCommon.cpp
├── ASTCommon.h
├── ASTReader.cpp
├── ASTReaderDecl.cpp
├── ASTReaderInternals.h
├── ASTReaderStmt.cpp
├── ASTWriter.cpp
├── ASTWriterDecl.cpp
├── ASTWriterStmt.cpp
├── CMakeLists.txt
├── GeneratePCH.cpp
├── GlobalModuleIndex.cpp
├── InMemoryModuleCache.cpp
├── ModuleCache.cpp
├── ModuleFile.cpp
├── ModuleFileExtension.cpp
├── ModuleManager.cpp
├── MultiOnDiskHashTable.h
├── ObjectFilePCHContainerReader.cpp
├── PCHContainerOperations.cpp
├── TemplateArgumentHasher.cpp
└── TemplateArgumentHasher.h
像这里的ASTReaderInternals.h,MultiOnDiskHashTable.h和TemplateArgumentHasher.h都是只会在序化内使用的头文件.
这些头文件对Clang的库用户而言都是不可见的.如此类文件,就适合改造为模块实现分块单元.
或使用模块实现分块单元原则,任何不属于库的接口的文件,都使用模块实现分块单元.
即库的接口使用模块接口单元(包含模块主要接口单元和模块接口分块单元)和头文件(如果依然需要暴露宏),除此外都使用模块实现分块单元.
不要在模块接口中导入模块实现分块单元
在模块接口包含模块主要接口单元和模块接口分块单元)中不要导入模块实现分块单元.如
cpp
module example:impl;
//接口`.cppm`
export module example:interface;
import :impl;
现在编译该文件会提示.提示略.
这有两个原因:
1,不直接导入的模块实现分块单元不一定是可达的.
即必须指的是直接导入的TU或有接口依赖的TU.
即接口依赖指的是导入的模块接口单元及递归导入的模块接口单元.
总之,非直接导入的模块实现分块单元不一定是可达的.
它实际上很迷惑人.有多个Clang该的漏洞,但最后都被以无效的名义关掉了.所以为了进一步避免该迷惑,建议用户不要在模块接口中导入模块实现分块单元.
建议不要在模块接口中导入模块实现分块单元的另一个原因是可读性,这使得一个库的接口和实现的边界非常清楚.
不一定所有头文件或可导入模块单元都是接口.库的接口的边界应该是由精心设计的.
但对大规模项目而言接口边界,因为多人协同的关系,可能并没有很好的维护起来,可能很多头文件都被无脑的放入包含目录下最后渐渐变成了事实接口的一部分.
而在引入模块后,有了新的工具助手,写代码时可很清晰的看到当前TU是模块接口单元或是模块分块实现单元,从而判断当前编写的文件是否属于项目的接口.
这对可读性帮助是很大的.
模块实现分块单元小结
使用模块实现分块单元的原则是,任何不属于库的接口的文件,都使用模块实现分块单元.
如果其他模块单元可导入模块实现分块单元的话,则使用.cppm(.ccm)为后缀.否则,则按实现文件使用模块实现分块单元,以.cpp或.cc为后缀.
不要在模块接口中导入模块实现分块单元.
按实现文件时使用模块实现分块单元,CMake现在可能依然会编写BMI,这会导致额外的成本.
模块实现单元
那模块实现单元呢?在发现了模块实现分块单元作为实现文件的用法后,不建议在任何大型模块中使用模块实现单元.
我现在感觉模块实现单元只是个不太甜的语法糖,可帮助节约一行导入的空间,但引入的依赖太粗了.
对实际上TU本地的实体,积极使用匿名字空间或静.
如:
cpp
export module a;
struct A {};
在该TU中,a模块没有导出任何东西,可期望编译器在编译时优化掉A的声明吗?编译器不可以这样做,因为虽然这里未导出的声明对外不可见,但其对同一模块中的其他模块单元是可见的.
所以编译器依然需要BMI中完整地记录struct A的所有信息.
实际上,可能会忘记它.哪怕模块引入了导出关键字,对只在当前TU可见的实例,还是应该积极的使用使用匿名字空间或静标识.
这既可减少最终生成的二进制符号,也可减少BMI``体积.
BMI,编译器理论上可为主要模块接口生成两套BMI,一套在模块内部用,一套在模块外部用.
但这一方面需要编译器和构建系统协作,当前看编译器和构建系统的协作还是很困难.另一方面本文还是更关注于用户视角.所以不过多展开.
从模块包装器到模块本地
虽然这比较遥远,但依然可以想象,未来,会有一个提供模块包装器的库,希望真正的在模块中开发,而不是只提供包装器.此时有几个选项:
1,按模块接口全部转换头文件.然后将原先的头文件版本的库,放在单独的分支维护,或放在单独目录内冻结.
2,依然保留头文件,但只能在模块版本中使用声明部分的新特性.
其中第一个选项无论是手动修改还是依赖工具(如clang模块转换器)修改,在理解了该如何模块本地地编写代码后都比较直观.
这里
第2个选项,可先将原先的模块包装器重命名到当前模块一个分块,如:
cpp
export module example:header_interfaces;
import std;
//其他第三方模块(如果有的话)
#define IN_MODULE_WRAPPER
extern "C++" {
#include "header.h"
}
之后在其他分块内正常编写模块代码即可,要用到原先头文件中的接口时导入:header_interfaces即可.
最后在主要模块接口中导出header_interfaces:
cpp
export module example:
export import :header_interfaces;
export import :...;
//其他分块
这样,可保持原先头文件的前提下,使用C++20模块开发.
模块改造过程中的运行时问题
24年底到25年初,我曾经花了两个多月,按模块本地项目修改一个7MLoC的大规模C++项目.在改造开始前,我期望大部分时间可能会花在修复编译器``漏洞上.
但实际上真正花在编译器上的时间只有两个多星期,其余的大部分时间实际上是在查询改造后的运行时问题.这和期望不符.
我之前认为模块改造的大部分问题会在编译时,有问题应该就编不过,编过了就不应有问题.但实际还是不是这样的.
在大规模C++项目中ODR违反是普遍存在的,很多时候他只是工作.改造为模块后就可能触发不少.
这些问题的原因其实都很简单,但排查的过程很头疼.但换个角度想,模块改造的过程提升了项目的稳定性,发现了很多技术债.
性能
多人提及,现在命名模块不导出非内联的函数定义的方式对优化性能有损害的.该行为现在算标准委员会推荐的行为.主要动机是保证ABI的稳定性.
抛开ABI稳定性不谈,实际上发现,此做法结合thinLTO其实并没有造成任何可观测的性能损失(在多个项目中反而发现了略微的提升性能),反而带来了更快的编译速度.
如果编译器``平凡地在优化时导入一切可导入的函数的话,那每个TU的优化复杂度会从O(N)增长到O(N^2)(N指每个TU中函数的平均数量),这反而不太能接受.
总结
整体上C++20模块相比于其他C++的大特性而言,在语言特性角度还是很简单的.这里其实大部分内容在介绍如何兼容头文件时提供C++20模块及ABI相关内容.
如果不关心ABI或非常激进,愿意直接编写模块本地项目的话,遇见的语言方面的问题应该还是比较少的.