1. 導論

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

除了上面這個問題之外, 在 C++ 17 以及之前的標準中, 無法做到真正的隔離. 本人在小型項目 data_structure 中定義了一個全域的名稱空間 data_structure, 並且在其內部定義了一個非內嵌的巢狀名稱空間 __data_structure_auxiliary. __data_structure_auxiliary 這個名稱空間中都是程式庫內部實作的內容, 都是我不希望外界使用的, 而僅供程式庫作者也就是我自己使用. 雖然使用名稱空間 data_structure 時, 程式庫使用者不可能接觸到 __data_structure_auxiliary 中的內容, 而且 IDE 也不可能將 __data_structure_auxiliary 中的內容暴露給外界, 但是程式庫使用者仍然可以通過 data_structure::__data_structure_auxiliary 存取到裡面的內容. 所以, 儘管我使用了名稱空間對內部實作進行了隔離, 但是這並沒有辦法做到真正的隔離

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

對於上面幾個問題, 都由 C++ 20 中引入的 Module 解決了

閱讀提示 : 如果閣下只是想掌握基礎的知識, 那麼閱讀第二節和第三節就足夠了. 另外, 由於 Module 的編碼方式和一般的程式碼不同, 因此如果想要一邊編碼一邊學習, 那麼閱讀第二節了解 Module 之後, 建議立即閱讀第五節.

2. 基本概念

2.1 關鍵字

Module 中使用到了幾個關鍵字 : export, module, importprivate

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

一般來說, 和語境有關的關鍵字我都不建議大家使用, 甚至對於歷史遺留程式碼中使用了這些關鍵字的, 我都會建議大家修改

2.2 宣告模組

要宣告一個模組非常簡單 :

module MODULE_NAME [[ATTRIBUTION]];

其中, MODULE_NAME 是模組的名稱, ATTRIBUTION 是可選的屬性. 一般來說, 模組的宣告都是放在一個檔案的頭部. 模組宣告之後, 所有的名稱都劃入該模組之下. 預設情況下, 這些名稱都不對外公開. 因此, 在這樣宣告模組的情況下, 我們稱 MODULE_NAME 為實作單元

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

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

這裡, translation unit 是指一個實作檔案, 例如以 .cpp 為後綴的檔案

一個模組必須對外可見, 因此僅使用 module MODULE_NAME; 宣告模組是不被允許的, 它必須還要被可匯出才行 :

export module MODULE_NAME;

而且, 一個模組必須在第一次被宣告的時候就被匯出. 如果一個模組不允許被匯出, 那麼它沒有任何存在的意義. 那麼什麼時候可以不加 export 呢? 看下面實例 :

A.cpp :

module A;

int func() {
    return 0;
}

main.cpp :

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

這樣會產生編碼錯誤, 因為模組 A 在第一次宣告的時候, 並沒有讓 A 對外可見. 因此需要修改 A.cpp :

export module A;

int func() {
    return 0;
}

但是, 如果採用標頭檔和實作分離的形式, 那麼就可以在實作檔案中不加 export :

A.hpp :

export module A;        //declare module A

//...

A.cpp :

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

int func() {
    return 0;
}

main.cpp :

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

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

在上面兩個實例中, 直接在 A.cpp 中宣告模組會導致 A.cpp 被稱為介面單元 (interface unit). 而使用分離的方法實作模組 A 則有些不同, 我們稱 A.hpp 為介面單元, 而 A.cpp 被稱為實作單元 (implementation unit). 這樣, 我們給出介面單元和實作單元的定義 :

  • 若某個檔案中宣告了一個模組, 並且將這個模組匯出, 即使用了 export module MODULE_NAME; 這樣的陳述式, 那麼就稱這個檔案為介面單元
  • 若某個檔案中宣告了一個模組, 但是沒有將這個模組匯出, 說明這個檔案是模組的實作部分, 那麼稱這個檔案為實作單元

2.3 匯入模組

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

import MODULE_NAME [[ATTRIBUTION]];

import <HEADER_NAME> [[ATTRIBUTION]];

import "HEADER_NAME" [[ATTRIBUTION]];

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

下面是一個實例 :

import A;
import "test.hpp";
import <iostream>;

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

當一個檔案稱為模組單元 (包含 module 宣告) 的時候, 它就不能使用 import "NAME.hpp"; 的方法匯入, 這樣會導致編碼錯誤, 只能通過第一種方式 import NAME; 的方式匯入

迴圈地匯入是不被允許的 :

A.cpp :

export module A;
import B;
//...

B.cpp :

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

C.cpp :

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

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

A.hpp :

export module A;
//...

A.cpp :

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

3. 進階

3.1 匯出名稱

如果我們想要讓某個名稱對外可見, 那麼我們需要在這個名稱之前增加 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 關鍵字 :

module A;       //這個檔案屬於實作單元

export int func() {     //Error : export declaration can only be used within a module interface unit
    return 0;
}

不過, 需要注意的是, C++ 20 標準還不允許匯出沒有內容的名稱空間, 也就是 export namespace A {} 這樣的程式碼會產生編碼錯誤. 另外, 不具名名稱空間中不允許出現 export :

export module A;

namespace {
    export int a;       //Error : export declaration appears within anonymous namespace
}

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

export module A;

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

但是, 雖然可以將名稱放在一個可視範圍內讓 export 匯出, 但是這並不會建立一個新的可視範圍, 也就是 "{}" 相當於不存在, 類似於 extern {}. 這裡也有一個需要注意的地方, C++ 20 目前還不允許巢狀的 export :

export module A;

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

對於名稱空間來說, 如果名稱空間中的任意名字被匯出, 那麼該名稱空間會隱含地被匯出 :

A.cpp :

export module A;

namespace name {
    export int func();
    int b;
}

main.cpp :

import A;

int main() {
    name::func();       //OK
    name::b = 1;        //Error : no member named 'b' in namespace 'name'
    using namespace name;       //OK
}

對於函式樣板的特製化來說, 如果僅僅匯出特製化的版本而不匯出函式樣本本身, 那麼會導致這個特製化的版本匯出失效 :

A.cpp :

export module A;

template <typename T>
T func(T) {
    return {};
}
export template <>
char func<char>(char) {
    return 1;
}

main.cpp :

import A;
import <iostream>;

int main() {
    std::cout << func('a') << std::endl;        //Error : use of undeclared identifier 'func'
}

export 中, C++ 20 也不允許出現 static_assert 陳述式 :

export module A;

export static_assert(true);     //Error : static_assert declaration cannot be exported
export {
    static_assert(true);        //Error : ISO C++20 does not permit a static_assert declaration to appear in an export block
};

靜態函式或者靜態變數本身其作用範圍已經被限制在了一個檔案中, 因此, 它們也不支援被 export 匯出

若某個類別在被 export 標記時, 它沒有被定義, 那麼我們在外部僅可以取到其名稱而不能取到其內部成員 :

A.hpp :

export module A;

export struct S;
export S *func();

A.cpp :

module A;

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

main.cpp :

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'
}

3.2 linkage

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

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

在 C++ 20 引入 Module 之後, 有了一個新的連結, 即 module linkage, 它介於 internal linkage 和 external linkage 之間. 簡單地說, 就是之前具有 external linkage 的名稱被引入模組之後, 未被 export 的就具有 module linkage 屬性. 例如 :

A.hpp :

int func() {
    return 0;
}

B.hpp :

export module B;

import "A.hpp";     //函式 func 具有 module linkage 屬性, 因為 func 沒有被 export 標識

有了 linkage 的概念之後, 我們針對 3.1 進行一些補充. 首先, 具有 internal linkage 屬性的名稱不能被匯出 :

export module A;

export static int func();       //Error : declaration of 'func' with internal linkage cannot be exported

那麼, 對於不具名名稱空間中的具有 internal linkage 屬性的名稱不會隨著其上層名稱空間的匯出或者隱含匯出而匯出 :

A.hpp :

export module A;

namespace N {
    export class Foo;
    namespace {
        class Bar;
    }
}

main.cpp :

import A;

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

3.3 匯入再匯出

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

export import MODULE_NAME [[ATTRIBUTION]];

export import <HEADER_NAME> [[ATTRIBUTION]];

export import "HEADER_NAME" [[ATTRIBUTION]];

3.4 多次匯出

那麼模組是否可以從多個檔案組合呢? 例如在 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;

這樣是不被允許的. 一個模組不能被拆分在多個檔案中

3.5 模組分區

