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 ; 。
子產品中的 Import 聲明需要放在子產品聲明之後、子產品内部其他實體聲明之前,即:import <iostream>; 必須放在 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 <iostream>),另一個過渡方案便是下一節所介紹的 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 的四大特性我們介紹了其一
寫在最後:其實每個人都有自己的選擇,學程式設計,每一種程式設計語言的存在都有其應用的方向,選擇你想從事的方向,去進行合适的選擇就對了!對于準備學習程式設計的小夥伴,如果你想更好的提升你的程式設計核心能力(内功)不妨從現在開始!
C語言C++程式設計學習交流圈子,QQ群:614504899【點選進入】微信公衆号:C語言程式設計學習基地
整理分享(多年學習的源碼、項目實戰視訊、項目筆記,基礎入門教程)
歡迎轉行和學習程式設計的夥伴,利用更多的資料學習成長比自己琢磨更快哦!
程式設計學習視訊分享: