C++20 四大特性之一:Module 特性詳解

C++20 最大的特性是什麼?

最大的特性是迄今爲止沒有哪一款編譯器完全實現了所有特性。 有人認爲 C++20 是 C++11 以來最大的一次改動,甚至比 C++11 還要大。本文僅介紹 C++20 四大特性當中的 Module 部分,分爲三部分:

探究 C++ 編譯鏈接模型的由來以及利弊 介紹 C++20 Module 機制的使用姿勢 總結 Module 背後的機制、利弊、以及各大編譯器的支持情況 C++ 是兼容 C 的,不但兼容了 C 的語法,也兼容了 C 的編譯鏈接模型。1973年初,C 語言基本定型:有了預處理、支持結構體;編譯模型也基本定型爲:預處理、編譯、彙編、鏈接四個步驟並沿用至今;1973年,K&R 二人使用 C 語言重寫了 Unix 內核。

爲何要有預處理?爲何要有頭文件?在 C 誕生的年代,用來跑 C 編譯器的計算機 PDP-11 的硬件配置是這樣的:內存:64 KiB 硬盤:512 KiB。編譯器無法把較大的源碼文件放入狹小的內存,故當時 C 編譯器的設計目標是能夠支持模塊化編譯,即將源碼分成多個源碼文件、挨個編譯,以生成多個目標文件,最後整合(鏈接)成一個可執行文件。

C 編譯器分別編譯多個源碼文件的過程,實際上是一個 One pass compile 的過程,即:從頭到尾掃描一遍源碼、邊掃描邊生成目標文件、過眼即忘(以源碼文件爲單位)、後面的代碼不會影響編譯器前面的決策,該特性導致了 C 語言的以下特徵:

結構體必須先定義再使用,否則無法知道成員的類型以及偏移,就無法生成目標代碼。 局部變量先定義再使用,否則無法知道變量的類型以及在棧中的位置,且爲了方便編譯器管理棧空間,局部變量必須定義在語句塊的開始處。 外部變量只需要知道類型、名字(二者合起來便是聲明)即可使用(生成目標代碼),外部變量的實際地址由連接器填寫。 外部函數只需知道函數名、返回值、參數類型列表(函數聲明)即可生成調用函數的目標代碼,函數的實際地址由連接器填寫。 頭文件和預處理恰好滿足了上述要求,頭文件只需用少量的代碼,聲明好函數原型、結構體等信息,編譯時將頭文件展開到實現文件中,編譯器即可完美執行 One pass comlile 過程了。

至此,我們看到的都是頭文件的必要性和益處,當然,頭文件也有很多負面影響:

低效:頭文件的本職工作是提供前置聲明,而提供前置聲明的方式採用了文本拷貝,文本拷貝過程不帶有語法分析,會一股腦將需要的、不需要的聲明全部拷貝到源文件中。 傳遞性:最底層的頭文件中宏、變量等實體的可見性,可以通過中間頭文件“透傳”給最上層的頭文件,這種透傳會帶來很多麻煩。 降低編譯速度:加入 a.h 被三個模塊包含,則 a 會被展開三次、編譯三次。 順序相關:程序的行爲受頭文件的包含順影響,也受是否包含某一個頭文件影響,在 C++ 中尤爲嚴重(重載)。 不確定性:同一個頭文件在不同的源文件中可能表現出不同的行爲,導致這些不同的原因,可能源自源文件(比如該源文件包含的其他頭文件、該源文件中定義的宏等),也可能源自編譯選項。 C++20 中加入了 Module,我們先看 Module 的基本使用姿勢,最後再總結 Module 比 頭文件的優勢。

Module(即模塊)避免了傳統頭文件機制的諸多缺點,一個 Module 是一個獨立的翻譯單元,包含一個到多個 module interface file(即模塊接口文件),包含 0 個到多個 module implementation file(即模塊實現文件),使用 Import 關鍵字即可導入一個模塊、使用這個模塊暴露的方法。

實現一個最簡單的 Module

module_hello.cppm:定義一個完整的hello模塊,並導出一個 say_hello_to 方法給外部使用。當前各編譯器並未規定模塊接口文件的後綴,本文統一使用 ".cppm" 後綴名。".cppm" 文件有一個專用名稱"模塊接口文件",值得注意的是,該文件不光可以聲明實體,也可定義實體。

main 函數中可以直接使用 hello 模塊:

編譯腳本如下,需要先編譯 module_hello.cppm 生成一個 pcm 文件(Module 緩存文件),該文件包含了 hello 模塊導出的符號。

以上代碼有以下細節需要注意:

module hello:聲明瞭一個模塊,前面加一個 export,則意味着當前文件是一個模塊接口文件(module interface file),只有在模塊接口文件中可以導出實體(變量、函數、類、namespace等)。一個模塊至少有一個模塊接口文件、模塊接口文件可以只放實體聲明,也可以放實體定義。 import hello:不需加尖括號,且不同於 include,import 後跟的不是文件名,而是模塊名(文件名爲 module_hello.cpp),編譯器並未強制模塊名必須與文件名一致。 想要導出一個函數,在函數定義/聲明前加一個 export 關鍵字即可。 Import 的模塊不具有傳遞性。hello 模塊包含了 string_view,但是 main 函數在使用 hello 模塊前,依然需要再 import <string_view>; 。 模塊中的 Import 聲明需要放在模塊聲明之後、模塊內部其他實體聲明之前,即:import ; 必須放在 export module hello; 之後,void internal_helper() 之前。 編譯時需要先編譯基礎的模塊,再編譯上層模塊,buildfile.sh 中先將 module_hello 編譯生成 pcm,再編譯 main。 接口與實現分離

上個示例中,接口的聲明與實現都在同一個文件中(.cppm中,準確地說,該文件中只有函數的實現,聲明是由編譯器自動生成、放到緩存文件pcm中),當模塊的規模變大、接口變多之後,將所有的實體定義都放在模塊接口文件中會非常不利於代碼的維護,C++20 的模塊機制還支持接口與實現分離。下面我們將接口的聲明與實現分別放到 .cppm 和 .cpp 文件中。

module_hello.cppm:我們假設 say_hello_to、func_a、func_b 等接口十分複雜,.cppm 文件中只包含接口的聲明(square 方法是個例外,它是函數模板,只能定義在 .cppm 中,不能分離式編譯)。

module_hello.cpp:給出 hello 模塊的各個接口聲明對應的實現。

代碼有幾個細節需要注意:

整個 hello 模塊分成了 module_hello.cppm 和 module_hello.cpp 兩個文件,前者是模塊接口文件(module 聲明前有 export 關鍵字),後者是模塊實現文件(module implementation file)。當前各大編譯器並未規定模塊接口文件的後綴必須是 cppm。 模塊實現文件中不能 export 任何實體。 函數模板,比如代碼中的 square 函數,定義必須放在模塊接口文件中,使用 auto 返回值的函數,定義也必須放在模塊接口文件。 可見性控制

在模塊最開始的例子中,我們就提到了模塊的 Import 不具有傳遞性:main 函數使用 hello 模塊的時候必須 import <string_view>,如果想讓 hello 模塊中的 string_view 模塊暴露給使用者,需使用 export import 顯式聲明:

hello 模塊顯式導出 string_view 後,main 文件中便無需再包含 string_view 了。

子模塊(Submodule)

當模塊變得再大一些,僅僅是將模塊的接口與實現拆分到兩個文件也有點力不從心,模塊實現文件會變得非常大,不便於代碼的維護。C++20 的模塊機制支持子模塊。

這次 module_hello.cppm 文件不再定義、聲明任何函數,而是僅僅顯式導出 hello.sub_a、hello.sub_b 兩個子模塊,外部需要的方法都由上述兩個子模塊定義,module_hello.cppm 充當一個“彙總”的角色。

