上一篇大概整理了 C++20 Modules 的語法。不過也有提到,還有個「Module Partition」沒有講;這邊就來獨立寫一篇吧。
「Module Partition」(模組分區)這個概念,應該是針對大型模組的需求而設計的。他的功能是可以將模組的介面根據自己的分類(例如功能、用途)來分割到不同的檔案,避免全部寫在同一個 primary module interface unit 裡面會過於肥大;同時,這邊也可以建立出僅限內部使用的區塊,來提供不想讓外部看到的實作。
他的基本概念,就是透過在本來的模組名稱後面,先加上一個「:
」、然後再給他一個區塊的名稱;例如「module MyModule:Part1
」就代表這是 MyModule
這個模組裡面一個叫做 Part1
的區塊。
透過這樣拆分,可以讓程式寫得更有結構、更好維護;而這些區塊也只會在模組內部,外部在使用時是完全看不到這些區塊、使用上也完全沒有差別的。
基本範例
這邊還是用例子來說明。
比如說下面是這個 MyMath.ixx
例子:
export module MyMath; export struct CVec2 { float x; float y; }; export CVec2 operator+(const CVec2& a, const CVec2& b) { return { a.x + b.x, a.y + b.y }; }
上面這個模組裡面現在只有一個簡單的 2D 向量,以及他的加法計算,所以檔案相當小。
但是如果擔心以後還要加入 3D、4D 的向量、甚至是矩陣,再加上還有更多對應的函式,會造成模組的介面定義的檔案過大的話,那就可以考慮先把這個 module interface unit 進行拆分。
拆分成 module partition
拆分的分類方法很多,這邊是先把它拆分成「型別」和「計算」兩個區塊,這樣會分成三個檔案。
首先,代表型別的區塊取名為「Types
」,檔案名稱是「MyMath-Types.ixx
」,內容如下:
export module MyMath:Types; export struct CVec2 { float x; float y; };
這邊可以看到,模組宣告的部分是「export module MyMath:Types;
」,「export module
」代表它是一個 module interface unit;而模組名稱是「MyMath:Types
」代表他是 MyMath
這個模組裡、叫做 Types
的區塊,所以代表這個 module unit 是一個「module partition interface unit」。
在檔案名稱的部分,這邊是建議用「-
」來連接模組的名稱和區塊名稱,這樣在某些編譯器會比較好找到產生出來的 BMI 檔案。
計算的區塊則取名為「Compute
」、檔名是「MyMath-Compute.ixx
」,內容如下:
export module MyMath:Compute; import :Types; export CVec2 operator+(const CVec2& a, const CVec2& b) { return { a.x + b.x, a.y + b.y }; }
這邊第一行的「export module MyMath:Compute;
」一樣是把這個 module unit 宣告成為一個 module partition interface unit,名稱是「MyMath:Compute
」。
接下來的「import :Types;
」則代表他要匯入 Types
區塊。會需要這樣匯入是因為在 module interface unit 裡面不同的 module unit 是彼此隔離的,所以如果不這樣匯入的話,就沒辦法存取到 CVec2
這個向量型別了。
這邊要注意的,是在匯入 module partition 的時候,不需要、也不能寫前面的模組名稱,只要以「:
」開頭、然後加上區塊名稱就可以了。
這兩個分區都完成後,本來的 MyMath.ixx
就可以改成:
export module MyMath; export import :Types; export import :Compute;
這邊就只需要把 Type
和 Compute
這兩個區塊都匯入再匯出就可以了;而這樣的主要模組的名稱會叫做「primary module interface unit」。
要注意的,是 primary module interface unit 必須要把所有 module partition interface unit 都匯入再匯出,不可以匯入了但是不匯出。比如說下面的語法就是錯誤的:
export module MyMath; export import :Types; import :Compute; //ERROR, must export
不過,實際上這樣寫在 Visual Studio 和 clang 好像都還是可以編譯過、只有 g++ 會報錯就是了?但是就算編譯器可以編譯過,基本上還是不應該這樣用。
另外一提,在模組分區內部裡面的內容,就算沒有加上 export
來匯出,由於本質上還是屬於同一個模組,所以也可以在模組內部看到。所以如果只是內部、要給其他分區用的東西,其實是可以不用匯出的。
使用模組
這邊完整的程式可以參考 GitHub 上的檔案(連結)。
這樣完成「MyMath
」這個模組後,要使用的時候其實和沒有分區塊時是完全一樣的:
import MyMath; int main() { CVec2 v1{ 1, 2 }, v2{ 3,4 }; auto v3 = v1 + v2; }
而前面也有提過,模組內部的區塊在外部是完全看不到的,所以這邊沒有辦法透過「import MyMath:Types;
」來只匯入其中一個模組區塊。
建置的流程、相依性
這樣拆分出模組分區後,必然會讓檔案變多。好處是比較好維護、也比較容易找到自己要的內容,但是相對地,也有可能會讓整個建置的流程變得更複雜。
像在上面的例子裡面,可以看到 MyMath:Compute
裡面有匯入 :Types
, 所以這也代表了在編譯 MyMath-Compute.ixx
的時候,會需要匯入 MyMath:Types
的 BMI 檔案;同樣的,在編譯 MyMath.ixx
的時候,也會需要所有 module interface unit 的 BMI 檔案。
所以最後建置的流程大概會是下面的樣子:
可以看到,整個專案有四個檔案,但是由於每個檔案都有相依性、所以基本上已經變成沒辦法平行建置的狀態了。
此外,也由於需要匯入 BMI 檔案,所以在拆分的時候也要小心、不可以弄出循環相依的狀況;比如說 在 M:A
裡面匯入 :B
、然後在 M:B
裡面也匯入 :A
的話,就會沒辦法編譯。
最後,這邊個人覺得比較奇怪,不知道為什麼這邊不設計成最後把所有 BMI 彙整成一個?
像這邊最後會產生出三個 BMI 檔案,其中 MyMath-Types.ifc
和 MyMath-Compute.ifc
的內容理論上應該可以彙整進 MyMath.ifc
、讓最後只需要一個 BMI 檔就好;但是實際上這邊編譯器並沒有這樣做,所以最後編譯 main.cpp
的時候還是需要去找到所有 BMI。
有分區狀況下的 Module Unit 類型
前面的例子基本上都只有 module interface unit,算是比較簡單的例子。而上面也可以看到,在使用模組有拆分區塊的時候,module interface unit 會分成下面兩種:
- primary module interface unit:最後彙整所有區塊的主要模組介面
- 模組宣告範例:
export module MyMath;
- 模組宣告範例:
- module partition interface unit:定義一個模組區塊的介面
- 模組宣告範例:
export module MyMath:Types;
- 模組宣告範例:
那如果想把實作拆分出來、變成 module implementation unit 呢?
這邊其實就是 Heresy 之前被搞得很亂的部分了…老實說,現在個人也還是不確定自己的理解是否正確。
以 Heresy 現在的理解,這邊應該還是一樣分成兩種:
- module implementation unit:模組(含分區)的實作
- 模組宣告範例:
module MyMath;
- 不會產生 BMI 檔案
- 模組宣告範例:
- module partition implementation unit:定義一個僅內部使用的模組區塊
- 模組宣告範例:
module MyMath:Impl;
- 需要產生 BMI 檔案
- 模組宣告範例:
但是這邊「module partition implementation unit」的意義其實應該是去定義一個只能在模組內部使用、不能匯出的分區,而不是針對某個分區提供實作的內容。
Heresy 一開始一直以為是後者,所以搞了很久… orz
而且這種 module unit 的編譯處理方法和一般的 module implementation unit 也會有所不同,也需要讓他產生 BMI 檔案,所以在理解上…恩,好亂。
模組的實作:Module implementation unit
延續上面的例子,如果想要在前面的 CVec2
裡面加入 length()
這個計算長度的函式的話,那 MyMath-Types.ixx
可以改成:
module; #include <cmath> export module MyMath:Types; export class CVec2 { public: float x; float y; float length() const { return std::sqrt(x * x + y * y); } };
這樣改之後,在主程式裡面就可以使用 CVec2::length()
沒問題了。
但是,如果不想把 CVec2::length()
寫在宣告裡面、想要拆分出來呢?這時候 MyMath-Types.ixx
可以改成:
export module MyMath:Types; export class CVec2 { public: float x; float y; float length() const; };
然後另外加入一個「MyMath-Types-Impl.cpp
」:
module; #include <cmath> module MyMath; float CVec2::length() const { return std::sqrt(x * x + y * y); }
要注意的是,雖然 CVec2
是由 MyMath:Types
提供的,但是最後還是會彙整成 MyMath
的一部分,所以這邊的模組宣告要寫「module MyMath;
」。
如果這邊寫「module MyMath:Types;
」的話,在編譯的時候是會出現 CVec2
沒有宣告、以及後續相關的錯誤的。
(這邊 Visual Studio 處理得比較不一樣,直接編譯可以過,但是在開啟專案的「掃描來源是否具有模組相依性」這個選項後,則也會出現同樣的錯誤)
而由於在 MyMath.ixx
裡面就已經有把 :Types
重新匯出了,所以這邊也不需要另外匯入 :Types
。
也就是說,module implementation unit 基本上不用去管分區,是針對整個模組去寫的;所以如果想要把程式碼做區隔的話,大概就是自己透過檔名來做區隔了。
而要注意的,也因為 module implementation unit 是針對整個模組來提供實作,所以他會需要整個模組的 BMI 檔案,也因此他必須要等 MyMath.ixx
編譯完後才能編譯、也就是得放在整個模組編譯流程的最後面。
另外,在這邊也可以看到所有的模組內的內容(每個分區的都可以),就算沒有透過 export
匯出的,在這邊也都可以使用!
僅限內部使用的模組分區:Module partition implementation unit
如果使用「module MyMath:Types;
」這樣的形式來宣告一個 module unit 的話,那它代表的意義就是前面提到的,他會產生一個只有模組內部可以匯入使用、不可以匯出的模組分區。
而由於這種特性,所以在 Visual Studio 裡面它基本上是被當作「internal partition」。
比如說,這邊可以寫一個 MyMath-Internal.cpp
的檔案,內容是:
module; #include <string> #include <sstream> module MyMath:Internal; import :Types; std::string to_string_impl(const CVec2& v) { std::ostringstream ss; ss << '<' << v.x << '/' << v.y << '>'; return ss.str(); }
由於模組的宣告是「module MyMath:Internal;
」、所以代表這個 module unit 是一個「module partition implementation unit」,他會產生一個 MyMath:Internal
的模組分區。
這邊定義了一個 to_string_impl()
的函式、裡面是透過 std::ostringstream
把 CVec2
轉換成字串。
要注意的,由於他還是一個 module implementation unit,所以這邊不能匯出任何東西,也就是不能有 export
這個關鍵字。
在這樣寫好了之後,在 MyMath
這個模組的其他分區裡面,如果需要使用這個函式的內容,就只要匯入 Impl
這個分區就可以了。
例如 MyMath.ixx
可以改成:
module; #include <string> export module MyMath; export import :Types; export import :Compute; import :Internal; export std::string to_string(const CVec2& v) { return to_string_impl(v); }
由於 MyMath:Internal
的模組分區不是 module interface unit,所以雖然可以匯入、但是不能重新匯出;也因此,外部是完全無法使用 to_string_impl()
這個函式的。
不過,在編譯這種類型的檔案的時候需要特別注意,他的編譯方法和一般的 module implementation unit 或 .cpp
檔案不一樣,也需要讓他產生 BMI 檔案才行。
在 Visual Studio 編譯
以 Visual Studio 來說,編譯這種檔案需要加入「/internalPartition
」(官方文件)這個參數,編譯的指令會是:
cl /std:c++20 /c /internalPartition MyMath-Internal.cpp
這樣他就會產生 MyMath-Internal.ifc
和 MyMath-Internal.obj
這兩個檔案。
如果是在 IDE 環境裡面的話,則是要在方案管理員裡面,找到這個檔案後,開啟他的「屬性」視窗,選擇左側的「C/C++」下的「進階」,然後在右邊的「編譯成」中選擇「編譯為 C++ 模組內部分割區 (/internalPartition)」。
附帶一提,這個檔案不能用 .ixx
當副檔名、否則編譯也會錯誤。
在 g++ 編譯
g++ 的話比較簡單,只要把它當成有模組的 C++ 原始碼檔案編譯就可以了。指令是:
g++ -std=c++20 -fmodules-ts -c MyMath-Internal.cpp
這樣除了同一個目錄下會有 MyMath-Internal.o
這個物件檔外,在「gcm.cache
」這個資料夾下就會產生 MyMath-Internal.gcm
這個 BMI 檔了。
在 clang 編譯
在 clang++ 這邊,應該是把它當成一般的 module interface unit 來編譯就可以了?指令是:
clang++ -std=c++20 -c -fmodule-output -fprebuilt-module-path=.
-x c++-module MyMath-Internal.cpp
這樣他就會產生 MyMath-Internal.pcm
和 MyMath-Internal.obj
這兩個檔案。
基本上這樣應該是可以正確編譯的?
而由於它也會產生 BMI 檔案、而且會由其他的模組分區匯入使用、因此在整個流程中的編譯順序會比較前面,和 module implementation unit 會在最後才編譯不同。
這邊完整的程式可以參考 GitHub 上的檔案(連結)。
Module Partition 的部分大概就是這樣了?這邊 Heresy 一開始由於對 module partition implementation unit 的想法完全錯誤,但是 Visual Studio 又可以接受,所以其實鬼打牆了好久,研究了好久才搞懂(希望沒搞錯)。
另外,在這樣的情況下,其實編譯的相依性又變得更複雜了…
參考:The orthogonality of module interface/implementation units and partitions