摘要訊息 : 一個跨時代的全新 C++ 檔案翻譯方式.

0. 前言

C++ 20 之前的程式碼處理中, 編碼器在處理 #include 時並不會處理重複的內容. 例如在 a.hpp 中引入了 <iostream> 標頭檔, 在 b.hpp 中也引入了 <iostream> 標頭檔, 那麼編碼器會同時將幾萬程式碼引入 a.hppb.hpp 中. 這直接導致一個問題, 就是編碼速度慢, 特別是針對大型項目.

名稱污染這個問題也是 C++ 中經常被人提到的, 在 C++ 20 之前名稱隔離是無法真正做到的. 本人在小型項目 data_structure 中定義了一個全域的名稱空間 data_structure, 並且在其內部定義了一個非內嵌的巢狀名稱空間 __data_structure_auxiliary. __data_structure_auxiliary 這個名稱空間中都是程式庫內部實作的內容, 都是我不希望外界使用的, 而僅供程式庫作者也就是我自己使用. 雖然使用名稱空間 data_structure 時, 程式庫使用者幾乎不太可能接觸到 __data_structure_auxiliary 中的內容, 但是一旦寫出 data_structure:: 這樣的宣告, 一部分 IDE 可能會把 __data_structure_auxiliary 中的內容暴露給外界. 所以, 儘管我使用了名稱空間對內部實作進行了隔離, 但是實際上是沒有辦法做到真正隔離的.

在 C++ 中, 名稱空間, 全域變數和巨集名稱都是共享的. 特別是針對巨集名稱而言, 不同的 #include 順序可能導致不同的含義. 例如, 在 a.hpp#define A foo, 在 b.hpp#define A bar, 如果先 #include "a.hpp"#include "b.hpp", 那麼 A 會被替換為 bar; 反之, A 會被替換為 foo.

為了解決上面這些問題, C++ 20 引入了 Module.

本文的目錄過長, 可能影響前面章節的閱讀體驗, 故本篇文章的目錄預設為隱藏不展開狀態, 需要閣下手動展開.

更新紀錄 :

  • 2022 年 6 月 11 日進行第一次更新和修正.

1. 基本概念

在學習使用 Module 進行實作之前, 首先要了解其基本概念. 需要特別指出的是, 在學會宣告模組和匯入模組之後, 我們並不能直接讓這些程式碼通過編碼. 因為 Clang 和 GCC 這兩個編碼器針對帶有模組的程式碼採用了不同方式進行編碼, 因此閣下只有在閱讀第 1.5 節之後, 才能嘗試編碼帶有模組的程式碼.

1.1 關鍵字

Module 中使用到了幾個關鍵字 : export, module, importprivate. exportprivate 本身就是 C++ 中存在的關鍵字, 儘管 export 在 C++ 11 中被移除, 但是仍然被保留為關鍵字 (register 在 C++ 17 中被移除, 但是也仍然保留為關鍵字). 但是 moduleimport 並不是歷來就存在的, 因此它們在特定語義下才會稱為關鍵字, 類似於 C++ 11 引入的 finaloverride, 這是為了和舊的程式碼相容.

雖然 C++ 允許 moduleimport 成為名稱, 但是不建議大家這樣去做. 對於歷史項目, 也建議大家修改這些用法. C++ 11 引入的 finaloverride 也是同理.

1.2 宣告模組

一個模組可以宣告為 module module_name [[attribution]];. 其中, module_name 是模組的名稱, [[attribution]] 是可選的屬性. 一般來說, 模組的宣告都是放在一個檔案的頭部. 模組宣告之後, 所有的名稱都劃入該模組之下. 這就好比我們宣告一個名稱空間之後, 在大括號中的所有名稱都被劃入該名稱空間中. 模組的名稱劃入是以檔案為單位, 名稱空間的名稱劃入是以大括號為單位. 在預設情況下, 模組中的名稱都不對外公開, 也就是其它檔案預設情況下無法存取到某個模組中的沒有被公開的名稱, 哪怕已經匯入了這個模組. 這就解決了第 0 節中名稱隔離的問題. 因此, 在這樣宣告模組的情況下, 我們稱 module_name 為一個實作單元.

一個檔案之內存在且僅能存在一個模組, 不能宣告多個模組 :

module A;
void func() {}
module B;        // Error : translation unit contains multiple module declarations

編碼錯誤中的 translation unit 是指一個實作檔案, 例如以 .cpp 為後綴的檔案.

一個模組必須對外可見, 因此僅使用 module MODULE_NAME; 宣告模組是不被允許的, 它必須還要被可匯出才行 : export module MODULE_NAME;. 而且, 一個模組必須在第一次被宣告的時候就被匯出. 如果一個模組不允許被匯出, 那麼它沒有任何存在的意義. 也就是說, Code 1 中還存在另外一個錯誤, 我們還要將 module A; 改為 export module A;. 但是有一種情形是例外的, 就是採用分離實作的時候, 如果已經在分離宣告檔案中宣告並且匯出了模組, 那麼就不需要在實作檔案中再次匯出模組 :

export module A;        // 宣告並且匯出模組 A

// ...
module A;       // 不需要且不能再次 export

int func() {
    return 0;
}

A.cpp 中, 模組 A 已經被宣告過了, 因此沒有必要再為其增加一個 export. 強行增加一個 export 會導致編碼錯誤, 編碼器會告訴你模組 A 已經被 export 標記過了. module A; 也表明接下來的內容全部歸入模組 A 中. 預設情況下, 函式 func 是不對外可見的, 因此如果在 main 函式中直接呼叫 func 會產生編碼錯誤.

如果直接在 A.cpp 中宣告模組會導致 A.cpp 被當作為介面單元 (interface unit), 即存在 export module module_name; 這樣陳述式的檔案. 而使用分離的方法實作模組 A 則有些不同, 我們稱 A.hpp 為介面單元, 而 A.cpp 被稱為實作單元 (implementation unit).

1.3 匯入模組

匯入其實有三種形式, 一種是針對模組的, 另外兩種是針對標頭檔的 :

  • import module_name [[attribution]];,
  • import <library_header_name> [[attribution]];,
  • import "header_name" [[attribution]];.

其中, module_name 是模組的名稱, library_header_name 是程式庫標頭檔名稱, header_name 是標頭檔名稱, [[attribution]] 是可選的屬性. 這裡需要注意的是, #include 並不需要以分號結尾, 而 import 不是前處理指令, 因此需要分號結尾. 我們將匯入的標頭檔稱為標頭檔單元 (header unit).

例如, import A; 是指匯入名稱為 A 的模組中對外公開的那一部分名稱, import <iostream>;import "test.hpp"; 實際上類似於 #include 前處理指令. 在 C++ 20 中, 由於 <iostream> 暫時沒有模組化, 所以對其使用 import 匯入就相當於對其使用 #include 匯入.

當一個檔案稱為模組單元 (包含 module 宣告) 的時候, 它就不能再使用 import "module_name.hpp"; 的方法匯入, 這樣會導致編碼錯誤, 只能通過 import module_name; 這一種方式匯入. 另外, 遞迴地匯入是不被允許的 :

export module A;
import B;

// ...
export module B;
import C;

// ...
export module C;
import A;       // Error : cyclic interface dependency : A->B->C->A->...

// ...

當匯入一個模組或者標頭檔之後, 其內部所有可匯入的內容在當前模組下都可見. import 宣告應該儘量靠前, 至少要在實作部分或者宣告部分之上. 這一點和 #include 實際上是類似的.

在實作單元宣告模組之後, 無需再引入模組本身, 編碼器會自動幫你隱含地引入. 如果明確引入模組本身會導致編碼錯誤 :

export module A;

// ...
module A;
import A;       // Error : @import of module 'A' in implementation of 'A'

//...

1.4 匯出名稱

如果我們想要讓某個名稱對外可見, 那麼我們需要在這個名稱之前增加 export 標識, 就像匯出模組那樣 :

export module A;

export auto a {0};
export int func() {
    return a;
}
export enum E {
    ZERO, ONE, TWO, THREE
};
export class foo {
public:
    int a;
};
export namespace name {
    int a;
}

當然, 有一些需要注意的地方. C++ 20 要求所有要匯出的名稱必須出現在介面單元 (一般是標頭檔) 中, 也就是不允許在實作單元中出現 export 關鍵字. 所以 Code 5 就是在介面單元中進行了實作. 另外, C++ 20 不允許匯出沒有內容的名稱空間, 也就是 export namespace A {} 這樣的程式碼會產生編碼錯誤. 另外, 不具名名稱空間中也不允許出現 export, 即不具名名稱空間中的名稱是不可以使用 export 匯出到模組之外的, 這也是為了對應不具名名稱空間的性質. 類似地, 靜態函式或者靜態變數本身其作用範圍已經被限制在了一個檔案中, 因此它們也不支援被 export 匯出.

為每一個要匯出的名稱添加一個 export 是很麻煩的, 因此 export 還允許將需要匯出的名稱囊括在一個可視範圍之內, 然後直接匯出 :

export module A;

export char c {};

export {
    auto a {0};
    int func() {
        return a;
    }
    enum E {
        ZERO, ONE, TWO, THREE
    };
    class foo {
    public:
        int a;
    };
    namespace name {
        int a;
    }
}

這裡我們要知道, export {} 不會建立一個新的可視範圍, 也就是 Code 6 中的全域變數 ca 是處於同一個呃名稱空間中的. 另外 C++ 20 不允許巢狀的 export 出現 :

export module A;

export {
    export {        // Error : export declaration appears within another export declaration
        int a;
    }
    int b;
}

對於名稱空間來說, 如果名稱空間中的任意名字被匯出, 那麼該名稱空間會隱含地被匯出. 例如模組 A 中存在一個名稱為 N 的名稱空間, N 沒有被 export 標識但是內部存在一個被 export 標識的變數 v, 這相當於隱含地給 N 也標識了 export. 對於函式樣板的特製化來說就稍有一些特殊, 如果僅僅匯出那一個特製化的版本而沒有匯出函式樣板本身, 這會導致特製化的那個版本匯出失敗. 所以想要匯出一個函式樣板的特製化版本, 就必須匯出其函式樣板本身.

在 C++ 20 中, static_assert 陳述式是不允許出現在 export 區塊內的.

如果某個類別在在使用 export 匯出的時候並沒有被定義, 而是僅僅匯出了宣告, 那麼我們在外部僅可以取到其名稱而不能取到其內部成員 :

export module A;

export struct S;
export S *func();
module A;

struct S {
    int value;
};
S *func() {
    return nullptr;
}
import A;

int main(int argc, char *argv[]) {
    S s;        // Error : variable has incomplete type 'S'
    auto p {func()};        // OK
    p->value = 0;       // Error : member access into incomplete type 'S'
}

1.5 在 Clang++ 和 G++ 上體驗模組

我們平時所說的 Clang 是指編碼器的名字, Clang 內有 Clang 和 Clang++. Clang 用來編碼 C 程式碼, Clang++ 用來編碼 C++ 程式碼. G++ 也是類似, 它是 GCC 專門用於編碼 GCC 的那一部分. 截止發文和第一次修改為止, Clang 還沒有完全支援 Module, 甚至模組分區都沒有支援, 因此我們使用最簡單的實例來體驗一下 C++ 20 的 Module.

我們首先體驗非分離實作.

export module A;
import <iostream>;

using namespace std;
export void hello() {
    cout << "Hello, Module!" << endl;
}
import A;

int main(int argc, char *argv[]) {
    hello();
}

在 Clang 中, 我們首先要將 hello.cpp 編碼為模組介面 : clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -c hello_1.cpp -Xclang -emit-module-interface -o hello_1.pcm, 如果在同一個檔案夾下, 我們可以不明確指定模組的介面檔案, 直接編碼 main.cppA.cpp 即可 : clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -fprebuilt-module-path=. main.cpp hello_1.cpp -o main. 如果不在同一個檔案夾下, 需要明確給出 -fprebuilt-module-path 需要的路徑. 然後直接運作 main 程式, 可以得到 Hello, Module! 這樣的輸出.

如果將模組 A 進行拆分, 也就是使用分離實作的方式, 那麼我們需要將 A.cpp 也進行拆分 :

export module A;

export void hello();
import <iostream>;
using namespace std;

module A;

void hello() {
    cout << "Hello, Module!" << endl;
}

現在, 要轉換的檔案變為了 hello_2.hpp, 而不是 hello_2.cpp : clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -c hello_2.hpp -Xclang -emit-module-interface -o hello_2.pcm. 如果繼續按照之前的指令編碼的話, 會產生編碼錯誤 : definition of module 'A' is not available; use -fmodule-file= to specify path to precompiled module interface. 此時, 我們需要使用 -fmodule-file 明確指出 A.pcm : clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -fprebuilt-module-path=. main.cpp A.cpp -o main -fmodule-file=A.pcm.

GCC 對於 Module 的處理相比於 Clang 來說更加自動化一些, 但是被依賴的檔案必須是嚴格按照關係出現的, 否則就會出現錯誤. 如果採用分離實作的方式, 那麼上述 hello_2.hpphello_2.cpp 的編碼方式為 g++ -fmodules-ts -std=c++20 hello_2.hpp hello_2.cpp main.cpp -o main, 然後運作程式 main 即可.

2. 進階

2.1 連結

在 C++ 20 之前, C++ 的連結是繼承自 C 的 :

  • 一些不需要進行連結的名稱是無連結(no linkage)的, 例如函式之內的局域變數等;
  • 在同一個檔案下可用的名稱是內部連結(internal linkage)的, 例如靜態函式, 靜態變數以及不具名名稱空間下的名稱等;
  • 跨檔案之間的可用名稱是外部連結(external linkage)的, 例如名稱空間, 類別名稱, 普通函式和可以被宣告為 extern 的變數等.

C++ 20 引入了模組連結 (module linkage), 它介於內部連結和外部連結之間. 簡單地說, 就是之前具有外部連結的名稱被引入模組之後, 未被 export 的就具有模組連結屬性. 例如如果使用 import 來匯入普通非模組化的標頭檔, 這個標頭檔內部存在某個名稱, 那麼這個名稱就具有模組連結屬性.

有了模組連結的概念, 我們就可以對第 1.4 節中哪一些名稱不能被匯出作一些補充 : 具有內部連結屬性的名稱不能在模組中被 export 匯出. 第 1.4 節中我們提到不具名名稱空間, 這裡補充一個名稱空間中的不具名名稱空間的實例, 即不具名名稱空間中的具有內部連結屬性的名稱不會隨著其上層名稱空間的匯出或者隱含匯出而匯出 :

export module A;

namespace N {
    export class Foo;       // declare export namespace N implicitly
    namespace {
        class Bar;
    }
}
import A;

int main(int argc, char *argv[]) {
    N::Foo *p1;     // OK
    N::Bar *p2;        // no type named 'Bar' in namespace 'N'
}

2.2 匯入再匯出

對於某些模組中名稱, 使用 import 匯入之後模組之後, 這些名稱僅僅在當前模組中可見. 如果我們希望把這些名稱再匯出給外界, 那麼僅需要在 import 之前再加一個 export 即可 :

  • export import module_name [[attribution]];
  • export import <library_header_name> [[attribution]];
  • export import "header_name" [[attribution]];

2.3 模組拆分

模組是否可以從多個檔案組合呢? 例如在 A.cpp 中, 我們宣告一部分內容 :

export module A;

int func();
export char func(int);

然後在 B.cpp 中, 我們繼續向模組 A 中加入一些內容 :

export module A;        // 仍然宣告模組 A, 加入一個類別

class Foo {
private:
    int value;
    const char *str;
public:
    Foo(int, const char *);
};
Foo func(Foo) noexcept;

很遺憾, C++ 20 並不允許一個模組不能被拆分在多個檔案中.

2.4 模組分區

第 2.3 節中我們提到, 模組不允許被拆分至多個不同檔案中, 但是有時候在大型項目中, 我們是確實有必要對程式碼進行細分的. 在 C++ 20 之前, 如果我們要對標頭檔分區來實作一個動物類別, 那麼我們可能會實作 animal.hpp, animal_cat.hpp, animal_dog.hppanimal_xxx.hpp 等等. 這樣我們可能就需要一次匯入多個標頭檔. 對於模組來說, 我們並不希望這樣的實作方式重演, 我們希望有更佳簡便的方式來匯入, 於是便有了模組分區.

宣告一個模組分區非常簡單, 第一次宣告也要遵循模組必須被匯出的原則 : export module primary_module_name:partition_module_name [[attribution]];. 其中, primary_module_name 是主要模組名稱 (例如 animal), partition_module_name 是模組分區名稱 (例如 catdog 等), [[attribution]] 是可選的屬性. 我們稱 primary_module_name主要模組介面單元 (primary module interface module). 要宣告一個模組分區, 必須保證主要模組存在, 即 primary_module_name 必須已經被宣告.

如果其它實作單元中匯入一個模組分區, 要使用這樣的方式 : import primary_module_name:partition_module_name;. 如果目前處於主要模組介面單元 primary_module_name 之下, 那麼就可以直接省略掉 primary_module_name : import :partition_module_name;.

export module animal:dog;

export class dog;
export module animal:cat;

export class cat;
export module animal:friendly;

import :dog;
import :cat;

export void play_with_another(dog &, cat &);
export inline void play_with_another(cat &c, dog &d) {
    play_with_another(d, c);
}
export void play_with_another(dog &, dog &);
export void play_with_another(cat &, cat &);
export module animal;

export import :dog;     // 可以改寫為 export import animal.dog;
export import :cat;     // export import animal.dog;

export class animal;

void run(animal &);
void eat(animal &);
void meow(cat &);
void bark(dog &);
export module protect_dog;

import animal.dog;

void protect_dog(dog &);

2.5 Reachable

C++ 20 的 Module 還引入了一個可到達性這樣的概念. 但是, 這樣的實例並不是第一次出現在 C++ 20 中. 在 C++ 14 引入的函式回傳型別推導就有這樣的概念 :

#include <iostream>

auto func() {
    struct s {
        int value;
    };
    return s {1};
}
int main(int argc, char *argv[]) {
    std::cout << func().value << std::endl;     // 輸出結果 : 1
}

函式 func 中的局域類別 s 並不可達, 我們無法直接獲取到它, 但是可以通過回傳型別生成它的物件, 然後訪問其中的內容. 這是可到達性中最初始的案例原型, 在模組中就有了更多的實例.

首先, 如果在匯出的時候, 只是匯出了一個類別的宣告, 那麼就不能宣告對應的物件. 例如在 Code 8-1 中, 我們僅僅將類別 S 的名稱對外匯出了, 而沒有將 Code 8-2S 的定義對外匯出, 那麼直接宣告 S 的物件 S s; 就會導致編碼器提示這個 S 是一個不完全型別, 也就是僅宣告的類別, 無法產生一個類別的實體. 這也就是 S 的定義在外部是不可到達的.

