

介绍#
C++20 引入了 modules 这个组织代码的新方法, 它有着诸如更好的接口隔离和大大加快编译速度等优点. 不过对我个人来说, 最大的优点是: 不觉得 import std;
很酷吗 XD.
御三家中 MSVC 对 modules 的支持是最好的. 但是 btw i use arch 所以自然是不考虑了. 剩下的两家中 clang 支持得比较好, GCC 暂时还不支持 C++23 的 import std;
, 所以这次我们选择使用 clang 来尝试一下 modules. 此外, clang 的 modules 暂时还只支持自家的 libc++
, 所以你可能需要先安装它:
sudo pacman -S libc++
sh此外, 我们使用 xmake ↗ 作为构建工具, Neovim 和 VSCode 都有对应的插件.
简单工程示例#
我们先创建一个最简单的示例工程:
.
├── src
│ ├── hello.mpp
│ └── main.cc
└── xmake.lua
plaintextadd_rules("plugin.compile_commands.autoupdate", { outputdir = "build" })
add_rules("mode.debug")
target("cpp-learn")
set_kind("binary")
set_languages("cxx26")
add_files("src/*.cc", "src/*.mpp")
set_policy("build.c++.modules", true)
luaimport hello;
int main() {
hello();
}
cppexport module hello;
import std;
export void hello() {
std::println("Hello World");
}
cpp之后使用 xmake 构建和运行. 在那之前你需要指定 toolchain
和 runtimes
两个配置:
xmake f --toolchain=llvm --runtimes=c++_shared
xmake && xmake run
shell不出意外, 你就可以成功得到 “Hello World” 的输出了.
在这个示例中, 我们可以看到我们使用了一个新的文件拓展名 mpp
, 不过里面的内容和常规的 cc
或 cpp
文件并没有什么不同, 你也可以取名为 ixx
或 cppm
等, 只是一种不同的习惯罢了.
在代码中, 不难理解 import xxx
的作用是导入一个模块. 而 export module xxx
的作用则是声明主模块接口单元 (declares the primary module interface unit), 也就是说, 只要文件开头有一个模块单元声明, 那么这个文件就会被认为是一个模块单元. 而 export module xxx
正是模块单元中的一种.
在主模块接口单元中的函数声明或类声明等, 只有在前面加上 export
才可以在模块外可见, 即其他文件 import
该模块只能使用带有 export
修饰的内容. 当然, 你也可以使用 export {}
将所有需要供别人使用的全部括起来, 这样就不用一个一个写 export
了.
这就是一个最简单的使用 modules 特性的工程. 在深入了解 modules 之前, 我们先聊一些代码之外的内容.
IDE 配置#
你可能已经注意到了 IDE 对 C++ modules 的支持并不完善, 没有高亮, 没有补全, 各种报错, 简直就是地狱. 不过其实只需要简单配置一下就可以了. 在这里我主要讲 Neovim 的配置, VSCode 我尝试过也是可以的.
首先, IDE 可能并不知道 mpp
文件是一个 C++ 文件. 在 VSCode 中, 只需要在右下角选择就可以了. 而在 Neovim 中, 你需要在配置文件中添加如下的命令:
vim.api.nvim_create_autocmd(
{ "BufRead", "BufNewFile" },
{
pattern = { "*.mpp", "*.cppm", "*.ixx" },
callback = function()
vim.bo.filetype = "cpp"
end
})
lua然后, 你需要配置 clangd 对 modules 的实验性支持, 在 clangd 命令的启动参数中添加 --experimental-modules-support
. 另外和正常的 C++ 工程一样, 你需要给 clangd 提供 compile_commands.json
, 我习惯放在 build
文件夹中, 我个人的配置为:
cmd = {
"/usr/bin/clangd",
"--header-insertion=never",
"--experimental-modules-support",
},
lua这样之后, 只要 clangd 能正确找到生成的 compile_commands.json
文件, 代码高亮和补全就应该可以正常工作了. 如果不对, 可以尝试重启 LSP, 重新 compile_commands.json
, xmake clean && xmake
, 删除 .cache
等方法.
顺带一提, 使用 VSCode 的 xmake 插件进行构建时会覆盖之前配置的 toolchain
和 runtimes
, 个人推荐只使用命令行进行构建. 此外我也建议禁用 VSCode 和 Neovim 中 xmake 插件的自动生成 compile_commands.json
的功能, 在 xmake.lua
文件中指定 compile_commands.json
文件的位置即可.
另外, 你可能会发现 export module import
关键字没有高亮, 我们需要指定它们对应的高亮组. 创建文件 ~/.config/nvim/after/queries/cpp/highlights.scm
:
;extends
(module_name
(identifier) @module)
[
"import"
"export"
"module"
] @keyword.import.cpp
lisp这样我们就指定了它们的高亮组了. 你可以把它们绑定到其它的高亮组上, 你可以在这里 ↗找到所有高亮组的名称, 你也可以使用 :Inspect
和 :InspectTree
自己探索一下, 或者使用自己取的名称. 之后你可以选择在你的主题插件中对高亮组颜色进行自定义的配置, 或者直接使用命令进行配置, 例如 vim.cmd("highlight @module guifg=" .. "#35c9bb")
.
接口与实现分离#
现在我们扩充一下我们的小工程, 在之前工程的基础上改为接口与实现分离的形式. 我们可以添加一个 hello.cc
文件:
module hello;
void hello::hello() {
say("Hello World");
}
void hello::ciallo() {
say("Ciallo~(∠・ω< )⌒☆");
}
void hello::say(const std::string_view words) {
std::cout << words << std::endl;
}
cppexport module hello;
import std;
export namespace hello {
void hello();
}
namespace hello {
export void ciallo();
void say(const std::string_view words);
}
cpp这里 module xxx
的作用就是声明模块实现单元 (declares a module implementation unit), 即表示该文件是模块 xxx
的实现单元. 一个模块可以有多个实现单元, 但是接口单元只能有一个.
在模块中, 我们同样可以使用 namespace
, 但两者之间没有必然的联系. 在这个示例中, 只有 say()
这个函数没有被 export
标记, 所以它在模块外是不可见的.
另外, 与头文件类似, 我们在接口单元文件中 import std
后就不需要在实现文件中重复 import
了. 可以说接口单元就是模块中的的头文件, 只不过这个 “include” 是编译器帮咱做的.
同样地, 我们可以把 say()
函数分离出去成为 say
模块, 然后在 hello.mpp
中 import
就可以让 hello
模块使用了. 而 main.cc
导入 hello
模块时, 并不能看到 say
模块中的内容. 如果想要让 main.cc
导入 hello
模块中的内容时, 则需要将其再导出:
export import say;
cpp全局模块片段#
一个模块单元只能有一个模块声明, 但是可以附带一个全局模块片段 (global module fragment). 这是因为我们在模块单元里不能使用 #include
, 需要把它们放到全局模块片段中. 我们知道 #include
是把被包含的标头拷贝到文件中, 这样标头中的东西就变成了当前模块的一部分, 这显然是不合适的, 因此我们需要把它们放到全局模块片段中, 表明它是 “全局” 的东西.
我们使用 module;
来标记全局模块片段的开始. 在全局模块片段中只能出现预处理指令, 然后用一条标准的模块声明标记这个全局模块片段的结束, 后面就是模块内容.
再次修改我们的小工程, 它需要使用一个名为 say.hpp
的标头:
#pragma once
#include <string_view>
#include <iostream>
inline void say(const std::string_view words) {
#ifdef MOE
std::cout << words << " nya~" << std::endl;
#else
std::cout << words << std::endl;
#endif
}
#define DUMB
cppexport module hello;
import std;
export {
void hello();
void ciallo();
}
cppmodule;
#define MOE
#include "say.hpp"
module hello;
void hello() {
#ifndef DUMB
say("Hello World");
#endif
}
void ciallo() {
say("Ciallo~(∠・ω< )⌒☆");
}
cppimport hello;
int main() {
#ifndef DUMB
hello();
ciallo();
#endif
}
cpp执行一下, 分析一下你可以发现, 模块中的宏是不会被导出到模块外的, 所以宏隔离也是模块的一大优点. 即使将宏放到 export {}
也不会泄漏到模块外面. 另外全局模块片段也可以在接口单元中使用. 全局模块片段在无法导入标头时 (尤其是在标头用预处理宏进行配置时) 特别有用.
模块分区#
哇, 我们的工程代码量好大啊! 让我们把它拆分一下:
module hello:say;
import std;
void say(const std::string_view words) {
std::cout << words << std::endl;
}
cppexport module hello:en;
import :say;
export void hello() {
say("Hello");
}
cppexport module hello:yuzu;
import :say;
export void ciallo() {
say("Ciallo~(∠・ω< )⌒☆");
}
cppexport module hello;
export import :en;
export import :yuzu;
cpp上面的 :xxx
就代表了模块的一个分区. 可以看到, 模块分区也可以分为模块接口分区和模块实现分区.
你可能还见过 A.B
的形式, 但 A.B
模块和 A
模块没有任何语法上的关系, 用户需要自己维护它们的关系.
总结#
一番折腾下来, 觉得模块的逻辑和传统的标头还是挺不一样的, 而且有些地方还是比较难以理解, 不是很符合直觉. 我也稍微看了一下几个使用模块特性的大型项目, 他们的代码组织形式也各不相同. 只看些文章感觉理解地还是不够好, 可能还有许多坑没有注意到. 之后新写项目的时候尝试用用它吧.
References#
- Modules (since C++20) - cppreference.com ↗
- How to make clangd support more file types - Language Server Protocol (LSP) - Neovim Discourse ↗
- 如何自定义 neovim treesitter 的高亮_哔哩哔哩_bilibili ↗
- 现代C++基础-06-Modules_哔哩哔哩_bilibili ↗
- purecpp - a cool open source modern c++ community ↗
- Understanding C++ Modules ↗
- Are We Modules Yet? ↗