在 3.4 中, 我們說過, 一個模組不可以被分散在多個檔案中進行宣告. 但是有時候, 特別是在大型項目中, 我們有必要對程式碼進行細分, 為了避免使用舊的方法 (例如使用 animal, animal_dog, animal_cat) 進行表示, C++ 20 還引入了模組分區

宣告一個模組分區非常簡單, 第一次宣告也要遵循模組必須被匯出的原則 :

export module PRIMARY_MODULE_NAME:PARTITION_MODULE_NAME [[ATTRIBUTION]];

其中, PRIMARY_MODULE_NAME 是主要模組名稱, PARTITION_MODULE_NAME 是模組分區名稱, ATTRIBUTION 是可選的屬性. 我們稱 PRIMARY_MODULE_NAME 為主要模組介面單元 (primary module interface module), 要宣告模組分區, 必須保證主要模組存在, 即 PRIMARY_MODULE_NAME 必須已經被宣告, 而且存在且僅能存在一個主要模組

如果其它實作單元中匯入一個模組分區, 要使用這樣的方式 :

import PRIMARY_MODULE_NAME:PARTITION_MODULE_NAME;

其中, PRIMARY_MODULE_NAME 是主要模組名稱, PARTITION_MODULE_NAME 是模組分區名稱. 但是, 如果目前處在 PRIMARY_MODULE_NAME 模組下, 那麼就可以直接省略掉 PRIMARY_MODULE_NAME :

import :PARTITION_MODULE_NAME;

下面是一個模組分區的實例 :

dog.cpp :

export module animal:dog;

export class dog;

cat.cpp :

export module animal:cat;

export class cat;

friendly.cpp :

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 &);

animal.cpp :

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 &);

protect_dog.cpp :

export module protect_dog;

import animal.dog;

void protect_dog(dog &);

一個模組分區除了它是一個主要模組的分區之外, 其它行為都和主要模組一致

3.6 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 並不可達, 我們無法直接獲取到它, 但是可以通過回傳型別生成它的物件, 然後訪問其中的內容. 這是 Reachable 中最初始的案例原型, 在 Module 中就有了更多的實例

首先, 如果在匯出的時候, 只是匯出了一個類別的宣告, 那麼就不能宣告對應的物件 :

A.cpp :

export module A;

export class Foo;

main.cpp :

import A;

int main(int argc, char *argv[]) {
    Foo f;      //variable has incomplete type 'Foo'
}

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

A.cpp :

export module A;

import <iostream>;

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

export Foo func() {
    return {};
}

main.cpp :

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 本身沒有被匯出

3.7 #include

如果直接在模組中使用 #include 匯入標頭檔, 這會導致標頭檔內部的名稱在模組匯出之後全部對外可見. 因此, 使用 import 匯入標頭檔和使用 #include 匯入標頭檔的處理結果完全不同

為了避免這種情況導致的名稱直接被匯出, C++ 20 還為 Module 引入了一種特殊的語法 : module;

module;
#include <string>

export module A;
//...

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

test.hpp :

namespace N {
    struct X {};
    inline void f(X);
    void g(X);
    int h();
}

A.cpp :

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>()};

main.cpp :

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;

4. 雜項

4.1 全域模組

對於那些沒有放入模組的名稱, 特別像是 main 函式這樣的, 編碼器會如何處理它們呢? 答案是將它們放入全域模組

對於全域模組, 我們沒有辦法對其使用 import 匯入

在 3.7 中在特定語法下使用 #include 就是向全域模組中匯入標頭檔

4.2 module :private;

在目前, 編碼器對 Module 的支援都不太完整, 並且比較難以編碼, 因此大家都傾向於直接將實作放在介面單元中 :

export module A;

export struct S {};
export S func() {
    return {};
}

但是, 如果我們還是想要分離實作的方式, 或者我們只想匯出類別 S 的名稱, 即外界看到的類別 S 應該是一個不完整型別, 亦即外界看到的類別 S 是一個未定義的類別, 那麼我們就可以借助另外一個特殊的語法 module :private;

export module A;

export struct S;
export S func();

module :private;
struct S {};
S func() {
    return {};
}

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

4.3 importmodule 的相容性

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

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

import<int> a;
module B;

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

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

::import<int> a;        //OK
::module B;     //OK