例外的是, 如果一個函式回傳的類別沒有被匯出, 但是函式被匯出, 那麼這個類別包括內部的公用內容就是可到達的 :

export module A;

import <iostream>;

class Foo {
public:
    void print() {
        std::cout << "Hello Module!" << std::endl;
    }
};

export Foo func() {
    return {};
}
import A;

int main(int argc, char *argv[]) {
    auto obj {func()};
    obj.print();        // 輸出結果 : Hello Module!
    using Foo = decltype(obj);
    Foo f;      // OK
}

對於變數 obj 來說, 我們不能使用 Foo obj {func()}; 這樣的方式進行宣告, 因為類別 Foo 本身沒有被匯出

2.6 #include

有一部分標頭檔並沒有模組化, 所以可能需要使用 #include 來匯入. 但是如果直接在模組中使用 #include 匯入標頭檔, 這會導致標頭檔內部的名稱在模組匯出之後全部對外可見. 例如在模組 A 宣告 export module A; 之後, 我們在這個宣告下面使用 #include <iostream> 會導致外部在引入模組 A 之後, 同時也獲得了標頭檔 <iostream> 中的全部名稱. 這並不是我們想要看到的. 為了避免這種情況導致的名稱直接被匯出, C++ 20 還引入了一種特殊的語法 : module;. 只要先進行 module; 宣告, 然後再通過 #include 引入標頭檔, 然後再宣告並且匯出模組就可以避免上述問題發生 :

module;
#include <string>

export module A;

// ...

這樣, 那些標頭檔中沒有被使用的名稱會在當前模組中被遺棄. 例如, 我們使用了 <string> 中的 std::basic_string, 但是沒有使用 std::string. 那麼, 模組 A 會保留 std::basic_string 而遺棄包括 std::string 在內的沒有被使用的名稱. 具體是否廢棄某一名稱, 這涉及到 C++ 20 為 Module 引入的宣告可達 (decl-reachable) 問題. 在同一個翻譯單元下 (通常是一個檔案), 一個位於 S 中的宣告 D 是否為宣告可達的, 這取決於非常複雜的規則, 這裡暫時不敘述. 但是大家需要對下面實例熟悉 :

namespace N {
    struct X {};
    inline void f(X);
    void g(X);
    int h();
}
module;
#include "test.hpp"

export module A;
template <typename T>
void use_f() {
    f({});
}
template <typename T>
void use_g() {
    g({});
}
template <typename T>
int use_h() {
    return h({});
}
auto value {use_h<int>()};
import A;

int main(int argc, char *argv[]) {
    use_f<int>();       // OK, 函式 f 是 inline 的, 所以沒有被廢棄
    use_g<int>();       // Error : 函式樣板 g 由於沒有被具現化過, 因此已經被廢棄了
    use_h<int>();       // OK, 函式樣板 use_h 曾經在 A.cpp 中被具現化, 所以沒有被廢棄
}

module; 開始直到遇到 export module A; 的程式碼片段中, 是不允許出現名稱宣告的 :

module;
#include <iostream>

export int func();      // Error : export declaration can only be used within a module interface unit after the module declaration

export module A;

3. 雜項

如果閣下閱讀到這裡, 就代表閣下已經基本對 C++ 20 引入的 Module 有了基本認識. 接下來是一些比較複雜的情形, 如果閣下的目標只是停留在了解 Module, 那麼可以無需閱讀接下來的內容.

3.1 全域模組

對於那些沒有放入模組的名稱, 特別像是 main 函式這樣的, 編碼器會如何處理它們呢? 答案是將它們放入全域模組. 對於全域模組, 我們沒有辦法對其使用 import 匯入. 在第 2.6 節中在特定語法下使用 #include 就是向全域模組中匯入標頭檔.

3.2 module :private;

有時候為了方便起見, 我們可能不會進行分離實作, 而是將實作內容直接放入標頭檔中. 在這種情況下, 是否可以輔助名稱對外不可見呢? 這便是 module :private; 所要做的.

export module A;

struct S;
export S f();

module :private;
struct S {
    char c;
    int i;
};

constexpr int g() noexcept {
    return sizeof(S);
}

S f() {
    return S {' ', g()};
}

module :private; 表示從接下來開始的部分都是模組的專屬部分, 不對外公開. 所以, 在 module :private; 之後也就不能出現匯出名稱這樣的宣告.

3.3 語境關鍵字的相容性

由於 importmodule 都是和語境相關的關鍵字, 所以這有時候會和模組的宣告衝突 :

template <typename T>
struct import {};
struct module {};

import<int> a;
module B;

這些程式碼在 C++ 20 之前都是沒有問題的, 但是在 C++ 20 之後是無法通過編碼. 因為最後兩行會被編碼器視為模組的匯入和模組的宣告. 為了表明它們確實不和模組有關, 需要使用可視範圍運算子 :: 指明這兩個名稱是類別和類別樣板而非模組. 也就是要把 import<int> a;module B; 改為 ::import<int> a;::module B;.