子模塊 module hello.sub_a 採用了接口與實現分離的定義方式:“.cppm” 中給出定義,“.cpp” 中給出實現。

module hello.sub_b 同上,不再贅述。

這樣,hello 模塊的接口和實現文件被拆分到了兩個子模塊中,每個子模塊又有自己的接口文件、實現文件。

值得注意的是,C++20 的子模塊是一種“模擬機制”,模塊 hello.sub_b 是一個完整的模塊,中間的點並不代表語法上的從屬關係,不同於函數名、變量名等標識符的命名規則,模塊的命名規則中允許點存在於模塊名字當中,點只是從邏輯語義上幫助程序員理解模塊間的邏輯關係。

Module Partition

除了子模塊之外,處理複雜模塊的機制還有 Module Partition。Module Partition 一直沒想到一個貼切的中文翻譯,或者可以翻譯爲模塊分區,下文直接使用 Module Partition。Module Partition 分爲兩種:

module implementation partition module interface partition module implementation partition 可以通俗的理解爲:將模塊的實現文件拆分成多個。module_hello.cppm 文件:給出模塊的聲明、導出函數的聲明。

模塊的一部分實現代碼拆分到 module_hello_partition_internal.cpp 文件,該文件實現了一個內部方法 internal_helper。

模塊的另一部分實現拆分到 module_hello.cpp 文件,該文件實現了 func_a、func_b,同時引用了內部方法 internal_helper(func_a、func_b 當然也可以拆分到兩個 cpp 文件中)。

值得注意的是, 模塊內部 Import 一個 module partition 時,不能 import hello:internal;而是直接import :internal; 。

module interface partition 可以理解爲模塊聲明拆分到多個文件中。module implementation partition 的例子中,函數聲明只集中在一個文件中,module interface partition 可以將這些聲明拆分到多個接口文件。

首先定義一個內部 helper:internal_helper:

hello 模塊的 a 部分採用聲明+定義合一的方式,定義在 module_hello_partition_a.cppm 中:

hello 模塊的 b 部分採用聲明+定義分離的方式,module_hello_partition_b.cppm 只做聲明:

module_hello_partition_b.cpp 給出 hello 模塊的 b 部分對應的實現:

module_hello.cppm 再次充當了”彙總“的角色,將模塊的 a 部分+ b 部分導出給外部使用:

module implementation partition 的使用方式較爲直觀,相當於我們平時編程中“一個頭文件聲明多個 cpp 實現”這種情況。module interface partition 有點類似於 submodule 機制,但語法上有較多差異:

module_hello_partition_b.cpp 第一行不能使用 import hello:partition_b;雖然這樣看上去更符合直覺,但是不允許。 每個 module partition interface 最終必須被 primary module interface file 導出,不能遺漏。 primary module interface file 不能導出 module implementation file,只能導出 module interface file,故在 module_hello.cppm 中 export :internal; 是錯誤的。 同樣作爲處理大模塊的機制,Module Partition 與子模塊最本質的區別在於:子模塊可以獨立的被外部使用者 Import,而 Module Partition 只在模塊內部可見。

全局模塊片段

(Global module fragments)

C++20 之前有大量的不支持模塊的代碼、頭文件,這些代碼實際被隱式的當作全局模塊片段處理,模塊代碼與這些片段交互方式如下:

事實上,由於標準庫的大多數頭文件尚未模塊化(VS 模塊化了部分頭文件),整個第二章的代碼在當前編譯器環境下(Clang12)是不能直接編譯通過的——當前尚不能直接 import < iostream > 等模塊,通全局模塊段則可以進行方便的過渡(在全局模塊片段直接 #include ),另一個過渡方案便是下一節所介紹的 Module Map——該機制可以使我們能夠將舊的 iostream編譯成一個 Module。

Module Map

Module Map 機制可以將普通的頭文件映射成 Module,進而可以使舊的代碼喫到 Module 機制的紅利。下面便以 Clang13 中的 Module Map 機制爲例:

假設有一個 a.h 頭文件,該頭文件歷史較久,不支持 Module:

通過給 Clang 編譯器定義一個 module.modulemap 文件,在該文件中可以將頭文件映射成模塊:

編譯腳本需要依次編譯 A、ctype、iostream 三個模塊,然後再編譯 main 文件:

首先使用 -fmodule-map-file 參數,指定一個 module map file,然後通過 -fmodule 指定 map file 中定義的 module,就可以將頭文件編譯成 pcm。main 文件使用 A、iostream 等模塊時,同樣需要使用 fmodule-map-file 參數指定 mdule map 文件,同時使用 -fmodule 指定依賴的模塊名稱。

注:關於 Module Map 機制能夠查到的資料較少,有些細節筆者也未能一一查明,例如:

通過 Module Map 將一個頭文件模塊化之後,頭文件中暴露的宏會如何處理? 假如頭文件聲明的實體的實現分散在多個 cpp 中,該如何組織編譯? Module 與 Namespace

Module 與 Namespace 是兩個維度的概念,在 Module 中同樣可以導出 Namespace:

總結

最後,對比最開始提到的頭文件的缺點,模塊機制有以下幾點優勢:

無需重複編譯:一個模塊的所有接口文件、實現文件,作爲一個翻譯單元,一次編譯後生成 pcm,之後遇到 Import 該模塊的代碼,編譯器會從 pcm 中尋找函數聲明等信息,該特性會極大加快 C++ 代碼的編譯速度。 隔離性更好:模塊內 Import 的內容,不會泄漏到模塊外部,除非顯式使用 export Import 聲明。 順序無關:Import 多個模塊,無需關心這些模塊間的順序。 減少冗餘與不一致:小的模塊可以直接在單個 cppm 文件中完成實體的導出、定義,但大的模塊依然會把聲明、實現拆分到不同文件。 子模塊、Module Partition 等機制讓大模塊、超大模塊的組織方式更加靈活。 全局模塊段、Module Map 制使得 Module 與老舊的頭文件交互成爲可能。 缺點也有:

編譯器支持不穩定:尚未有編譯器完全支持 Module 的所有特性、Clang13 支持的 Module Map 特性不一定保留到主幹版本。 編譯時需要分析依賴關係、先編譯最基礎的模塊。 現有的 C++ 工程需要重新組織 pipline,且尚未出現自動化的構建系統,需要人工根據依賴關係組構建腳本,實施難度巨大。 Module 不能做什麼?

Module 不能實現代碼的二進制分發,依然需要通過源碼分發 Module。 pcm 文件不能通用,不同編譯器的 pcm 文件不能通用,同一編譯器不同參數的 pcm 不能通用。 無法自動構建,現階段需要人工組織構建腳本。 編譯器如何實現對外隱藏 Module 內部符號的?

在 Module 機制出現之前,符號的鏈接性分爲外部連接性(external linkage,符號可在文件之間共享)、內部鏈接性(internal linkage,符號只能在文件內部使用),可以通過 extern、static 等關鍵字控制一個符號的鏈接性。 Module 機制引入了模塊鏈接性(module linkage),符號可在整個模塊內部共享(一個模塊可能存在多個 partition 文件)。 對於模塊 export 的符號,編譯器根據現有規則(外部連接性)對符號進行名稱修飾(name mangling)。 對於 Module 內部的符號,統一在符號名稱前面添加 “_Zw” 名稱修飾,這樣鏈接器鏈接時便不會鏈接到內部符號。 截至2020.7,三大編譯器對 Module 機制的支持情況:

以上就是本文的全部內容,關於 C++20 的四大特性我們介紹了其一,在後續的文章中,我們也會陸續安排另外三大(concept、range、coroutine)的解讀,也歡迎繼續關注我們。文中內容難免會有疏漏與不足,歡迎留言與我們交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章