不過, 比較複雜的情況是結合巨集下的 import 到底應該如何處理的問題. 根據 P1703R1 中提出, 目前編碼器針對巨集結合 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

左側是本來允許的寫法, 但是經過 P1703R1 調整之後不再允許. 另外, import 之後的內容不受影響 :

import A; int x;        //OK
int x; import A;        //Error after P1703R1

下面的情況就不再受到支援 :

#define IMPORT(file) import file
#define IMPORT_OR_INCLUDE(file) ???

IMPORT(<set>);
IMPORT_OR_INCLUDE(<set>)

對於 P1703R1 中的某些情況, 我上面沒有提出來, 因為 P1703R1 的更改導致了對 import 的限制太鬆了, 也限制了一些情況, 這些都在 P1857R3 中得到修復. 我們同樣列表進行對比 :

Before P1703R1 After P1703R1
// -Dm="export module x;" is OK
m
// -Dm="export module x;" is incorrect
m
// OK
module;
#if FOO
export module foo;
#else
export module bar;
#endif
// Error
module;
#if FOO
export module foo;
#else
export module bar;
#endif
// OK
module;
#define EMPTY
EMPTY export module m;
// Error
module;
#define EMPTY
EMPTY export module m;
// OK
#if MODULE
export module m;
#endif
// Error
#if MODULE
export module m;
#endif
import::type x {};		// Error
module::type y {};		// OK
namespace N {
    module a;		// OK
    import b;		// Error
}
#define MAYBE_IMPORT(x) x
MAYBE_IMPORT(
    import <a>;		// Undefined behaviour
)
#define MAKE_EMPTY(x)
MAYBE_IMPORT(
    import <a>;		// Undefined behaviour
)
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
}
#define MAYBE_IMPORT(x) x
MAYBE_IMPORT(
	import <a>;		// Undefined behaviour
)
#define MAKE_EMPTY(x)
MAYBE_IMPORT(
	import <a>;		// Undefined behaviour
)
void f(import_t *import) {
import->do_import();		// OK
}

還有一種情況是在兩篇 Proposal 下都不可行的 :

#if MODULES
module;
export module m;
#endif

4.4 巨集

在開始時, 就已經說過 C++ 20 引入 Module 要解決的其中一個問題就是巨集產生的名稱問題, 這個名稱問題並不能由名稱空間來解決

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

A.hpp :

#define X 1
#define Y a
#define Z 0
#undef X

B.hpp :

#define X 2
#define Y b
#define Z 0

main.cpp :

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 中的定義不同

4.5 typedef (P1766R1)

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 之後, 這一類問題會變得更加嚴重 :

foo.hpp :

#ifndef __FOO_HPP__
#define __FOO_HPP__

typedef struct {
    //...
} X;

#endif

A.cpp :

module;
#include "foo.hpp"

export module A;
X x;

B.cpp :

export module B;

import A;

//...

main.cpp :

import B;
#include "foo.hpp"

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

在模組 A 中, 編碼器可能已經知道了類別 X 的定義是什麼樣的, 這就導致編碼器會將新的定義合併到舊的定義中. 這中間存在太多的實作定義行為

因此, C++ 20 直接不允許為類別引入存在名稱連結的 typedef 宣告, 以下情況除外 :

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

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

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

4.6 重複定義問題 (P1811R0)

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

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

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

foo.hpp :

#ifndef __FOO_HPP__
#define __FOO_HPP__

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

#endif

A.cpp :

export module A;
import "foo.hpp";

export template <typename T> constexpr T make() {
    return T(Foo<42> {}.get());
}

main.cpp :

import A;       //類別 Foo 對本檔案不可見, 但是編碼器知道類別 Foo 如何定義

int arr[make<int>()];

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

main.cpp 中, 雖然類別 Foo 不可見, 但是編碼器已經知道了 Foo 如何定義, 那麼匯入標頭檔 foo.hpp 會不會產生重複定義類別 Foo 的定義呢? 這是允許的, 但是如果修改 A.cpp, 讓 foo.hppA.cpp 中匯入的同時也匯出 :

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

export template <typename T> constexpr T make() {
    return T(Foo<42> {}.get());
}

