dokee's site

Back

C++ modules 折腾记Blur image

介绍#

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
plaintext
xmake.lua
add_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)
lua
main.cc
import hello;

int main() {
    hello();
}
cpp
hello.mpp
export module hello;

import std;

export void hello() {
    std::println("Hello World");
}
cpp

之后使用 xmake 构建和运行. 在那之前你需要指定 toolchainruntimes 两个配置:

shell
xmake f --toolchain=llvm --runtimes=c++_shared
xmake && xmake run
shell

不出意外, 你就可以成功得到 “Hello World” 的输出了.

在这个示例中, 我们可以看到我们使用了一个新的文件拓展名 mpp, 不过里面的内容和常规的 cccpp 文件并没有什么不同, 你也可以取名为 ixxcppm 等, 只是一种不同的习惯罢了.

在代码中, 不难理解 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 插件进行构建时会覆盖之前配置的 toolchainruntimes, 个人推荐只使用命令行进行构建. 此外我也建议禁用 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 文件:

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;
}
cpp
hello.mpp
export 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.mppimport 就可以让 hello 模块使用了. 而 main.cc 导入 hello 模块时, 并不能看到 say 模块中的内容. 如果想要让 main.cc 导入 hello 模块中的内容时, 则需要将其再导出:

export import say;
cpp

全局模块片段#

一个模块单元只能有一个模块声明, 但是可以附带一个全局模块片段 (global module fragment). 这是因为我们在模块单元里不能使用 #include, 需要把它们放到全局模块片段中. 我们知道 #include 是把被包含的标头拷贝到文件中, 这样标头中的东西就变成了当前模块的一部分, 这显然是不合适的, 因此我们需要把它们放到全局模块片段中, 表明它是 “全局” 的东西.

我们使用 module; 来标记全局模块片段的开始. 在全局模块片段中只能出现预处理指令, 然后用一条标准的模块声明标记这个全局模块片段的结束, 后面就是模块内容.

再次修改我们的小工程, 它需要使用一个名为 say.hpp 的标头:

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
cpp
hello.mpp
export module hello;

import std;

export {
void hello();
void ciallo();
}
cpp
hello.cc
module;

#define MOE
#include "say.hpp"

module hello;

void hello() {
#ifndef DUMB
    say("Hello World");
#endif
}

void ciallo() {
    say("Ciallo~(∠・ω< )⌒☆");
}
cpp
main.cc
import hello;

int main() {
#ifndef DUMB
    hello();
    ciallo();
#endif
}
cpp

执行一下, 分析一下你可以发现, 模块中的宏是不会被导出到模块外的, 所以宏隔离也是模块的一大优点. 即使将宏放到 export {} 也不会泄漏到模块外面. 另外全局模块片段也可以在接口单元中使用. 全局模块片段在无法导入标头时 (尤其是在标头用预处理宏进行配置时) 特别有用.

模块分区#

哇, 我们的工程代码量好大啊! 让我们把它拆分一下:

hello-say.cc
module hello:say;

import std;

void say(const std::string_view words) {
    std::cout << words << std::endl;
}
cpp
hello-en.mpp
export module hello:en;

import :say;

export void hello() {
    say("Hello");
}
cpp
hello-yuzu.mpp
export module hello:yuzu;

import :say;

export void ciallo() {
    say("Ciallo~(∠・ω< )⌒☆");
}
cpp
hello.mpp
export module hello;

export import :en;
export import :yuzu;
cpp

上面的 :xxx 就代表了模块的一个分区. 可以看到, 模块分区也可以分为模块接口分区和模块实现分区.

你可能还见过 A.B 的形式, 但 A.B 模块和 A 模块没有任何语法上的关系, 用户需要自己维护它们的关系.

总结#

一番折腾下来, 觉得模块的逻辑和传统的标头还是挺不一样的, 而且有些地方还是比较难以理解, 不是很符合直觉. 我也稍微看了一下几个使用模块特性的大型项目, 他们的代码组织形式也各不相同. 只看些文章感觉理解地还是不够好, 可能还有许多坑没有注意到. 之后新写项目的时候尝试用用它吧.

References#

C++ modules 折腾记
https://astro-pure.js.org/blog/c/c-modules
Author dokee
Published at March 1, 2025
Comment seems to stuck. Try to refresh?✨