C++20 引進的模組的架構,基本上算是針對整個 C++ 使用外部程式的架構做了相當大的調整。而如果想要使用模組的話,在應用程式端其實算是相對簡單,大部分的狀況只要把 #include <HEADER>
改成 import MODULE;
就可以了。
但是問題是:現階段有哪些函示庫有提供模組的版本呢?
老實說,由於程式語法完全不一樣,如果還要考慮相容性問題的話,一般既有的函式庫大概都不會真的用模組的語法來改寫吧?畢竟一改下去,就變成只能相容 C++20 的編譯環境了…
那這樣該怎麼使用模組的架構呢?C++20 這邊也提供了一個感覺像是折衷方案的「header unit」,可以把既有的 header 檔案在不改寫的狀況下、轉換成模組的 BMI 檔案來匯入。
至於要怎麼用呢?這邊就來簡單看一下。
把 header 檔當成 header unit 使用
假設現在有一個自己的 header 檔「lib.h
」:
#pragma once #ifndef DEF_VAL #define DEF_VAL 10 #endif inline int getVal() { return DEF_VAL; }
本來的程式寫法是:
#include <iostream> //#define DEF_VAL 100 #include "lib.h" int main() { std::cout << DEF_VAL << " => " << getVal() << '\n'; }
那如果想要改成 import header unit 的型式的話,只要把程式改成:
import <iostream>; //#define DEF_VAL 100 import "lib.h"; int main() { std::cout << DEF_VAL << " => " << getVal() << '\n'; }
基本上,只要把 #include
改掉就可以了!
不過由於要使用 import
匯入的話,會需要先將 header 檔透過編譯器轉換成 BMI 檔案,所以這邊會需要先手動編譯。
巨集運作模式的不同
不過,雖然 header unit 的使用會非常接近本來的 header 檔案,但是在巨集(前處理指示詞)的部分,處理方法還是有差別的。
像是上面的範例程式,基本上在主程式裡面是可以使用 lib.h
裡面透過 #ifdef
的形式來有定義 DEF_VAL
的這個變數。
但是如果把主程式的「//#define DEF_VAL 100
」的註解拿掉的話,那本來用 include header 的時候,輸出的結果就會變成「100 => 100
」。
但是如果改成用 import header unit 的時候,把註解拿掉的話則會出現巨集重複定義的狀況,執行的結果會是「10 => 10
」,基本上就是在主程式定義的 DEF_VAL
被 lib.h
裡的定義覆蓋過去的狀況。(這邊的處理狀況在不同的編譯器也會不一樣。Visual Studio 的 cl 和 clang 都只是警告、還是可以編譯,但是 g++ 則是直接回報錯誤。)
這是因為這邊會需要把 header 檔案轉譯成 BMI 檔,所以 header 內部不會受到外部巨集的影響;也因此,lib.h
裡面雖然本來有透過 #ifdef
的形式來判斷外部是否有定義 DEF_VAL
這個巨集,但是會因為在轉譯成 BMI 後就沒辦法抓到外部的定義了,所以會當變成沒有定義過的狀態。
而如果真的要設定巨集的話,就必須透過編譯參數的形式、在把 header 轉譯成 BMI 的階段指定了。
所以基本上,如果是要使用的 header 必須要仰賴外部的巨集來控制參數的話,那可能就得小心使用了。
使用命令建置
Visual C++ 的建置指令
以 MSVC 來說,要建置 <iostream>
這個 header 的 header unit 的話,可以使用下面的指令:
cl /std:c++20 /EHsc /exportHeader /headerName:angle iostream cl /std:c++20 /EHsc /exportHeader lib.h
這邊基本上就是要透過「/exportHeader
」這個參數(官方文件),來告訴編譯器現在是要產生 header unit。
第一行是去處理 lib.h
。這邊由於是在同一個目錄下,所以很簡單、直接給他檔案名稱就好,算是最簡單的用法;執行結束後他會在同一個資料夾下產生「lib.h.ifc
」這個 BMI 檔案、不會有物件檔。
而第二行要去處理 <iostream>
的時候,由於這個檔案是編譯環境提供的、要給完整的路徑會很麻煩;所以這邊可以透過「/headerName:angle
」(官方文件)這個參數、告訴編譯器該怎麼去找這個檔案。
在執行完成後,他會在目前的目錄產生「iostream.ifc
」這個檔案。
而「/headerName
」有兩種值、分別對應本來 include header 時的檔案搜尋方法:
/headerName:quote
:如果本來是#include "lib.h"
的話,就用這個參數/headerName:angle
:如果本來是#include <lib.h>
的話,就用這個參數
所以像是第一行其實也可以寫成「cl /std:c++20 /EHsc /exportHeader /headerName:quote lib.h
」。
另外,如果需要同時處理多個 header 檔案也可以直接把檔名加在後面,例如:
cl /std:c++20 /EHsc /exportHeader /MP /headerName:angle iostream vector span
這樣由於又加上「/MP
」來平行化處理,所以就可以快速地建置出 iostream.ifc
、vector.ifc
、span.ifc
三個 BMI 檔了。
而最後編譯主程式的時候,則是需要透過「/headerUnit
」(官方文件)手動指定 header unit 的檔案,這點算是比較麻煩的;這邊的指令如下:
cl /std:c++20 /EHsc /headerUnit lib.h=lib.h.ifc /headerUnit:angle iostream=iostream.ifc App.cpp
這邊和前面的「/headerName
」一樣,可以設定是「quote
」或「angle
」;如果都不加就是只會在目前目錄找。
g++ 的建置指令
在 g++ 14 要建立 header unit 的 BMI 檔的話,指令應該是:
g++ -std=c++20 -fmodules-ts -fmodule-header lib.h g++ -std=c++20 -fmodules-ts -x c++-system-header iostream
要處理同個資料夾下的檔案的話,只要加上「-fmodule-header
」這個參數就可以了。
但是如果是要處理 <iostream>
這種系統的檔案的時候,似乎是得透過「-x c++-system-header
」這個指令,告訴 g++ 他是系統的 header 檔案(參考)。
而 g++ 產生的 BMI 檔一樣會是在「gcm.cache
」這個資料夾下。不過應該是為了方便搜尋、所以 header unit 的 BMI 檔案會在這個資料夾下以對應原來路徑的樹狀結構來配置;例如上面產生的兩個檔案完整的路徑會是:
./gcm.cache/,/lib.h.gcm
./gcm.cache/usr/include/c++/14/iostream.gcm
之後要編譯主程式的時候,指令則是:
g++ -std=c++20 -fmodules-ts App.cpp
這邊不需要特別去指定 BMI 檔案算是比較方便的了。
clang 的建置指令
clang 的部分有點尷尬,是官方文件直接寫「The support for header units, including related command line options, is experimental」…感覺上,應該是還沒做完?
不過要用的話,現在還是可以用的,編譯的指令是:
clang++ -std=c++20 -fmodule-header lib.h clang++ -std=c++20 -fmodule-header=system -xc++-header iostream
這邊要用的基本參數是「-fmodule-header
」,和 g++ 是一樣的。
但是針對系統的檔案,這邊則是使用「-fmodule-header=system
」就可以了;只是因為 <iostream>
沒有副檔名、所以要加上「-xc++-header
」來告訴編譯器他是 C++ 的 header。
這樣執行完後,資料夾下會產生「lib.pcm
」和「iostream.pcm
」這兩個檔案。
之後主程式的編譯指令則是:
clang++ -std=c++20 \ -fmodule-file=iostream.pcm \ -fmodule-file=lib.pcm \ App.cpp
這邊 clang 沒辦法自己找到 header unit,所以要透過「-fmodule-file
」來手動指定要用那些 .pcm
檔案。
Visual Studio IDE 設定
在 Visual Studio 裡面要讓一個 header 檔案產生 header unit,需要針對專案內的 header 檔案個別進行設定。設定的方法如下:
- 確定專案的「C++ 語言標準」(專案的屬性、一般)是 C+20 或 C++ latest。
- 在「方案總管」找到要產生 header unit 的 header 檔案,點選右鍵、在右鍵選單選擇「屬性」、叫出屬性視窗。
- 把「一般」的「項目類型」,從預設的「C/C++ 標頭」改成「C/C++ 編輯器」,然後按下「套用」。
- 此時左邊會出現「C/C++」的選項,展開之後點選「進階」,然後把右邊的「編譯成」改成「編譯為 C++ 標頭單位 (/exportHeader)」;之後再按「確定」關閉屬性視窗。
之後只要在把程式中的 #include "lib.h"
換成 import "lib.h";
就可以了,這邊不需要另外去處理 /headerUnit
這個參數。
不過比較麻煩的,是 Heresy 這邊不知道該怎麼透過 IDE 來個別處理標準函示庫、或是其他系統的 header?
根據官方的文件(連結),這邊的做法似乎是讓他自己去找、自己來處理(這邊先看 Approach 2)。設定的方法是:
在專案的屬性裡,選取左側「C/C++」下的「一般」,然後:
-
- 將「掃描來源是否具有模組相依性」改成「是」
- 將「將 Include 轉譯為 Import」改成「是 (/translatelnclude)」
下圖就是這邊的設定介面:
這樣設定完之後,之後專案裡面就可以匯入其他函式庫的 header 檔了!這邊應該沒有限制要是系統提供的函式庫,應該就算是其他第三方函式庫、只要能透過指定的 Include 路徑找的到的都可以。
這樣的設定方法感覺很方便,但是微軟不保證這種設定方法能讓每個 header 檔案再轉換成 header unit 的時候都只處理一次;尤其在大型方案裡面、專案多的時候,很有可能會變成是每個專案各自處理 header unit 的狀態。
另外一個方法(Approach 1),是建立一個新的靜態函示庫(static library)專案來做;他的設定方法是:
- 加入一個新的 C++ 空專案
- 加入一個
.cpp
檔案來 include 需要的標準函式庫的 header - 修改專案屬性
- 將專案屬性的「一般」內的「組態類型」改成「靜態函式庫 (.lib)」
- 將「C++ 語言標準」設定成和本來的專案一樣(C++20)
- 左邊點選「C/C++」下的「一般」
- 將「掃描來源是否具有模組相依性」改成「是」
- 將「將 Include 轉譯為 Import」改成「是 (/translatelnclude)」
- 在方案總管選取本來的專案,在滑鼠的右鍵選單選「加入」、「參考」,然後勾選上面新加入的專案。
這樣設定完成後,建置的時候在靜態函示庫專案就會自動產生對應的 .ifc
這種 BMI 檔案,讓其他專案可以使用了;而只要把這個專案加入參考,Visual Studio 也會自動去從這個專案來找需要的 BMI 檔案,不需要再做其他的設定就可以匯入 header unit 了。
這樣的好處,是這個新加入函示庫方案因為基本上只用來 include header 檔案,所以基本上不會修改,之後也不太會需要重建;所以如果方案裡面的專案很多的話,就可以共用這個函示庫專案。
使用 header unit 的好處有什麼呢?主要應該還是透過獨立/事先產生 BMI 檔案、來減少後續的建置時間了。而由於它要修改的部分相對少,所以基本上可以更簡單地、甚至自動完成。
此外,他也還有保護外部函示庫內部巨集的能力,但是由於實際上有的時候還得靠這樣來設定某些東西,所以這點是好是壞其實很難說就是了。
而雖然它的形式和「precompiled header」很像,但是根據微軟的說法是 header unit 的速度雖然比不上真正的模組,但是會比 precompiled header 來的更快、運作得更好。這部分可以參考微軟的《Compare header units, modules, and precompiled headers》。
不過,在《C++20 Modules: Private Module Fragment and Header Units》這邊倒是有提到,根據實作的不同,也並非所有 header 都可以轉成 header unit 來匯入,這點可能就得再看看了。
同一個 header 可能會被處理多次
Header Unit 基本的使用大概就是這個樣子了?不過老實說,個人是還有些問題沒有釐清…
其中一個主要的問題,應該還是在流程上到底怎麼去產生 header unit 需要的 BMI 檔案會比較合適了?
比如說,有一個 header 檔案 lib1.h
的內容是:
#pragma once #include <iostream> int getVal1(){ return 1; }
然後另一個 header 檔案 lib2.h
的內容是:
#pragma once #include <iostream> int getVal2(){ return 2; }
這如果把這兩個檔案都轉換成 header unit 的話,由於 lib1.h
、lib2.h
內都有去引入 <iostream>
,所以實際上 <iostream>
的內容會被處理兩次、然後在 lib1.h.ifc
與 lib2.h.ifc
各有一份。
如果像這樣的情境變多的話,那其實 header 被重複處理的可能性還是滿高的;而且這個狀況實際上發生的可能性應該也算是相當高。
一般這個問題可能也還好,就是讓編譯時間變久、讓中繼檔案變大而已;但是如果狀況再複雜一點,裡面某個 header unit 又有透過巨集去修改某個 header 的設定的話、就可能造成在不同的 header unit 裡面理論上應該是同樣的東西、但是狀態不一致的狀況…那到時候頭就很大了。
關於 header unit 大概就先這樣了?總覺得,雖然大概知道怎麼用了,但是到底怎麼用比較好應該還是得花時間摸索的了。
比如說如果是根據微軟建議的方法、透過修改 Visual Studio 的專案設定讓他自己去找 header 來建置 header unit 的話,在內部的實作應該是會去針對每個 header 檔有引入的 header 個別建立出 BMI 檔案之後,再用匯入的形式來使用。
所以其實在建置 header unit 的過程與結果都會和直接用 cl /exportHeader
這樣的指令去編譯差很多,而在專案的中繼檔資料夾(例如 x64\Debug
)中,就可以看到會有非常多的 .ifc
檔。
不過,總覺得運作模式和想像得好像不完全一樣?這部分可能之後還要再花點時間研究了…
參考:C++20: More Details about Module Support of the Big Three