情況就有些不同了. 這會導致類別 Foo 重複定義的問題. P1811R0 提出允許這樣的情況發生, 並且在 Module 放鬆類似的限制 : Proposal 更改了重新定義的限制, 在全域模組下, 不論某個名稱是否 Reachable, 每一個檔案 (translation unit) 都允許至多定義一個這樣的名稱. 這樣使得編碼器無需精確跟蹤到非常具體的哪一個定義, 但是在某些發生衝突的情況下, 可能不能保證給出精確的診斷信息. 同時, Proposal 也建議針對 #include, 編碼器儘量向 import 進行轉換, 當作模組來處理

4.7 非局域變數的初始化順序問題 (P1874R1)

export module A;
import <iostream>;

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

上述程式碼存在初始化順序的問題. 有可能當類別 G 的建構子被呼叫的時候, std::cout 還沒有初始化完成, 這將導致未定行為. 因為全域變數的初始化在這種情況下屬於實作定義行為, 具體的順序並不確定. P1874R1 更改了初始化順序的限制, 使得這個問題在 C++ 20 中已經被解決

4.8 未明確匯入的標頭檔 (P1979R0)

use_vector.hpp :

import <vector>;

//...

partition.cpp :

module;
#include "use_vector.hpp"

module A:partition;
//...

A.cpp :

module A;
import :partition;

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

根據 C++ 20 引入 Module 的初衷, 上述錯誤應該是存在的. 但是在 P1979R0 之前, 上述程式碼可以通過編碼

4.9 樣板具現化的更改 (P1779R3)

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

A.hpp :

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

A.cpp :

#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> 的定義. 在 Module 中, 我們不再需要手動去具現化 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;

4.10 不具名列舉 (P2115R0)

從不同模組中匯入的不具名列舉中相同名稱的實體是存在爭議的, 因為不具名列舉中的名稱是不存在連結的. P2115R0 提出以首先匯入的那一個作為值 :

A.cpp :

export module A;

export enum {
    FIRST, SECOND, THIRD
};

B.cpp :

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
}

接下來交換前面兩行, 輸出結果會有不同 :

import A;
import B;
import <iostream>;

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

4.11 Translation-Unit-Local (P1815R2)

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

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

這裡使用 Proposal 中的一個實例來說明一下 TU-local 的意義 :

A.hpp :

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, 但是特製化可能會導致暴露
}

A.cpp :

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 並非常數表達式
}

5. 使用 Clang++ 和 G++ 體驗 Module

我們平時所說的 Clang 是指編碼器的名字, Clang 內有 Clang 和 Clang++. Clang 用來編碼 C 程式碼, Clang++ 用來編碼 C++ 程式碼. G++ 也是類似, 它是 GCC 專門用於編碼 GCC 的那一部分.

截止發文為止, Clang 還沒有完全支援 Module, 甚至模組分區都沒有支援, 因此我們使用最簡單的實例來體驗一下 C++ 20 的 Module :

A.cpp :

export module A;
import <iostream>;

using namespace std;
export void hello();

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

main.cpp :

import A;

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

這兩個程式碼並不能直接按照平常的方法進行編碼. 在 Clang 中, 我們首先要將 A.cpp 編碼為 Module Interface :

clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -c A.cpp -Xclang -emit-module-interface -o A.pcm

在同一個檔案夾下, 我們可以不明確指定模組的介面檔案, 直接編碼 main.cppA.cpp 即可 :

clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -fprebuilt-module-path=. main.cpp A.cpp -o main

最終我們得到輸出結果 :

輸出結果 :
Hello Module!

如果採用分離實作的方式, 就要明確指定 A.pcm :

A.hpp :

export module A;

export void hello();

A.cpp :

module;
import <iostream>;
using namespace std;

module A;

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

main.cpp :

import A;

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

現在, 要轉換的檔案變為了 A.hpp :

clang++ -Wall -std=c++20 -fimplicit-modules -fimplicit-module-maps -c A.hpp -Xclang -emit-module-interface -o A.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

輸出的結果和之前是相同的 :

輸出結果 :
Hello Module!

GCC 對於 Module 的處理相比於 Clang 來說更加自動化一些, 但是被依賴的檔案必須是嚴格按照關係出現的, 否則就會出現錯誤. 如果採用分離實作的方式, 那麼上述 A.hpp 和 A.cpp 的編碼方式為

g++ -fmodules-ts -std=c++20 A.hpp A.cpp main.cpp -o main

6. 總結

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

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