跳至内容

C++20 模块

了解模块为何替代头文件、如何声明和导出模块、模块分区以及从基于头文件的代码的过渡路径。

为什么模块替代头文件

#include 系统数十年来一直是 C++ 编译的基础,但它带来了严重的问题。头文件是文本包含的 ── 预处理器将整个文件拷贝到每个翻译单元中。这意味着 #include 可能会将 50,000 多行代码粘贴到每个 .cpp 文件中。结果是:编译缓慢、脆弱的包含顺序(一个头文件的宏影响另一个头文件)以及单一定义规则(ODR)违规导致的静默未定义行为。

C++20 模块解决了所有这些问题。模块被编译一次为二进制接口,然后消费者导入时无需重新解析。没有文本包含、没有宏泄露、没有包含顺序依赖。大型项目的构建时间可以提升 5-20 倍。

模块声明与导出

模块有一个接口单元(声明导出内容)和一个或多个可选的实现单元。export 关键字使声明对导入者可见:

math_utils.cppm
// math_utils.cppm — module interface unit
export module math_utils;    // module declaration

import <cmath>;               // import a header unit

// Exported — visible to importers
export double circle_area(double radius) {
    return std::numbers::pi * radius * radius;
}

export struct Point {
    double x, y;

    double distance_to(const Point& other) const {
        double dx = x - other.x;
        double dy = y - other.y;
        return std::sqrt(dx * dx + dy * dy);
    }
};

// NOT exported — module-private (internal linkage)
double helper_function(double val) {
    return val * val;
}

// Export a namespace — all members become visible
export namespace geometry {
    double triangle_area(double base, double height) {
        return 0.5 * base * height;
    }

    double rectangle_area(double width, double height) {
        return width * height;
    }
}

导入模块

消费者使用 import 来访问模块的导出声明。与 #include 不同,import 不执行文本包含 ── 宏和实现细节不会跨模块边界泄露:

main.cpp
// main.cpp — imports the module
import math_utils;

#include <iostream>  // can still use #include for non-modularized headers

int main() {
    double area = circle_area(5.0);
    std::cout << "Circle area: " << area << "\n";

    Point p1{0.0, 0.0};
    Point p2{3.0, 4.0};
    std::cout << "Distance: " << p1.distance_to(p2) << "\n";  // 5.0

    std::cout << "Triangle: " << geometry::triangle_area(10, 5) << "\n";

    // helper_function(42);  // ERROR: not exported, not visible

    return 0;
}

// Compile order matters for modules:
// 1. Compile the module interface first:
//    g++ -std=c++20 -fmodules-ts -c math_utils.cppm
// 2. Then compile the consumer:
//    g++ -std=c++20 -fmodules-ts main.cpp -o main

模块分区

大型模块可以被拆分为分区 ── 共同组成完整模块接口的子模块。分区是模块内部的,不能被外部消费者直接导入:

module_partitions.cppm
// --- math_utils-algebra.cppm (partition interface) ---
export module math_utils:algebra;

export double square(double x) { return x * x; }
export double cube(double x) { return x * x * x; }

// --- math_utils-trig.cppm (partition interface) ---
export module math_utils:trig;

import <cmath>;

export double deg_to_rad(double degrees) {
    return degrees * std::numbers::pi / 180.0;
}

export double sin_deg(double degrees) {
    return std::sin(deg_to_rad(degrees));
}

// --- math_utils.cppm (primary module interface) ---
export module math_utils;

// Re-export partitions — consumers see everything
export import :algebra;
export import :trig;

// Additional exports can go here
export double hypotenuse(double a, double b) {
    return std::sqrt(square(a) + square(b));
}

// --- Consumer ---
// import math_utils;  // gets algebra, trig, and hypotenuse

import std(C++23)

C++23 引入了 import std;,它将整个标准库作为模块导入。这是编译速度的颠覆性改进:

import std;  // Everything: iostream, vector, string, algorithm, ...

int main() {
    std::println("Hello from C++23 modules!");
    std::vector<int> v{1, 2, 3};
    std::ranges::sort(v);
}

import std; 用单个编译速度大幅提升的导入替代了数十个 #include 指令,因为标准库模块是预编译的。还有 import std.compat;,它额外在全局命名空间中提供 C 库名称(如 printf、strlen)。

常见陷阱

编译器支持仍在成熟中:截至 2024 年,GCC、Clang 和 MSVC 都支持模块,但完整程度和构建系统集成各不相同。MSVC 的支持最为成熟。

构建系统集成:CMake 3.28+ 通过 CXX_MODULE_SETS 特性支持模块,但生态系统(依赖扫描器、包管理器)仍在追赶中。构建顺序现在很重要 ── 模块接口必须在其消费者之前编译。

无宏导出:模块故意不导出 #define 宏。依赖头文件中宏的代码(如 WIN32_LEAN_AND_MEAN 或 NOMINMAX)必须以不同方式处理。

迁移策略:从 import std;(C++23)开始替代标准库的 include,然后从依赖较少的叶子库开始逐步模块化你自己的代码。

最佳实践

采用模块时:

1. 保持模块接口精简 ── 只导出消费者需要的内容。未导出的实体具有模块链接性,不会导致 ODR 违规。
2. 对大型模块使用分区 ── 拆分为逻辑子模块(例如 :types、:algorithms、:io)。
3. 优先使用 export namespace ── 导出整个命名空间而不是单独的声明,以保持接口的组织性。
4. 不要对同一个库混用 #include 和 import ── 在同一翻译单元中选择一种并保持一致。
5. 对模块接口文件使用 .cppm 或 .ixx 扩展名 以便构建系统能自动识别它们。

关键要点
  • 模块用 import 替代 #include ── 无文本包含、无宏泄露、无包含顺序 bug
  • 使用 export 使声明对导入者可见;未导出的实体是模块私有的
  • 模块分区将大型模块拆分为子组件,同时呈现统一的接口
  • import std;(C++23)用单个快速导入替代所有标准库 include
  • 构建系统必须在消费者之前编译模块接口 ── 编译顺序很重要

测验 — 检验你的知识

(15 经验值)

1. 模块相对于 #include 的主要优势是什么?

2. 在模块中定义但未标记 `export` 的函数会怎样?

3. C++23 中 `import std;` 做了什么?