C++20 Module 的語法(2/N)

| | 0 Comments| 09:51|
Categories:

前一篇算是簡單介紹了 C++20 module 的概念,這篇就來認真看他的語法吧。不過在講語法之前,可能還是先講一下名詞吧。

首先,在 C++20 模組的相關說明裡面,常常會看到「named module」(具名模組)這個詞,他基本上就是代表有取名字的模組。像是之前例子的「export module MyModule;」,就代表是一個名字叫做「MyModule」的模組。

不過由於 C++20 定義裡面模組本來就都要取名字,所以其實好像沒有明確訂定義相對應的「unnamed module」?

下面就以範例為主來看其他的定義吧~


模組的單元/Module Unit

首先,下面是一個最簡單的模組的例子、檔名是「MyModule.ixx」(.ixx 是 Visual Studio 建議的副檔名):

export module MyModule;
 
export int getVal()
{
  return 10;
}

這邊第一行的「export module MyModule;」,是一種「module declaration」(模組宣告)。

如果一個需要編譯的單元(translation unit,這邊通常會是一個 .ixx.cpp 檔案)是以 module declaration 開始的話,就代表他是一個組成模組的「module unit」(模組單元)。

Module unit 也有幾種不同的類型,這邊這種前面有加上「export」的模組宣告、代表它是一種用來定義這個模組介面的「module interface unit」;而由於他不是「module partition」(用來拆分模組的方式、之後再解釋),所以它也是「primary module interface unit」。

一個模組可以有一個以上的 module unit,但是只能有一個、也一定要有一個 primary module interface unit。

而 module interface unit 也需要透過編譯器編譯,編譯的時候大致上會像在編譯 .cpp 檔案一樣,不過不同的編譯器可能會要加上特別的參數;在編譯後除了會產生物件檔外,還會產生「Built Module Interface」(BMI)這種新的檔案。


在上面的例子裡面、getVal() 是把宣告和定義寫在一起的。如果想要把它的實作拿到另一個檔案、不要放在 MyModule.ixx 裡的話,那可以把 MyModule.ixx 改成:

export module MyModule;
 
export int getVal();

而獨立出來的檔案這邊取名為「MyModule_impl.cpp」(這邊不取名成「MyModule.cpp」是避免產生的物件檔會和 MyModule.ixx 的同名)、其內容如下:

module MyModule;
 
int getVal()
{
  return 10;
}

這邊第一行的「module MyModule;」也是一種 module declaration、所以代表 MyModule_impl.cpp 也是一個 module unit;由於它是用來提供模組的實作用的,所以叫做「module implementation unit」。

和 module interface unit 的作用不同,這邊只能放實作的部分,所以在這邊不能透過「export」這個關鍵字來匯出任何東西;所有要匯出的東西,都必須宣告在 module interface unit 才行。

而視需要而定,module implementation unit 也可以拆分成很多個,沒有數量的限制。

一般的 module implementation unit 在編譯時基本上就是像一般編譯 .cpp 就可以了,不過由於他需要 module interface unit 的資訊,所以需要等 module interface unit 編譯出 BMI 黨之後才能編一;而編譯後只會產生物件檔案。


所以,基本上 module declaration / module unit 主要分成下面兩個大類型:

  • export module MODULE_NAMEmodule interface unit
  • module MODULE_NAMEmodule implementation unit

其中,module interface unit 是用來定義模組的介面用的,就類似以前寫的 .h 檔案,以 Visual Studio 的建議來說就會是 .ixx 檔;所有要讓外部使用的東西,都必須在這邊透過「export」這個關鍵字來匯出。

和 header 檔最大的不同點,是 module interface unit 也需要編譯,編譯過後會產生物件檔和 BMI 檔。

Module implementation unit 則是用來把實作的部分從介面定義抽出來的檔案,裡面寫的東西都會被藏在模組內部、外面是碰不到的;副檔名的部分一般應該還是會是 .cpp、當作一般的 C++ 原始碼檔案來編譯。

而一個 module 是由一個或多個 module unit 組成的;其中 primary module interface unit 只會有一個,而 module implementation unit 則是視需要而定,可以從 0 個到多個都可以。

如果在模組變大之後,想要拆分 primary module interface unit 內容的話,則可以透過 module partition(模組分區)的概念來做。這時候還會再多出:

  • export
    module MODULE_NAME:PART_NAME
    module partition interface
    unit
  • module
    MODULE_NAME:PART_NAME
    module partition implementation unit

不過這部分就等下一篇再提了。


放置前處理器指示詞的 Global module fragment

由於 C++ 程式通常無可避免地還是會需要使用到傳統的 header 檔案、或是透過前處理器指示詞(preprocessor directives)來定義巨集、切換程式內的狀態。所以為了提供這樣的處理能力,C++20 的模組定義了一個「global module fragment」來處理這樣的需求。

只要在檔案的一開始加上一行「module;」,那到接下來的 module declaration 之間的這個區塊,就是所謂的「global module fragment」。

module;
 
#include <iostream>

#ifndef DEFAULT_VAL
#define DEFAULT_VAL 10;
#endif
 
export module MyModule;
 
int iValue = DEFAULT_VAL;
 
export int getInt();

在上面的例子裡面,黃底的部分就是 global module fragment。

在 C++20 的模組裡,preprocessing directives 只可以放在放在這裡,而這裡也只可以有 preprocessing directives;不過現階段把其他內容放到這裡似乎只是警告、而非錯誤。

這樣設計的目的是讓模組裡面可以使用舊有的程式,也可以在這邊透過巨集來調整程式的內容;而放在這邊的東西,基本上不會被當成屬於模組的東西,所以在匯入這個模組的程式裡面都看不到、也無法存取!

以上面的例子來說,就算匯入了這個模組也沒辦法使用 <iostream> 這個 header 的內容。

同時,這邊定義的 DEFAULT_VAL 在模組外也是沒辦法看到的,而且在外部定義的 DEFAULT_VAL 也不會影響到模組內的定義;如果真的希望調整 DEFAULT_VAL 的值的話,基本上就是要靠編譯參數了。

而可能要注意的,是這邊的內容就只有在這個 module unit 裡面看的到;像在這邊寫在 module interface unit 的 global module fragment 的內容,在同一個模組的 module implementation unit 也是存取不到的。所以如果有要用巨集定義共用的數值的話,可能還是得用 header 檔的形式,在需要的 module unit 裡面個別引用。


匯出(export)

至於要怎麼透過「export」來匯出宣告、讓外部可以使用呢?基本上,只要在想要匯出的東西、包括變數、型別、namespace 前面加上 export 就可以了。

下面就是一個例子:

export module MyModule;
 
export constexpr int iVal = 10;
 
export int getVal(){ return 0;}
 
export namespace MyNameSpace
{
  constexpr int iVal = 2;
  int func2() { return iVal; }
}

這邊是把所有的東西都匯出了。

如果某個東西不想讓外部使用也很簡單,不要加上 export 就可以了。

而如果把 export 加上 namespace 前面的話,則會把這個 namespace 下的所有東西都匯出,這點是要注意的。

另外,如果有大量的東西要匯出,又不想一個一個加 export,那也可以透過 { } 來統一匯出;下面就是一個例子:

export module MyModule;
 
export
{
  constexpr int iVal = 10;
 
  int getVal() { return 0; }
}

此外,usingtemplate 基本上也可以也都匯出。下面就是一個例子:

module;
#include <complex>
export module MyModule;
 
export using TVal = std::complex<double>;
 
export template<typename T>
class TC
{
public:
  T v;
};
 
export template<typename T>
T getVal(const T& val)
{
  return val;
}

在匯入這個模組後,要使用上面匯出的 template 類別或函式基本上都不會有什麼問題。

而個人覺得比較有趣的匯出用 using 來設定的別名也是可以用的。但是使用時要注意的是,他只有匯出 std::complex<double> 而已、並沒有把整個 <complex> 的內容都匯出。

如果使用的時候寫成:

import MyModule;
 
int main()
{
  TVal x{ 1.0,2.0 };    // OK, std::complex<double>
  TVal y{ 1.0,2.0 };    // OK, std::complex<double>
 
  auto z = x + y;         // ERROR
  std::complex<float> f;  // ERROR
}

這時候 xy 都可以正確使用,但是 x + y 會因為沒辦法找到 operator+ 而編譯錯誤。

f 在 g++ 和 clang 也會因為沒有完整的 std::complex<> 來產生 std::complex<float> 而出現 complex 沒有宣告的錯誤;比較有趣的,是在 MSVC 的話,f 這邊是可以正常編譯的。

如果要讓上面的程式可以正確編譯,則就是要在前面加上 #include <complex> 了。


匯入(import)

模組的 import 基本上可以匯入一個 module、module partition 或是 header unit。
其中 module partition 或是 header unit 這邊還沒說明,之後會另外再補充。

在模組內,import 其實有很嚴謹的位置,不是隨便放哪都可以的;他必須要寫在 module declaration 之後、並在宣告任何其他東西之前。

而如果是在 module interface unit 的話,前面也還可以加上 export,在匯入的同時也匯出,讓有匯入這個模組的人也可以使用這個匯入的模組。

比如說有一個模組是:

export module MyModule2;
 
export int getVal2() { return 0; }

而另外還有一個模組是:

export module MyModule1;
 
export import MyModule2;
 
export int getVal1() { return getVal2(); }

MyModule1 裡面,因為匯入 MyModule2 的同時也把它匯出了,所以之後在外部匯入 MyModule1 的時候,也會自動匯入 MyModule2,所以也可以存取 getVal2();像下面的程式就是可以正確編譯的:

import MyModule1;
 
int main()
{
  getVal1();
  getVal2();
}

但是如果把「export import MyModule2;」改成「import MyModule2;」的話,那就會因為找不到 getVal2() 而編譯失敗。


至於在撰寫應用程式要使用模組的時候,import 要放在哪邊好像沒有明確要求?
不過以 g++ 和 clang 來說,#includeimport 應該會比較好

這邊測試的狀況,是如果匯入的模組內有 #include <iostream> 的話,那在匯入這個模組的程式裡面,如果先匯入模組再 #include <iostream> 的話會造成衝突而編譯失敗。


Private module fragment

前面有提到,如果一個模組想把實作的部分從 module interface unit 拆分出來的話,可以透過 module implementation unit 來做。

但是如果內容其實沒那麼多的狀況下,不想額外多一個檔案的時候該怎麼辦呢?模組的架構有提供一個「private module fragment」可以達到這樣的需求。

像是下面這個例子:

export module MyModule;
 
export int getVal()
{
  return 10;
}

如果想要把 getVal() 的實作獨立出來的話,除了前面提到、額外加入 module implementation unit 得檔案,另一個方法就是改寫成:

export module MyModule;
 
export int getVal();
 
module :private;
 
int getVal()
{
  return 20;
}

這邊的「module :private;」代表的就是之後的程式碼都是 private module fragment 的內容。

不過這樣的寫法算是針對小模組專用的,所以有一些額外的限制:

  • 只可以寫在 primary module interface unit
  • 這個模組只可以有這個 module unit(不可以再有其他的 implementation unit)

雖然根據《C++20 Modules: Private Module Fragment and Header Units》這篇的說法,修改 private module fragment 不會造成有匯入這個模組的程式都需要重編譯;但是老實說 Heresy 自己不知道這到底有沒有真的功用?而實際上編譯時的條件判斷到底要怎麼寫才可以減少重編譯的動作也不知道?

最後附帶一提,g++14 目前還不支援 private module fragment。


這篇大概就是這樣了。

實際上,模組的語法還有 module partition 還沒講。不過因為那部份個人玩了很久才搞懂(希望沒搞錯),所以就另外寫一篇吧…

由於 Heresy 自己也是邊看邊測試邊寫文章,所以也不確定有沒有哪裡有弄錯就是了。如果有發現錯誤,也希望可以告知一下。

總覺得現階段要使用的話,第一個可能還得先考慮編譯環境的支援程度,再來是建置流程感覺還是會很難搞…而真的要改寫的話,感覺工程大概也會比一開始預期地來的複雜吧…


本系列文章目錄:

  • 簡介
  • 模組的語法
  • 模組分區
  • 沒有標準定義的子模組
  • Header Unit
  • 標準函式庫的模組(C++23)

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *