1. 導論
在 C++ 20 之前的程式碼處理中, 編碼器在處理 #include
時並不會處理重複的內容. 例如在 a.hpp
中引入了 <iostream>
標頭檔, 在 b.hpp
中也引入了 <iostream>
標頭檔, 那麼編碼器會同時將幾萬程式碼引入 a.hpp
和 b.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
, import
和 private
需要注意的是, export
和 private
本身就是 C++ 中存在的關鍵字, 儘管 export
在 C++ 11 中被移除, 但是仍然被保留為關鍵字 (register
在 C++ 17 中被移除, 但是也仍然保留為關鍵字). 但是 module
和 import
並不是歷來就存在的, 因此它們在特定語義下才會稱為關鍵字, 類似於 C++ 11 引入的 final
和 override
, 這是為了和舊的程式碼相容
一般來說, 和語境有關的關鍵字我都不建議大家使用, 甚至對於歷史遺留程式碼中使用了這些關鍵字的, 我都會建議大家修改
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 import
與 module
的相容性
由於 import
和 module
都是和語境相關的關鍵字, 所以這有時候會和模組的宣告衝突 :
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 中提出, 對於這種特殊情況, 需要進行語義的限制 : 當巨集替換未發生的時候, 要求 import
和 export import
宣告必須位於單行的頭部. 對於一些情況, 我們作出一些對比 :
Before P1703R1 | After P1703R1 |
---|---|
|
|
|
|
|
|
|
|
|
|
左側是本來允許的寫法, 但是經過 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 |
---|---|
|
|
|
|
|
|
|
|
|
|
還有一種情況是在兩篇 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.hpp
在 A.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
, SECOND
和 THIRD
的值不同, 誰先被匯入會決定這三個列舉的值 :
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.cpp
和 A.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 的程式碼還是非常複雜的, 需要手動進行. 我非常期待編碼器可以自動化編碼那一天的到來
自創文章, 原著 : Jonny. 如若閣下需要轉發, 在已經授權的情況下請註明本文出處 :