C / C++ 一直以來要拆分程式碼的檔案、或是使用函示庫,基本上都是透過 #include
header 檔案來讓編譯器在前處理(preprocessing)階段知道有那些定義、宣告的形式來進行的。
這樣的運作機制也維持幾十年了,但是一直以來也都有一些已知的缺點:
- 可能會因為巨集的重複定義,造成程式的執行會因為 include 順序而不同、甚至可能因為衝突而無法編譯
- 可能會讓函式庫不想被外部使用的內部實作被暴露出來
- 可能會拖慢編譯速度,當同一個 header 檔被多個
.cpp
引用的時候,同樣的內容會被重複處理很多次
而 C++20 為了解決這些問題,則是推出了新的「modules」(模組、C++ Reference)的概念、試著透過把 #include
替換成 import
來解決這些問題。
實際上當時 C++20 在發布的時候,也有人把它和 concept、ranges、coroutines 稱為「The Big Four」(參考)、作為 C++20 的四大亮點。
不過由於當時編譯器的支援還滿糟糕的,再加上會影響整個程式建置流程,所以 Heresy 一直都沒認真去碰。直到前陣子比較有空、才稍微認真地去研究了一下到底該怎麼使用,這邊就來稍微紀錄一下吧~
基本概念
在 Heresy 來看,C++20 的模組的基本概念是用「import MODULE_NAME;
」來取代「#include "LIBRARY.H"
」這種前處理器指示詞(preprocessor directives),讓編譯器不需要在前處理階段把整個外部的 header 檔案拉進來。
而用來取代 header 檔案讓編譯器知道有哪些東西可以用的,則是一種新的 binary 形式的「Built Module Interface」(BMI)檔案(gcc 似乎把它叫做「Compiled Module Interface」、參考);這樣的檔案在 Visual Studio 的副檔名會是 .ifc
、g++ 是 .gcm
、clang 則是 .pcm
。
使用新的模組設計的優點,大概有:
- 模組的設計是讓開發者可以自行決定哪些東西需要匯出讓外界使用的;開發者可以更明確地讓不想被外部使用的東西不會被外部存取到。
- 由於這個 BMI 檔是可以預先建置的 binary 檔案,所以如果在專案中要多次使用同一個模組的時候,就可以共用同一個已經產生的 BMI 檔、不需要每次都個別轉譯。
所以在大型專案、同樣的模組被多次使用的時候就不會像舊的 header 一樣要處理同樣的內容很多次,也因此是有可能縮短建置所需要的時間的。 - 由於使用模組的時候會先建立出 BMI 檔案、在設計上也會排除外部的巨集造成的影響,所以不會像使用 header 的時候、有可能會因為 include 順序的不同而造成巨集不同、進而讓程式運作不同的問題。
- 技術上,自己開發的函示庫如果是用模組的型式的話,應該是可以只釋出 BMI 和編譯好的 lib 檔案、完全不需要釋出可供人閱讀的程式碼(以前大多會要釋出
.h
)。
不過以目前測試的感覺來看,在編譯器的設計上應該不建議這樣做?
不過也由於引進了新的 BMI 檔案,所以整個程式建置的架構也會有所變化、會需要引入新的 BMI 檔案產生流程、而且檔案建置的相依性也比以前更多,所以很有可能會需要調整整個建置的流程。
再加上要建立模組也需要使用 import
、export
、module
等新語法,所以既有的程式如果要改用模組的形式、也是需要相當大幅度地修改程式碼的。
不過,由於 C++20 的模組可以和舊的 header 混合使用、並沒有一次全部轉換的必要性,算是有一定的彈性。
另一方面、由於模組對於巨集的處理方法算是有很大的變化,所以如果是巨集使用比較多的舊程式、要改寫成模組的形式可能會有很大的問題。
而為了方便把既有的程式以模組的形式來使用,C++20 也有提供「header unit」的形式,讓開發者可以快速地將既有的 header 檔案轉換成 BMI 的形式來匯入;這基本上算是一種感覺很接近 precompiled header、但是實際上不一樣的過渡方案,雖然在使用上會和真正使用模組不同,但是還是會有一些優點的。
這部分可以參考《Compare header units, modules, and precompiled headers》。
簡單的範例:使用 header
這邊先來看一個傳統使用 header 的程式的寫法。這邊是建立一個專案、裡面有三個檔案:MyModule.h
、MyModule.cpp
、main.cpp
。
MyModule.h
基本上就是定義有哪些東西可以使用:
#pragma once inline constexpr int iValue = 10; int getInt();
MyModule.cpp
則是去實作 MyModule.h
裡面定義的函式:
#include "MyModule.h" int getInt() { return iValue; }
而主程式 main.cpp
的內容則如下:
#include <iostream> #include "MyModule.h" int main() { std::cout << getInt() << "\n"; }
這樣的程式要建置的話,使用 Visual Studio 的 cl
指令會是:
cl /std:c++20 /EHsc /c MyModule.cpp cl /std:c++20 /EHsc main.cpp MyModule.obj
如果是使用 g++ 的話,則是:
g++ -std=c++20 -c MyModule.cpp g++ -std=c++20 main.cpp MyModule.o
如果是使用 clang 的話,則是:
clang++ -std=c++20 -c MyModule.cpp clang++ -std=c++20 main.cpp MyModule.o
這邊第一行都是去編譯 MyModule.cpp
這個檔案,然後加上「/c
」告訴編譯器不要執行連結階段;這樣的話會產生 MyModule.obj
這個物件檔(g++ 和 clang 是 MyModule.o
)。
再來,第二行則是編譯 main.cpp
、並把剛剛編譯出來的物件檔 MyModule.obj
連結進來、產生可執行檔。
這樣的編譯方法應該算是相當標準的形式了。
如果把最後連結的部分拆出來、畫成圖的話大概會是下面的狀況:
這邊的 MyModule.cpp
和 main.cpp
由於沒有相依性,所以先編譯哪個都可以。
簡單的範例:改成模組
如果要把上面的範例,改成用模組的形式來寫要怎麼做呢?首先,要先把 MyModule.h
改成定義模組介面的檔案。檔案副檔名的部分,Visual Studio 是建議用 .ixx
、clang 似乎是建議用 .cppm
、gcc 似乎沒有特別的建議。
這邊是選擇使用 Visual Studio 建議的 .ixx
來命名;修改完後的 MyModule.ixx
內容如下:
export module MyModule; inline constexpr int iValue = 10; export int getInt();
這邊程式的意義大致如下:
-
- 第一行的「
export module MyModule;
」是一種模組宣告,代表這個檔案是用來定義一個名稱是MyModule
的模組的介面,並且會匯出讓外部可以使用這個模組。 - 定義一個全域變數
iValue
、並指定他的值。不過這邊沒有加上「export
」,所以之後在外部會無法看到它的存在。 - 最後宣告的
getInt()
這個函式的介面。因為前面有加上「export
」,所以外部可以使用他。
- 第一行的「
由於這個檔案已經不是 header 檔案的性質了,所以也就不需要使用「#pragma once
」這類的 include guard 語法(維基百科)了。相對地,這種拿來定義模組介面的檔案會需要經過編譯器處理、不像 header 檔一樣不需要任何處理。
MyModule.cpp
的部分,因為建置過程的時候產生的物件檔可能會和 MyModule.ixx
衝突,所以建議把名字改掉。這邊是改名成 MyModule_impl.cpp
, 它的內容如下:
module MyModule; int getInt() { return iValue; }
這邊只有把本來的「#include "MyModule.h"
」改成「module MyModule;
」、代表這個檔案是在針對 MyModule
這個模組提供實作的部分;而其他的部分基本上都不用動。
最後,主程式 main.cpp
的部分:
#include <iostream> import MyModule; int main() { std::cout << getInt() << "\n"; }
這邊也只要把「#include "MyModule.h"
」改成「import MyModule;
」,代表要匯入 MyModule
這個模組來使用就可以了。
和前面使用 Header 的版本一個很大的不同,是使用模組的話,在 main.cpp
這邊是沒有辦法使用沒有匯出的 iValue
這個變數的!如果試著把它加進去的話,是會看到變數未宣告的錯誤的。
Visual Studio 的建置指令(2022 17.12)
而改成這樣的程式要怎麼建置呢?以 Visual C++ 來說,建置的指令是:
cl /std:c++20 /EHsc /c MyModule.ixx cl /std:c++20 /EHsc /c MyModule_impl.cpp cl /std:c++20 /EHsc main.cpp MyModule.obj MyModule_impl.obj
這邊可以看到,MyModule.ixx
這個檔案也是要丟給編譯器處理的。
在 Visual C++ 的建置流程中,大致上可以把它視為一般的 C++ 原始碼檔案,只不過當編譯器發現他是用來定義模組介面的檔案的時候,除了會產生物件檔(.obj
)外,也會產生模組的 BMI 檔案(.ifc
)。
所以,在使用 cl
編譯 MyModule.ixx
這個檔案後,在同一個資料夾下會出現「MyModule.ifc
」和「MyModule.obj
」這兩個檔案。
而之後編譯 MyModule_impl.cpp
和 main.cpp
的時候,都會需要有 MyModule.ifc
這個檔案、才能正確地匯入 MyModule
這個模組來完成編譯,所以 .ixx
這類的模組介面定義檔在建置的流程中要放在前面。
由於編譯 MyModule.ixx
的時候也會產生 MyModule.obj
這個物件檔,所以前面才會建議把本來的 MyModule.cpp
改名成 MyModule_impl.cpp
、避免產生同名的物件檔覆蓋掉之前的檔案。
如果是使用 Visual Studio 2022 的 IDE 環境的話,由於目前對於模組的支援算很完整了,所以只要把專案的「C++ 語言標準」設定成「ISO C++20 標準 (/std:c++20)」或是「預覽 – 最新 C++ 工作草稿中的功能 (/std:c++latest)」後,按照上面的副檔名來建立檔案就可以了。
而實際上,在方案總管的右鍵選單的「加入」裡面,現在也有「模組」的選項了;選擇這個選項的話,預設就會是「C++ 模組介面單位 (.ixx)」。
如果副檔名不對的話,可能就得針對檔案去修改個別檔案的設定。修改方法是在檔案的屬性視窗裡:
- 確認「一般」的「項目類型」是設定成「C/C++ 編譯器」(不是的話要在修改後按套用)
- 然後在左邊「C/C++」的分類,然後在「進階」裡面的「編譯成」會有「編譯為 C++ 模組程式 (/interface)」的選項可以選擇。
這邊修改的時候建議先把屬性視窗上的「平台」和「組態」都設定成「所有平台」和「所有組態」、確認在不同的建置環境設定是一致的。
之後再寫程式的時候,Visual Studio IDE 的功能大多也都會可以正常使用,IntelliSense 大致上也是可以正常運作的(不過官方有說目前是實驗性模組)。
g++ 的建置指令(14.2)
而 g++ 的話,建置的指令則是:
g++ -std=c++20 -fmodules-ts -c -x c++ MyModule.ixx g++ -std=c++20 -fmodules-ts -c MyModule_impl.cpp g++ -std=c++20 -fmodules-ts main.cpp MyModule.o MyModule_impl.o
這邊應該是因為 g++ 就算在 14.x 的 C++20 標準還沒有正式支援模組,所以就算已經加上「-std=c++20
」了,還是得另外加上「-fmodules-ts
」這個參數才會支援模組的語法。
另外,由於 g++ 不認得 .ixx
這個副檔名,所以這邊是透過 -x c++
來告訴編譯器要把 MyModule.ixx
當成 C++ 原始碼檔案來處理。
(如果把 MyModule.ixx
改名成 MyModule.cpp
的話可以不用加)
在編譯 MyModule.ixx
後,會在同一個目錄下產生 MyModule.o
這個物件檔;CMI(BMI)檔的部分會叫做「MyModule.gcm
」,但是不會在同一個目錄下、而是會被放到「gcm.cache
」這個資料夾下,這也是 g++ 之後尋找 BMI 檔的預設目錄。
之後在編譯 MyModule_impl.cpp
和 main.cpp
的時候,也都需要讓 g++ 能找到 MyModule.gcm
這個 BMI 檔案、才能正確地匯入 MyModule
這個模組。
clang 的建置指令(18.1)
clang 的話,他的建置指令可以寫成下面的形式:
clang++ -std=c++20 -c -fmodule-output -x c++-module MyModule.ixx clang++ -std=c++20 -c -fprebuilt-module-path=. MyModule_impl.cpp clang++ -std=c++20 -fprebuilt-module-path=. main.cpp MyModule_impl.o MyModule.o
這邊編譯 MyModule.ixx
的時候,要先透過「-x c++-module
」告訴編譯器要把這個檔案當作 C++ 的模組來處理;如果副檔名是 clang 建議的 .cppm
的話則不需要這個參數。
然後要編譯出 BMI 和物件檔的話,則要加上「-c -fmodule-output
」這兩個參數;這樣會產生出 MyModule.pcm
和 MyModule.o
兩個檔案。
另外,官方文件(連結)裡面還有另一種兩段式建置的方法,是使用「--precompile
」這個參數;他會先只會建置出一個 BMI 檔、然後之後再另外產生物件檔案,不過這邊就先不討論這個方法了。
之後在編譯 MyModule_impl.cpp
和 main.cpp
的時候,由於 clang 似乎沒有預設的 BMI 搜尋路徑,所以會需要透過「-fprebuilt-module-path=.
」這個參數來告訴編譯器要在同一個路徑下找 BMI 檔案來用。
這邊的建置流程如果把連結的部分獨立出來後再畫成流程圖的話,會是下面的狀況:
可以看到,因為 .ixx
也需要編譯、然後產生的 BMI
檔案在編譯其他檔案的時候又要用到,所以整個流程和相依性都會變得更複雜。
而如果有多個模組、模組之間又有匯入別的模組的話,其實整個建置的順序會很難判斷。也因此,以往 C++ 同一個專案下要平行建置很簡單,但是一旦引進模組後,要判斷檔案之間的相依姓、決定建置順序就會變得相當地複雜…
所以其實 C++ 標準的 P1689R5(PDF)也定義了一個 JSON 的格式標準來描述檔案之間的相依性;Visual Studio 和 clang 應該是都有對應的方法可以產生這樣的報告。
而 Visual Studio 基本上會自己去管理建置順序,但是如果要自己寫 Makefile 的話,感覺就會有點想哭了?
C++20 module 的基本使用概念大概是這樣吧?當然,這邊沒有去提到模組的詳細語法,而實際上在開發模組的部分還有很多細節,這些就等之後再來寫了。
現階段 Heresy 這邊在使用的 Visual Studio 2022 和 g++12 都算是有支援模組了,所以確實可以考慮開始使用了?
不過,老實說個人是覺得還有不少地方可能都還得試試看,才知道到底該怎麼修改?尤其實在 g++ 的環境下建置要怎麼去處理建置的優先序…想想還滿頭大的。
而 C++ 標準函式庫的模組版本還要等到 C++23 才有正式支援(參考),這邊也算是讓人覺得有點可惜的。
參考: