前面 C++20 Modules 的說明與測試基本上都是單一專案、而且檔案都在同一個目錄的狀況下,所以在編譯指令的部分其實算是相對單純的。
但是實務上真的要開發專案的時候,應該會有很多包含函示庫、應用程式在內的專案;這個時候要怎麼讓編譯器可以找到 BMI 檔案其實會是個問題了。
這篇就是來記錄一下,Heresy 這邊自己在測試多專案的 C++ Modules 時的狀況。
先以結論來說,在以 Heresy 這邊需要考慮 g++(Makefile)/ MSVC 跨平台的狀況下,個人目前覺得比較好的方法可能是:
- 將 Header Unit 的部分透過一個共用的靜態函式庫專案來處理
- 在 g++ 的環境下、把產生的 CMI 檔(
.gcm
)集中到同一個目錄
這邊設計出來比較大的範例方案,可以參考 GitHub 上的「multi_projects」(連結)。
專案的部分,這邊有四個專案:
libStdHeader
:靜態函式庫、用來處理標準函式庫 header unit 的專案LibMyMath
:靜態函式庫、以子模組概念建置的MyMath
系列模組libMyGraph
:靜態函式庫、有用到MyMath.Types
的MyGraph
模組app1
:會使用到前面幾個函式庫的應用程式
而模組與 header unit 的相依性如下:
這個方案基本上是透過 Visual Studio 來設計的,但是這邊有做成 MSVC、g++ 和 clang 都可以編譯,完整的編譯指令可以參考 README.md
內的說明。
其中 MSVC 和 clang 都有「把 BMI 放在各自資料夾」和「把 BMI 集中在共用資料夾」兩種指令;而 g++ 的部分礙於本身的設計,感覺上應該沒辦法做到把 BMI 放在各自資料夾的架構?
不過這邊的執行指令只是參考、或是說可行性驗證用的,實際上大型專案不太可能靠這種指令來個別編譯。
由於 Heresy 這邊工作的需求,所以基本上 Windows 會是以 Visual Studio IDE 為主、不會去碰到 CLI 的部分;而 Linux 的部分,則會是透過 Makefile 來搭配 g++ 做建置。
所以這邊這邊也有針對 g++ 寫簡單的 Makefile,所以在 Linux 環境也可以透過 make 這個指令來建置。(Makefile 個人也沒熟到哪去就是了)
Visual Studio IDE
在使用 Visual Studio 的 IDE 環境進行開發的話,要使用 Module 非常簡單,只要把 C++ 語言標準設定成 C++20 以上就可以了。
比較特別的專案設定,只有針對 Header Unit 的部分,在這邊就是按照官方建議、把 header unit 的部分獨立出來成 一個靜態函式庫(static library):libStdHeader
。
這個函式庫專案是專門用來處理 header unit 的專案的、裡面只有一個 std.cpp
,內容是:
import <cmath>; import <iostream>;
它的用途只是告訴 Visual Studio 需要哪些 header 檔案,所以連真正建置出來的 .lib
檔案都不是很重要。
和一般的專案不同,需要在專案屬性中,把「C/C++」下「一般」的「掃描來源是否具有模組相依性」和「將 Include 轉譯為 Import」這兩個選項都開啟。
這樣 Visual Studio 就會根據專案內程式的需要,去建立出必要的 header unit 的 BMI 檔案了;像這邊他理論上就會去建立 <cmath>
和 <iostream>
的 BMI 檔了。(實際上會建立出更多)
之後其他有要匯入 header unit 的專案,只要把這個專案加入參考、就可以找到需要的 header unit BMI 檔案了。
(在「方案總管」中,在專案上按下滑鼠右鍵、選取「加入」、「參考」)
像在這個範例方案裡面,LibMyMath
和 app1
都有直接匯入 header unit,所以只要讓這兩個專案去參考 libStdHeader
,就可以共用同一份 header unit 的 BMI 了。
而如果需要其他第三方函式庫的時候,也可以繼續用這個專案來做處理(不過專案名稱可能就要改一下會比較好了)。
這邊可以另外建立一個 .cpp
檔案、或是繼續沿用 std.cpp
也可以。
要注意的,是如果需要指定 header 檔的路徑的話,會需要設定「C/C++」下「一般」的「其他 Include 目錄」;而且這個設定除了 libStdHeader
這個專案要設定之外,有要匯入 header unit 的專案似乎也需要設定。
g++ 搭配 Makefile
在使用 Visual Studio IDE 的時候算是相當簡單,微軟會自動處理大部分的設定,不太需要擔心設定的問題。
而在使用 g++ 和 Makefile 的時候,其實就必須要自己考慮很多問題了;這邊主要應該會是:CMI(BMI)路徑的設定、編譯順序的相依性。
下面就依序來研究這些問題。
g++ 的 CMI 路徑設定
和 MSVC 與 clang 不同,g++ 會自己去管理產生出來的 .gcm
檔案。他預設是放在「gcm.cache
」這個資料夾下,如果是 header unit 的話甚至還會有對應原來路徑的樹狀結構。
因此,當多專案在不同資料夾的時候,要怎麼讓 g++ 找到需要的 .gcm
檔就是一個滿大的問題了…
g++ 本身似乎也沒有提供相對簡單的參數可以指定 CMI 資料夾路徑(MSVC 是「/ifcSearchDir
」、clang 是「-fprebuilt-module-path
」),需要透過「Module Mapper」(官方文件)才能做到。
研究了好一陣子,最後是覺得這邊比較簡單的方法,可能會是參考《How to modify gcm.cache path when using gcc-11 to build c++20 modules?》這篇的回答,使用感覺很特別的指令來讓他去設定預設的 CMI 快取資料夾:
'-fmodule-mapper=|@g++-mapper-server -r'$(MAPPER_DIR)
這邊基本上應該就是去執行一個 g++-mapper-server
起來,然後透過「-r
」來設定路徑。
但是老實說,Heresy 看不懂這個參數的寫法…尤其是那兩個「'
」。
完整的指令會像下面的樣子:
g++ -std=c++20 -fmodules-ts \
'-fmodule-mapper=|@g++-mapper-server -r'../bmi \
-c -x c++ MyMath.Types.ixx
這樣去編譯 MyMath.Types.ixx
的話,編譯器就會去 ../bmi
這個資料夾找需要的 .gcm
檔案,同時如果有產生 .gcm
檔的話,也會放到 ../bmi
這個資料夾裡面。
而如果是 header unit 的話,也一樣會有對應的樹狀結構。
這樣一來,../bmi
這個資料夾的性質就和本來的 gcm.cache
一樣,只是會變成是所有專案共用同一個資料夾了。
不過,這樣設定雖然對標準模組沒有問題,但是對於 header unit 還是會有問題,這點就之後再提了。
Makefile:一般的專案
在多專案的環境下,由於很多參數設定需要保持一致,所以這邊是在根目錄下放了一個名為 config.mk
的設定檔:
# c++ compiler C++ = g++ AR = ar rcs # Path PATH_LIB = ../lib PATH_BMI = ../bmi # C++ Flags CFLAGS = --std=c++20 -fmodules-ts CMI_MAPPER = '-fmodule-mapper=|@g++-mapper-server -r'$(PATH_BMI) CMODULE = -c -x c++ # Helper functions GCM_LIST = $(patsubst %.o,$(PATH_BMI)/%.gcm,$(OBJECTS))
這邊基本上是將指令、參數都設定成為變數,方便之後使用與維護。
而最後的 GCM_LIST
則是用來把 OBJECTS
這個檔案清單(全部都是 .o
)產生對應的 CMI 檔案列表,是用來給 make clean
清除檔案用的。
如果以比較複雜的 libMyMath
來看的話,他的 Makefile 內容如下:
include ../config.mk TARGET = libMyMath.a OBJECTS = \ MyMath.o \ MyMath.Types.o \ MyMath.Compute.o \ MyMath.IO.o $(TARGET): $(OBJECTS) $(AR) $@ $(OBJECTS) cp -f $@ $(PATH_LIB) MyMath.o: MyMath.ixx MyMath.Types.o MyMath.Compute.o $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.ixx MyMath.Types.o: MyMath.Types.ixx $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.Types.ixx MyMath.Compute.o: MyMath.Compute.ixx MyMath.Types.o $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.Compute.ixx MyMath.IO.o: MyMath.IO.ixx MyMath.Types.o $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.IO.ixx clean: rm -f $(OBJECTS) $(TARGET) rm -f $(GCM_LIST) $(PATH_LIB)/$(TARGET)
這邊的建置的指令就盡量使用前面 config.mk
定義的變數來寫了。
而在建置規則上,由於 g++ 編譯模組的時候,會同時產生 .o
和 .gcm
檔案,而這邊算是稍微簡化一點,簡單地拿 .o
當作建置的目標以及相依性的參考。
比如說這邊要產生 Math.IO.o
的規則,就是寫他是依賴 MyMath.IO.ixx
與 MyMath.Types.o
兩個檔案,如果這兩個檔案有被修改,那他就會重新建立。
但是實際上,要編譯 MyMath.IO.ixx
的時候其實並不需要 MyMath.Types.o
、而是需要 MyMath.Types.gcm
;不過因為 .gcm
的檔案會在別的資料夾、處理上比較麻煩,所以這邊就拿 .o
來湊數了。如果要嚴謹一點,也可以改寫成:
MyMath.IO.o: MyMath.IO.ixx $(PATH_BMI)/MyMath.Types.gcm $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.IO.ixx
這部分完整的範例可以參考 GitHub 上的檔案(連結)。
而由於建置 .ixx
檔案的指令基本上也是相同的,所以其實也可以用 pattern rules 的形式來簡化:
include ../config.mk TARGET = libMyMath.a OBJECTS = \ MyMath.o \ MyMath.Types.o \ MyMath.Compute.o \ MyMath.IO.o $(TARGET): $(OBJECTS) $(AR) $@ $(OBJECTS) cp -f $@ $(PATH_LIB) MyMath.o: MyMath.Types.o MyMath.Compute.o MyMath.Compute.o: MyMath.Types.o MyMath.IO.o: MyMath.Types.o %.o: %.ixx $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) $< clean: rm -f $(OBJECTS) $(TARGET) rm -f $(GCM_LIST) $(PATH_LIB)/$(TARGET)
這樣他就會把所有的 .ixx
都用同樣的指令編譯成 .o
;而有額外的相依性,也只要針對個別的建置目標來寫就好了。
而如果要進一步共用的話,則也可以把建置的規則移到 config.mk
去、讓他在所有專案共用。
這部分的寫法可以參考 GitHub 上的另一個分支(連結)。
Makefile:Header unit
Header unit 的部分算是相當麻煩的。
在 Visual Studio 的 IDE 環境下,可以讓他去掃描專案內的程式碼、自動幫有用到的 header 檔案產生 BMI 檔案,算是相當方便。
但是在 g++ 中,似乎沒有類似的功能可以使用?所以這邊並不會去用到 std.cpp
這個檔案,而是得把要處理的 header 檔清單寫在 Makefile 內。
目前這邊搭配 ChatGPT 寫出來的 Makefile 內容是下面的樣子:
include ../config.mk SOURCES := cmath iostream TARGETS := $(addsuffix .gcm, $(SOURCES)) FIND_CMD := find $(PATH_BMI) -name all: $(TARGETS) clean: @for target in $(TARGETS); do \ $(FIND_CMD) $(basename $target) -exec rm -f {} +; \ done %.gcm: $(eval GCM_FILE := $(notdir $@)) $(eval FILE_PATH := $(shell $(FIND_CMD) $(GCM_FILE))) @if [ -z "$(FILE_PATH)" ]; then\ echo "Generating $(GCM_FILE)"; \ $(C++) $(CFLAGS) $(CMI_MAPPER) -x c++-system-header $*; \ else \ echo "Found $(FILE_PATH), skip generate"; \ fi
要編譯的 header 檔案清單是 SOURCES
,如果還有其他 header 檔案也要轉換只要加在後面就可以了。
建置目標的清單是 TARGETS
,這邊會把 SOURCES
的所有檔案都再加上 .gcm
、產生一份新的清單;以這邊來說就是「cmath.gcm iostream.gcm
」。
而實際建置的規則,就是要針對 TARGETS
所有的 .gcm
都執行同樣的指令了。
但是由於這邊要拿來編譯的檔案是標準函式庫的檔案,所以很難去判斷來源路徑、有沒有更新,所以變成所有的建置目標都沒有相依性。
為了避免每次都要重新建置,這邊的做法是透過 FIND_CMD
這個定義好的指令去 PATH_BMI
找有沒有已經產生出來的 .gcm
檔案,如果有的話就跳過、沒有才真的去建立。
而 clean 的條件也是一樣會去 PATH_BMI
找到檔案名稱符合的 .gcm
檔來刪除。
這邊為了相容於其他函式庫,所以也有考慮指定的 header 可能包含路徑的問題,理論上應該是可以在大部分狀況下運作才對。
比如說如果要自己建置的 Boost 放在「/usr/local/include/
」、然後想要建置 boost/format.hpp
的話,就可以把 SOURCES
修改成:
SOURCES := cmath iostream boost/format.hpp
然後再額外加上一行:
CFLAGS += -I /usr/local/include/
這樣就可以告訴編譯器要去哪邊找 header 了。
不過這個 Makefile 也不是完美的。由於這邊要去找建置出來的 .gcm
的方法是仰賴 find
來針對檔名來找,所以如果要建置兩個不同函式庫下同名的 header 檔案(例如 libA/hello.h
、libB/hello.h
),那應該會因為最後要找的檔名是一樣的而出問題。
不過個人是覺得發生機會應該很低,所以姑且先不管吧。
而這邊目前寫法主要的問題,應該是沒辦法對應自己寫、放在專案資料夾裡的 header。
這部份的原因是 g++ 的 CMI 檔案管理,如果使用下面這樣的指令來建置的話:
g++ -std=c++20 -fmodules-ts -fmodule-header lib.h
那 lib.h.gcm
會放在 CMI 資料夾下的「,
」這個資料夾下,代表是目前的資料夾。
由於 g++ 是透過這種相對路徑來做紀錄,所以在透過 module mapper 設定讓所有專案共用一個 CMI 資料夾的時候,其實就已經把這種相對路徑的概念破壞掉了。
比如說使用傳統 header 的型式的時候,app1
要使用 libStdHeader/lib.h
這個 head 的話,通常會通過相對路徑來設定 include path,以這邊來說就是加上「-I ../
」這個編譯參數,讓 g++ 往上一層資料夾去找;但是如果改成 header unit 的時候,這樣的參數他需要的 .gcm
檔案會變成是 ,/,,/libStdHeader/lib.h.gcm
,所以會變成找不到檔案的狀況…
目前想到兩個可能的解法:
- 建立出 CMI 檔案後,自己再去搬移檔案到符合需求的資料夾
- 把這種 header 檔也當成系統的 header、然後用絕對路徑的方式來處理
這兩種方法手動測試都可以,但是要怎麼寫進 Makefile 就要再研究了。
大致上就是這樣了?由於這邊基本上還只是情境模擬,所以真的到實際案例的狀況下,可能還是會有相當的內容需要修改了。
老實說,目前看來問題比較大的部分可能還是 header unit 的部分了…總覺得真的要以 header unit 來使用的話,可能還會有不少問題要處理?
如果某些函式庫的 header 檔案是設計成有相依性、或是有引入的順序的,那可能就得自己寫一個 header 檔案、建立成 header unit 檔案後匯入使用了。
另外,clang 的部分這邊暫時沒打算寫成 Makefile,不過如果要寫的話有幾點可能要注意:
- clang 在編譯模組的時候可以用兩段式編譯的方法,先把
.ixx
編譯成.pcm
、再把.pcm
編譯成.o
;官方的說法是因為編譯成.o
的時間會比較久、所以這樣做可能會比較容易平行化、讓整個建置的速度更快。(參考) - clang 在使用 header unit 的時候由於需要個別指定 header 的
.pcm
檔案,編譯參數感覺會很難處理。