C++20 四大特性之一:Module 特性詳解
C++20 最大的特性是什么?
最大的特性是迄今為止沒有哪一款編譯器完全實(shí)現(xiàn)了所有特性。

文章來源:網(wǎng)易云信
有人認(rèn)為 C++20 是 C++11 以來最大的一次改動,甚至比 C++11 還要大。本文僅介紹 C++20 四大特性當(dāng)中的 Module 部分,分為三部分:
探究 C++ 編譯鏈接模型的由來以及利弊
介紹 C++20 Module 機(jī)制的使用姿勢
總結(jié) Module 背后的機(jī)制、利弊、以及各大編譯器的支持情況
C++ 是兼容 C 的,不但兼容了 C 的語法,也兼容了 C 的編譯鏈接模型。1973年初,C 語言基本定型:有了預(yù)處理、支持結(jié)構(gòu)體;編譯模型也基本定型為:預(yù)處理、編譯、匯編、鏈接四個(gè)步驟并沿用至今;1973年,K&R 二人使用 C 語言重寫了 Unix 內(nèi)核。
為何要有預(yù)處理?為何要有頭文件?在 C 誕生的年代,用來跑 C 編譯器的計(jì)算機(jī) PDP-11 的硬件配置是這樣的:內(nèi)存:64 KiB 硬盤:512 KiB。編譯器無法把較大的源碼文件放入狹小的內(nèi)存,故當(dāng)時(shí) C 編譯器的設(shè)計(jì)目標(biāo)是能夠支持模塊化編譯,即將源碼分成多個(gè)源碼文件、挨個(gè)編譯,以生成多個(gè)目標(biāo)文件,最后整合(鏈接)成一個(gè)可執(zhí)行文件。
C 編譯器分別編譯多個(gè)源碼文件的過程,實(shí)際上是一個(gè) One pass compile 的過程,即:從頭到尾掃描一遍源碼、邊掃描邊生成目標(biāo)文件、過眼即忘(以源碼文件為單位)、后面的代碼不會影響編譯器前面的決策,該特性導(dǎo)致了 C 語言的以下特征:
結(jié)構(gòu)體必須先定義再使用,否則無法知道成員的類型以及偏移,就無法生成目標(biāo)代碼。
局部變量先定義再使用,否則無法知道變量的類型以及在棧中的位置,且為了方便編譯器管理?xiàng)?臻g,局部變量必須定義在語句塊的開始處。
外部變量只需要知道類型、名字(二者合起來便是聲明)即可使用(生成目標(biāo)代碼),外部變量的實(shí)際地址由連接器填寫。
外部函數(shù)只需知道函數(shù)名、返回值、參數(shù)類型列表(函數(shù)聲明)即可生成調(diào)用函數(shù)的目標(biāo)代碼,函數(shù)的實(shí)際地址由連接器填寫。
頭文件和預(yù)處理恰好滿足了上述要求,頭文件只需用少量的代碼,聲明好函數(shù)原型、結(jié)構(gòu)體等信息,編譯時(shí)將頭文件展開到實(shí)現(xiàn)文件中,編譯器即可完美執(zhí)行 One pass comlile 過程了。
至此,我們看到的都是頭文件的必要性和益處,當(dāng)然,頭文件也有很多負(fù)面影響:
低效:頭文件的本職工作是提供前置聲明,而提供前置聲明的方式采用了文本拷貝,文本拷貝過程不帶有語法分析,會一股腦將需要的、不需要的聲明全部拷貝到源文件中。
傳遞性:最底層的頭文件中宏、變量等實(shí)體的可見性,可以通過中間頭文件“透傳”給最上層的頭文件,這種透傳會帶來很多麻煩。
降低編譯速度:加入 a.h 被三個(gè)模塊包含,則 a 會被展開三次、編譯三次。
順序相關(guān):程序的行為受頭文件的包含順影響,也受是否包含某一個(gè)頭文件影響,在 C++ 中尤為嚴(yán)重(重載)。
不確定性:同一個(gè)頭文件在不同的源文件中可能表現(xiàn)出不同的行為,導(dǎo)致這些不同的原因,可能源自源文件(比如該源文件包含的其他頭文件、該源文件中定義的宏等),也可能源自編譯選項(xiàng)。
C++20 中加入了 Module,我們先看 Module 的基本使用姿勢,最后再總結(jié) Module 比 頭文件的優(yōu)勢。
Module(即模塊)避免了傳統(tǒng)頭文件機(jī)制的諸多缺點(diǎn),一個(gè) Module 是一個(gè)獨(dú)立的翻譯單元,包含一個(gè)到多個(gè) module interface file(即模塊接口文件),包含 0 個(gè)到多個(gè) module implementation file(即模塊實(shí)現(xiàn)文件),使用 Import 關(guān)鍵字即可導(dǎo)入一個(gè)模塊、使用這個(gè)模塊暴露的方法。
實(shí)現(xiàn)一個(gè)最簡單的 Module
module_hello.cppm:定義一個(gè)完整的hello模塊,并導(dǎo)出一個(gè) say_hello_to 方法給外部使用。當(dāng)前各編譯器并未規(guī)定模塊接口文件的后綴,本文統(tǒng)一使用 ".cppm" 后綴名。".cppm" 文件有一個(gè)專用名稱"模塊接口文件",值得注意的是,該文件不光可以聲明實(shí)體,也可定義實(shí)體。

main 函數(shù)中可以直接使用 hello 模塊:

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

以上代碼有以下細(xì)節(jié)需要注意:
module hello:聲明了一個(gè)模塊,前面加一個(gè) export,則意味著當(dāng)前文件是一個(gè)模塊接口文件(module interface file),只有在模塊接口文件中可以導(dǎo)出實(shí)體(變量、函數(shù)、類、namespace等)。一個(gè)模塊至少有一個(gè)模塊接口文件、模塊接口文件可以只放實(shí)體聲明,也可以放實(shí)體定義。
import hello:不需加尖括號,且不同于 include,import 后跟的不是文件名,而是模塊名(文件名為 module_hello.cpp),編譯器并未強(qiáng)制模塊名必須與文件名一致。
想要導(dǎo)出一個(gè)函數(shù),在函數(shù)定義/聲明前加一個(gè)?export 關(guān)鍵字即可。
Import 的模塊不具有傳遞性。hello 模塊包含了 string_view,但是 main 函數(shù)在使用 hello 模塊前,依然需要再 import ; 。
模塊中的 Import 聲明需要放在模塊聲明之后、模塊內(nèi)部其他實(shí)體聲明之前,即:import <iostream>; 必須放在 export module hello; 之后,void internal_helper() 之前。
編譯時(shí)需要先編譯基礎(chǔ)的模塊,再編譯上層模塊,buildfile.sh 中先將 module_hello 編譯生成 pcm,再編譯 main。
接口與實(shí)現(xiàn)分離
上個(gè)示例中,接口的聲明與實(shí)現(xiàn)都在同一個(gè)文件中(.cppm中,準(zhǔn)確地說,該文件中只有函數(shù)的實(shí)現(xiàn),聲明是由編譯器自動生成、放到緩存文件pcm中),當(dāng)模塊的規(guī)模變大、接口變多之后,將所有的實(shí)體定義都放在模塊接口文件中會非常不利于代碼的維護(hù),C++20 的模塊機(jī)制還支持接口與實(shí)現(xiàn)分離。下面我們將接口的聲明與實(shí)現(xiàn)分別放到 .cppm 和 .cpp 文件中。
module_hello.cppm:我們假設(shè) say_hello_to、func_a、func_b 等接口十分復(fù)雜,.cppm 文件中只包含接口的聲明(square 方法是個(gè)例外,它是函數(shù)模板,只能定義在 .cppm 中,不能分離式編譯)。

module_hello.cpp:給出 hello 模塊的各個(gè)接口聲明對應(yīng)的實(shí)現(xiàn)。

代碼有幾個(gè)細(xì)節(jié)需要注意:
整個(gè) hello 模塊分成了?module_hello.cppm 和 module_hello.cpp?兩個(gè)文件,前者是模塊接口文件(module 聲明前有 export 關(guān)鍵字),后者是模塊實(shí)現(xiàn)文件(module implementation file)。當(dāng)前各大編譯器并未規(guī)定模塊接口文件的后綴必須是 cppm。
模塊實(shí)現(xiàn)文件中不能 export 任何實(shí)體。
函數(shù)模板,比如代碼中的 square 函數(shù),定義必須放在模塊接口文件中,使用 auto 返回值的函數(shù),定義也必須放在模塊接口文件。
可見性控制
在模塊最開始的例子中,我們就提到了模塊的 Import 不具有傳遞性:main 函數(shù)使用 hello 模塊的時(shí)候必須 import <string_view>,如果想讓 hello 模塊中的 string_view 模塊暴露給使用者,需使用 export import 顯式聲明:

hello 模塊顯式導(dǎo)出 string_view 后,main 文件中便無需再包含 string_view 了。

子模塊(Submodule)
當(dāng)模塊變得再大一些,僅僅是將模塊的接口與實(shí)現(xiàn)拆分到兩個(gè)文件也有點(diǎn)力不從心,模塊實(shí)現(xiàn)文件會變得非常大,不便于代碼的維護(hù)。C++20 的模塊機(jī)制支持子模塊。
這次 module_hello.cppm 文件不再定義、聲明任何函數(shù),而是僅僅顯式導(dǎo)出 hello.sub_a、hello.sub_b 兩個(gè)子模塊,外部需要的方法都由上述兩個(gè)子模塊定義,module_hello.cppm 充當(dāng)一個(gè)“匯總”的角色。

子模塊 module hello.sub_a 采用了接口與實(shí)現(xiàn)分離的定義方式:“.cppm” 中給出定義,“.cpp” 中給出實(shí)現(xiàn)。

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


這樣,hello 模塊的接口和實(shí)現(xiàn)文件被拆分到了兩個(gè)子模塊中,每個(gè)子模塊又有自己的接口文件、實(shí)現(xiàn)文件。
值得注意的是,C++20 的子模塊是一種“模擬機(jī)制”,模塊 hello.sub_b 是一個(gè)完整的模塊,中間的點(diǎn)并不代表語法上的從屬關(guān)系,不同于函數(shù)名、變量名等標(biāo)識符的命名規(guī)則,模塊的命名規(guī)則中允許點(diǎn)存在于模塊名字當(dāng)中,點(diǎn)只是從邏輯語義上幫助程序員理解模塊間的邏輯關(guān)系。
Module Partition
除了子模塊之外,處理復(fù)雜模塊的機(jī)制還有 Module Partition。Module Partition 一直沒想到一個(gè)貼切的中文翻譯,或者可以翻譯為模塊分區(qū),下文直接使用 Module Partition。Module Partition 分為兩種:
module implementation partition
module interface partition
module implementation partition?可以通俗的理解為:將模塊的實(shí)現(xiàn)文件拆分成多個(gè)。module_hello.cppm 文件:給出模塊的聲明、導(dǎo)出函數(shù)的聲明。

模塊的一部分實(shí)現(xiàn)代碼拆分到 module_hello_partition_internal.cpp 文件,該文件實(shí)現(xiàn)了一個(gè)內(nèi)部方法 internal_helper。

模塊的另一部分實(shí)現(xiàn)拆分到 module_hello.cpp 文件,該文件實(shí)現(xiàn)了 func_a、func_b,同時(shí)引用了內(nèi)部方法 internal_helper(func_a、func_b 當(dāng)然也可以拆分到兩個(gè) cpp 文件中)。

值得注意的是, 模塊內(nèi)部 Import 一個(gè) module partition 時(shí),不能 import hello:internal;而是直接import :internal; 。
module interface partition?可以理解為模塊聲明拆分到多個(gè)文件中。module implementation partition 的例子中,函數(shù)聲明只集中在一個(gè)文件中,module interface partition 可以將這些聲明拆分到多個(gè)接口文件。
首先定義一個(gè)內(nèi)部 helper:internal_helper:

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

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

module_hello_partition_b.cpp 給出 hello 模塊的 b 部分對應(yīng)的實(shí)現(xiàn):

module_hello.cppm 再次充當(dāng)了”匯總“的角色,將模塊的 a 部分+ b 部分導(dǎo)出給外部使用:

module implementation partition 的使用方式較為直觀,相當(dāng)于我們平時(shí)編程中“一個(gè)頭文件聲明多個(gè) cpp 實(shí)現(xiàn)”這種情況。module interface partition 有點(diǎn)類似于 submodule 機(jī)制,但語法上有較多差異:
module_hello_partition_b.cpp 第一行不能使用 import hello:partition_b;雖然這樣看上去更符合直覺,但是不允許。
每個(gè) module partition interface 最終必須被 primary module interface file 導(dǎo)出,不能遺漏。
primary module interface file 不能導(dǎo)出 module implementation file,只能導(dǎo)出 module interface file,故在 module_hello.cppm 中 export :internal; 是錯(cuò)誤的。
同樣作為處理大模塊的機(jī)制,Module Partition 與子模塊最本質(zhì)的區(qū)別在于:子模塊可以獨(dú)立的被外部使用者 Import,而 Module Partition 只在模塊內(nèi)部可見。
全局模塊片段
(Global module fragments)
C++20 之前有大量的不支持模塊的代碼、頭文件,這些代碼實(shí)際被隱式的當(dāng)作全局模塊片段處理,模塊代碼與這些片段交互方式如下:

事實(shí)上,由于標(biāo)準(zhǔn)庫的大多數(shù)頭文件尚未模塊化(VS 模塊化了部分頭文件),整個(gè)第二章的代碼在當(dāng)前編譯器環(huán)境下(Clang12)是不能直接編譯通過的——當(dāng)前尚不能直接 import < iostream > 等模塊,通全局模塊段則可以進(jìn)行方便的過渡(在全局模塊片段直接 #include <iostream>),另一個(gè)過渡方案便是下一節(jié)所介紹的 Module Map——該機(jī)制可以使我們能夠?qū)⑴f的 iostream編譯成一個(gè) Module。
Module Map
Module Map 機(jī)制可以將普通的頭文件映射成 Module,進(jìn)而可以使舊的代碼吃到 Module 機(jī)制的紅利。下面便以 Clang13 中的 Module Map 機(jī)制為例:
假設(shè)有一個(gè) a.h 頭文件,該頭文件歷史較久,不支持 Module:

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


編譯腳本需要依次編譯 A、ctype、iostream 三個(gè)模塊,然后再編譯 main 文件:

首先使用 -fmodule-map-file 參數(shù),指定一個(gè) module map file,然后通過 -fmodule 指定 map file 中定義的 module,就可以將頭文件編譯成 pcm。main 文件使用 A、iostream 等模塊時(shí),同樣需要使用 fmodule-map-file 參數(shù)指定 mdule map 文件,同時(shí)使用 -fmodule 指定依賴的模塊名稱。
注:關(guān)于 Module Map 機(jī)制能夠查到的資料較少,有些細(xì)節(jié)筆者也未能一一查明,例如:
通過 Module Map 將一個(gè)頭文件模塊化之后,頭文件中暴露的宏會如何處理?
假如頭文件聲明的實(shí)體的實(shí)現(xiàn)分散在多個(gè) cpp 中,該如何組織編譯?
Module 與 Namespace
Module 與 Namespace 是兩個(gè)維度的概念,在 Module 中同樣可以導(dǎo)出 Namespace:


總結(jié)
最后,對比最開始提到的頭文件的缺點(diǎn),模塊機(jī)制有以下幾點(diǎn)優(yōu)勢:
無需重復(fù)編譯:一個(gè)模塊的所有接口文件、實(shí)現(xiàn)文件,作為一個(gè)翻譯單元,一次編譯后生成 pcm,之后遇到 Import 該模塊的代碼,編譯器會從 pcm 中尋找函數(shù)聲明等信息,該特性會極大加快 C++ 代碼的編譯速度。
隔離性更好:模塊內(nèi) Import 的內(nèi)容,不會泄漏到模塊外部,除非顯式使用 export Import 聲明。
順序無關(guān):Import 多個(gè)模塊,無需關(guān)心這些模塊間的順序。
減少冗余與不一致:小的模塊可以直接在單個(gè) cppm 文件中完成實(shí)體的導(dǎo)出、定義,但大的模塊依然會把聲明、實(shí)現(xiàn)拆分到不同文件。
子模塊、Module Partition 等機(jī)制讓大模塊、超大模塊的組織方式更加靈活。
全局模塊段、Module Map 制使得 Module 與老舊的頭文件交互成為可能。
缺點(diǎn)也有:
編譯器支持不穩(wěn)定:尚未有編譯器完全支持 Module 的所有特性、Clang13 支持的 Module Map 特性不一定保留到主干版本。
編譯時(shí)需要分析依賴關(guān)系、先編譯最基礎(chǔ)的模塊。
現(xiàn)有的 C++ 工程需要重新組織 pipline,且尚未出現(xiàn)自動化的構(gòu)建系統(tǒng),需要人工根據(jù)依賴關(guān)系組構(gòu)建腳本,實(shí)施難度巨大。
Module 不能做什么?
Module 不能實(shí)現(xiàn)代碼的二進(jìn)制分發(fā),依然需要通過源碼分發(fā) Module。
pcm 文件不能通用,不同編譯器的 pcm 文件不能通用,同一編譯器不同參數(shù)的 pcm 不能通用。
無法自動構(gòu)建,現(xiàn)階段需要人工組織構(gòu)建腳本。
編譯器如何實(shí)現(xiàn)對外隱藏 Module 內(nèi)部符號的?
在 Module 機(jī)制出現(xiàn)之前,符號的鏈接性分為外部連接性(external linkage,符號可在文件之間共享)、內(nèi)部鏈接性(internal linkage,符號只能在文件內(nèi)部使用),可以通過 extern、static 等關(guān)鍵字控制一個(gè)符號的鏈接性。
Module 機(jī)制引入了模塊鏈接性(module linkage),符號可在整個(gè)模塊內(nèi)部共享(一個(gè)模塊可能存在多個(gè) partition 文件)。
對于模塊 export 的符號,編譯器根據(jù)現(xiàn)有規(guī)則(外部連接性)對符號進(jìn)行名稱修飾(name mangling)。
對于 Module 內(nèi)部的符號,統(tǒng)一在符號名稱前面添加 “_Zw” 名稱修飾,這樣鏈接器鏈接時(shí)便不會鏈接到內(nèi)部符號。
截至2020.7,三大編譯器對 Module 機(jī)制的支持情況:

以上就是本文的全部內(nèi)容,關(guān)于 C++20 的四大特性我們介紹了其一
寫在最后:其實(shí)每個(gè)人都有自己的選擇,學(xué)編程,每一種編程語言的存在都有其應(yīng)用的方向,選擇你想從事的方向,去進(jìn)行合適的選擇就對了!對于準(zhǔn)備學(xué)習(xí)編程的小伙伴,如果你想更好的提升你的編程核心能力(內(nèi)功)不妨從現(xiàn)在開始!
微信公眾號:C語言編程學(xué)習(xí)基地
整理分享(多年學(xué)習(xí)的源碼、項(xiàng)目實(shí)戰(zhàn)視頻、項(xiàng)目筆記,基礎(chǔ)入門教程)
歡迎轉(zhuǎn)行和學(xué)習(xí)編程的伙伴,利用更多的資料學(xué)習(xí)成長比自己琢磨更快哦!