不過, 比較複雜的情況是結合巨集下的 import 到底應該如何處理的問題. 根據 C++ 20 提案 P1703R1 《Recognizing Header Unit Imports Requires Full Preprocessing》中提出, 目前編碼器針對巨集結合 import 這樣的環境下, 編碼器的處理和理想情況還是存在一部分差距的. 因此, P1703R1 針對一些特殊情況進行了語義的限制 : 當巨集替換未發生的時候, 要求 importexport import 宣告必須位於單行的頭部. 對於一些情況, 我們作出一些對比 :

Before P1703R1 After P1703R1
int x; import <map>; int y;
int x;
import <map>;
int y;
import <map>; import <set>;
import <map>;
import <set>;
export
import
<map>;
export import <map>;
#ifdef MAYBE_EXPORT
export
#endif
import <map>;
#ifdef MAYBE_EXPORT
export import <map>;
#else
import <map>;
#endif
#define MAYBE_EXPORT export
MAYBE_EXPORT import <map>;
#define MAYBE_EXPORT export
#ifdef MAYBE_EXPORT
export import <map>;
#else
import <map>;
#endif
import A; int x;
int x; import A;
只允許
import A; int x;
#define IMPORT(file) import file
#define IMPORT_OR_INCLUDE(file) ???
IMPORT(<set>);
IMPORT_OR_INCLUDE(<set>)
不再允許

左側是本來允許的寫法, 但是經過 P1703R1 調整之後不再允許, 需要採用右側的寫法.

對於 P1703R1 中的某些情況, 我上面沒有提出來, 因為 P1703R1 的更改導致了對 import 的限制太鬆了, 也限制了一些情況, 這些都在 P1857R3 中得到修復. 我們同樣列表進行對比 (對於第一行, 我們設編碼參數 -Dm="export module x;") :

Before P1857R3 After P1857R3
m
不再允許
module;
#if FOO
export module foo;
#else
export module bar;
#endif
不再允許
module;
#define EMPTY
EMPTY export module m;
不再允許
#if MODULE
export module m;
#endif
不再允許
import::type x {};     // Error
module::type y {};      // OK
namespace N {
    module a;       // OK
    import b;       // Error
}
void f(import_t *import) {
    import->do_import();     // Error
}
import::type x {};     // OK
module::type y {};      // OK
namespace N {
    module a;       // Error
    import b;       // Error
}
void f(import_t *import) {
    import->do_import();     // OK
}

這裡特別提出, 還有一種寫法不論在哪一篇提案中都不被允許 :

#if MODULES
module;
export module m;
#endif

3.4 巨集

第 0 節我們說過 C++ 20 引入 Module 要解決的其中一個問題就是巨集產生的名稱問題, 這個名稱問題並不能由名稱空間來解決.

模組要求相同的巨集如果存在多重的定義, 那麼定義必須相同, 否則會導致錯誤 :

#define X 1
#define Y a
#define Z 0
#undef X
#define X 2
#define Y b
#define Z 0
import "A.hpp";
import "B.hpp";

auto a {X};     // OK, X 在 A.hpp 中已經被 #undef 取消定義, 在 B.hpp 中重新定義是有效的
auto b {Z};     // OK, Z 在 A.hpp 和 B.hpp 中的定義相同
auto c {Y};     // Error : Y 在 A.hpp 和 B.hpp 中的定義不同

3.5 typedef

C++ 繼承了 C 的 typedef, 但是某些和 C 不相容的類別中, 我們可能寫出這種程式碼 :

typedef struct {
   void func();
} X;
void X::func() {}

Clang++ 直接編碼通過, 但是會留下一個警告 : Anonymous non-C-compatible type given name for linkage purposes by typedef declaration. 而下面這個實例 Clang 會直接擲出編碼錯誤 :

template <typename T>
int x;
typedef struct {        // Error : unsupported: typedef changes linkage of anonymous type, but linkage was already computed
    int *n {&x<decltype(this)>};
} X;
X y;

很多不具名類別都會觸法類似的名稱連結錯誤, typedef 在這裏並不能幫助編碼器解決名稱連結問題. 在 C++ 20 引入 Module 之後, 這一類問題會變得更加嚴重 :

#ifndef __FOO_HPP__
#define __FOO_HPP__

typedef struct {
    // ...
} X;

#endif
module;
#include "foo.hpp"

export module A;
X x;
export module B;

import A;

// ...
import B;
#include "foo.hpp"

int main(int argc, char *argv[]) {
    //...
}

