1. 程式人生 > 其它 >C++20 四大特性之一:Module 特性詳解

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

++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)的解讀,也歡迎繼續關注我們。文中內容難免會有疏漏與不足,歡迎留言與我們交流。


作者:網易雲信
連結:https://juejin.cn/post/6994342497698873375
來源:掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。