在模組 A 中, 編碼器可能已經知道了類別 X 的定義是什麼樣的, 這就導致編碼器會將新的定義合併到舊的定義中. 這中間存在太多的實作定義行為. 因此, C++ 20 直接不允許為類別引入存在名稱連結的 typedef 宣告, 以下情況除外 :

  • 不存在預設初始化的非靜態成員;
  • 不存在 lambda 表達式的成員;
  • 空陳述式;
  • static_assert 斷言.

除此之外, 對於多個檔案的內同名非內嵌函式, C++ 本身允許它們的預設引數不同. 但是引入 Module 之後就會出現問題. 當這些同名函式被直接匯入到一個檔案中時, 編碼器無法判斷哪一個預設引數比較合適.

C++ 20 提案 P1766R1《Mitigating minor modules maladies》中提出的解決方案就是為這些函式提供類似於內嵌函式的規則 : 不允許在這種情況下使用不同的預設引數, 包括函式樣板中的樣板預設引數.

3.6 重複定義問題

在 C++ 20 之前, 我們都是使用 #include 來包含檔案, 這也許會遇到重複定義的問題. 不過, 對於 :

  • 重複匯入相同的標頭檔, 我們一般通過定義巨集配合 #ifndef#endif 來解決, 或者通過 #pragma 來解決;
  • 從不同標頭檔匯入同名卻不同定義的類別, 我們一般通過名稱空間來解決.

在 C++ 20 引入 Module 之後, 會出現 #include 和模組交叉的情況 :

#ifndef __FOO_HPP__
#define __FOO_HPP__

template <int Value>
struct Foo {
    constexpr int get() const noexcept {
        return Value;
    }
};

#endif
export module A;
import "foo.hpp";

export template <typename T> constexpr T make() {
    return T(Foo<42> {}.get());
}
import A;       // 類別 Foo 對本檔案不可見, 但是編碼器知道類別 Foo 如何定義

int arr[make<int>()];

#include "foo.hpp"      // 匯入類別 Foo

main.cpp 中, 雖然類別 Foo 不可見, 但是編碼器已經知道了 Foo 如何定義, 那麼匯入標頭檔 foo.hpp 會不會產生重複定義類別 Foo 的定義呢? 這是允許的. 但是如果修改 A.cpp, 讓 foo.hppA.cpp 中匯入的同時也匯出, 即修改 Code 24-2 中的 import "foo.hpp";export import "foo.hpp";, 情況就有些不同了. 這會導致類別 Foo 重複定義的問題. C++ 20 提案 P1811R0《Relaxing redefinition restrictions for re-exportation robustness》提出允許這樣的情況發生, 並且在 Module 放鬆類似的限制 : 提案更改了重新定義的限制, 在全域模組下, 不論某個名稱是否可到達, 每一個檔案 (translation unit) 都允許至多定義一個這樣的名稱. 這樣使得編碼器無需精確跟蹤到非常具體的哪一個定義, 但是在某些發生衝突的情況下, 可能不能保證給出精確的診斷信息. 同時, 提案也建議針對 #include, 編碼器儘量向 import 進行轉換, 當作模組來處理.

3.7 非局域變數的初始化順序問題

export module A;
import <iostream>;

struct G {
    G() {
        std::cout << "constructing" << std::endl;
    }
} g;

Code 25 中存在初始化順序的問題. 有可能當類別 G 的建構子被呼叫的時候, std::cout 還沒有初始化完成, 這將導致未定行為. 因為全域變數的初始化在這種情況下屬於實作定義行為, 具體的順序並不確定. C++ 20 提案 P1874R1《Dynamic Initialization Order of Non-Local Variables in Modules》更改了初始化順序的限制, 使得這個問題在 C++ 20 中已經被解決.

3.8 未明確匯入的標頭檔

import <vector>;

// ...
module;
#include "use_vector.hpp"

module A:partition;

// ...
module A;
import :partition;

std::vector<int> x;     // Error : std::vector 在此處應該是不可見的

為什麼說 Code 26 中存在錯誤呢? Code 26-3 已經指明了, 根據第 2.6 節, std::vector 在此處確實應該是不可見的. 但是, 在 P1979R0 之前, 這些程式碼可以通過編碼. 於是, P1979R0 便提出修正了這個錯誤 (P1979R0 是對 https://github.com/cplusplus/nbballot/issues/85 的更正).

3.9 樣板具現化的更改

在分離實作一個類別內的樣板成員函式時, 如果手動具現化某個特製化的情況, 以免樣板被重複具現化, 那麼在實作檔案 (一般是在 .cpp 檔案中) 需要重新宣告它 (對於普通的樣本函式同樣有這樣的問題) :

struct A {
    template <int>
    void scale(const char *) const;
};
extern template void A::scale<1>(const char *) const;
#include "A.hpp"

template <int I>
void A::scale(const char *) const {}
template void A::scale<1>(const char *) const;

如果在 A.cpp 中缺少 template void A::scale<1>(const char *) const; 當我們使用 A::scale<1> 的時候就會產生連結問題, 編碼器連結到 A::scale<1> 的定義. 在 C++ 20 提案 P1799R3《ABI isolation for member functions》中, 我們可以不再需要手動去具現化 A::scale<1>, 只要直接宣告 template void A::scale<1>(const char *) const; 即可 :

export module M;

struct A {
    template <int>
    void scale(const char *) const {}
};
template void A::scale<1>(const char *) const;

3.10 不具名列舉

從不同模組中匯入的不具名列舉中相同名稱的實體是存在爭議的, 因為不具名列舉中的名稱是不存在連結的. C++ 20 提案 P2115R0《US069: Merging of multiple definitions for unnamed unscoped enumerations》提出以最先匯入的那一個作為最終值 :

export module A;

export enum {
    FIRST, SECOND, THIRD
};
export module B;

export enum {
    PRESERVED, FIRST, SECOND, THIRD
};

首先匯入模組 A 還是匯入模組 B 會導致列舉 FIRST, SECONDTHIRD 的值不同, 誰先被匯入會決定這三個列舉的值 :

import B;
import A;
import <iostream>;

int main(int argc, char *argv[]) {
    std::cout << FIRST << std::endl;        // 輸出 : 1
}

如果交換 main.cpp 中的 import B;import A; 這兩行的順序, 那麼輸出的結果將會變成 0.

3.11 Translation-Unit-Local

C++ 20 還引入了一個全新的概念, 即 Translation-Unit-Local, 簡寫為 TU-local, 它表示了某個名稱僅僅在當前檔案 (translation unit) 中可用. 具體的定義非常複雜, 這裡稍微列出一下哪一些名稱可能是 TU-local 的 (由於某些定義需要額外解釋, 因此不完整) :

  • 具有內部連結屬性的型別, 函式, 變數或者樣板;
  • 在具有 TU-local 實體內的被宣告但是不具有連結的型別, 函式, 變數或者樣板或者是由 Lambda 表達式引入的;
  • 帶有 TU-local 屬性的樣板的特製化;
  • 使用了帶有 TU-local 屬性的樣板引數對應的樣板特製化;
  • 一個可能已經存在具現體且已經向外匯出的樣板特製化;
  • 帶有 TU-local 屬性的或者指向帶有 TU-local 屬性的函式的值或者物件;
  • 與帶有 TU-local 屬性變數相關聯的指標;
  • 陣列型別對應的子型別或者類別內部存在的型別是帶有 TU-local 屬性的.
export module A;

import <utility>;

static void f() {}
inline void it() {
    f();        // Error : 這會導致函式 f 對外暴露, 而函式 f 被限制在了本檔案內
}
static inline void its() {
    f();        // OK
}
template <int>
void g() {
    its();      // OK
}
template void g<0>();

decltype(f) *fp;        // Error : f 是 TU-local 的
auto &fr = f;       // 函式的參考
constexpr auto &fr2 {fr};       // Error : fr2 會暴露 f
constexpr static auto fp2 {fr};     // OK

struct S {
    void (&ref)();
}s {f};     // OK, s 是 TU-local 的
constexpr extern struct W {
    S &s;
} wrap {s};     // OK, wrap 是 TU-local 的

static auto x {[] {
    f();
}};     // OK, x 是 TU-local 的
auto x2 {x};        // Error : lambda 表達式的型別是 TU-local 的
int y {([] {
    f();
}, 0)};     // Error : 這個 lambda 表達式的型別不是 TU-local 的
int y2 {(x, 0)};        // OK

namespace N {
    struct A {};
    void adl(A);
    static void adl(int);
}
void adl(double);
template <typename T>
inline void h(T &&x) {
    adl(std::forward<T>(x));        // OK, 但是特製化可能會導致暴露
}
module A;

void other() {
    g<0>();     // OK
    g<1>();     // Error : 未具現化的樣板是 TU-local 的
    h(N::A {});     // Error : 這個函式多載是 TU-local 的
    h(0);       // OK
    adl(N::A {});       // OK, 呼叫的是 void N::adl(N::A);
    fr();       // OK
    constexpr auto ptr {fr};        // Error : fr 並非常數表達式
}

4. 總結

可以看到, C++ 20 引入的 Module 並不像 #include 那麼簡單. 但是據他人反應, 對於大型項目, 在改用 Module 之後, 除了第一次編碼稍慢之外, 之後的編碼速度快得驚人. 這也是 C++ 20 引入 Module 的最大好處之一. 對於大型項目, 本人十分建議使用 Module 撰寫 (改寫舊程式碼卻沒有那麼必要); 對於小型項目, 根據目前編碼 Module 代碼的複雜程度, 個人暫時不建議小型項目使用 Module.

但是目前各家編碼器要編碼帶有 Module 的程式碼還是非常複雜的, 需要手動進行. 我非常期待編碼器可以自動化編碼那一天的到來.