C++ Module詳解:模塊化編程終極指南
一、模塊接口文件
1.定義和擴展名
模塊接口文件定義了模塊所提供功能的接口。這些文件通常具有 .cppm 擴展名。模塊接口以聲明文件定義了某個名稱的模塊開始,這被稱為模塊聲明。模塊的名稱可以是任何有效的 C++ 標識符。名稱可以包含點,但不能以點開頭或結(jié)尾,也不能連續(xù)包含多個點。有效名稱的示例包括 datamodel、mycompany.datamodel、mycompany.datamodel.core、datamodel_core 等。
注意:目前,還沒有為模塊接口文件標準化的擴展名。然而,大多數(shù)編譯器支持 .cppm(C++ 模塊)擴展名,這也是本書所使用的。請檢查你的編譯器文檔,了解應(yīng)使用哪種擴展名。
2.導(dǎo)出與模塊接口
模塊需要明確聲明要導(dǎo)出什么,即客戶端代碼導(dǎo)入模塊時應(yīng)該可見的內(nèi)容。從模塊導(dǎo)出實體(例如,類、函數(shù)、常量、其他模塊等)是通過 export 關(guān)鍵字完成的。模塊中未導(dǎo)出的任何內(nèi)容只在模塊內(nèi)部可見。所有導(dǎo)出實體的集合稱為模塊接口。
以下是一個名為 Person.cppm 的模塊接口文件示例,定義了一個 person 模塊并導(dǎo)出了一個 Person 類。注意它導(dǎo)入了 <string> 提供的功能。
export module person; // 模塊聲明
import <string>; // 導(dǎo)入聲明
export class Person // 導(dǎo)出聲明
{
public:
Person(std::string firstName, std::string lastName)
: m_firstName { std::move(firstName) }, m_lastName { std::move(lastName) } { }
const std::string& getFirstName() const { return m_firstName; }
const std::string& getLastName() const { return m_lastName; }
private:
std::string m_firstName;
std::string m_lastName;
};
3.使用模塊
這個 Person 類可以通過導(dǎo)入 person 模塊在以下代碼中使用(test.cpp):
import person; // 導(dǎo)入 person 模塊聲明
import <iostream>;
import <string>; // 用于 std::string 的 operator<<
using namespace std;
int main() {
Person person { "Kole", "Webb" };
cout << person.getLastName() << ", " << person.getFirstName() << endl;
}
所有 C++ 頭文件,如 <iostream>、<vector>、<string> 等,都是所謂的可導(dǎo)入頭文件,可以通過導(dǎo)入聲明導(dǎo)入。C++ 中可用的 C 頭文件不保證是可導(dǎo)入的。為了安全起見,對于 C 頭文件應(yīng)該使用 #include 而不是導(dǎo)入聲明。這樣的 #include 指令應(yīng)該放在所謂的全局模塊片段中,它必須在任何命名模塊聲明之前,并以無名模塊聲明開始。全局模塊片段只能包含預(yù)處理指令,如 #include。這樣的全局模塊片段和注釋是唯一允許出現(xiàn)在命名模塊聲明之前的內(nèi)容。
例如,如果你需要使用 <cstddef> C 頭文件的功能,可以按照以下方式使其可用:
module; // 開始全局模塊片段
#include <cstddef> // 包含傳統(tǒng)頭文件
export module person; // 命名模塊聲明
import <string>;
export class Person { /* ... */
};
4.標準術(shù)語和導(dǎo)出聲明
在標準術(shù)語中,從命名模塊聲明開始直到文件末尾的一切稱為模塊視野。幾乎任何東西都可以從模塊中導(dǎo)出,只要它有一個名稱。示例包括類定義、函數(shù)原型、類枚舉類型、使用聲明和指令、命名空間等。如果命名空間使用 export 關(guān)鍵字顯式導(dǎo)出,那么該命名空間內(nèi)的所有內(nèi)容也會自動導(dǎo)出。例如,以下代碼片段導(dǎo)出了整個 DataModel 命名空間;因此,無需顯式導(dǎo)出各個類和類型別名:
export module datamodel;
import <vector>;
export namespace DataModel {
class Person { /* ... */ };
class Address { /* ... */ };
using Persons = std::vector<Person>;
}
你還可以使用導(dǎo)出塊導(dǎo)出一整塊聲明。以下是一個示例:
export {
namespace DataModel {
class Person { /* ... */ };
class Address { /* ... */ };
using Persons = std::vector<Person>;
}
}
二、模塊實現(xiàn)文件
1.分割接口與實現(xiàn)
一個模塊可以被分割為模塊接口文件和一個或多個模塊實現(xiàn)文件。模塊實現(xiàn)文件通常使用 .cpp 作為擴展名。你可以自由決定將哪些實現(xiàn)移至模塊實現(xiàn)文件,以及保留哪些實現(xiàn)在模塊接口文件中。
一種選擇是將所有函數(shù)和方法的實現(xiàn)都移至模塊實現(xiàn)文件中,而只在模塊接口文件中保留函數(shù)原型、類定義等。另一種選擇是將小型函數(shù)和方法的實現(xiàn)保留在接口文件中,同時將其他函數(shù)和方法的實現(xiàn)移至實現(xiàn)文件。在這里,你有很大的靈活性。
模塊實現(xiàn)文件同樣包含一個命名模塊聲明,以指定實現(xiàn)是為哪個模塊服務(wù)的,但沒有 export 關(guān)鍵字。例如,之前的 person 模塊可以被分割為接口和實現(xiàn)文件,如下所示。這里是模塊接口文件:
export module person; // 模塊聲明
import <string>;
export class Person {
public:
Person(std::string firstName, std::string lastName);
const std::string& getFirstName() const;
const std::string& getLastName() const;
private:
std::string m_firstName;
std::string m_lastName;
};
實現(xiàn)現(xiàn)在放在 Person.cpp 模塊實現(xiàn)文件中:
module person; // 模塊聲明,但沒有 export 關(guān)鍵字
using namespace std;
Person::Person(string firstName, string lastName)
: m_firstName { move(firstName) }, m_lastName { move(lastName) } { }
const string& Person::getFirstName() const { return m_firstName; }
const string& Person::getLastName() const { return m_lastName; }
2.實現(xiàn)文件的特點
請注意,實現(xiàn)文件沒有為 person 模塊的導(dǎo)入聲明。module person 聲明隱含地包括了 import person 聲明。同樣值得注意的是,盡管在方法實現(xiàn)中使用了 std::string,實現(xiàn)文件也沒有對 <string> 的任何導(dǎo)入聲明。由于隱含的 import person,以及因為此實現(xiàn)文件是同一個 person 模塊的一部分,它隱含地繼承了模塊接口文件中的 <string> 導(dǎo)入聲明。
相比之下,向 test.cpp 文件添加 import person 聲明并不會隱含地繼承 <string> 導(dǎo)入聲明,因為 test.cpp 不是 person 模塊的一部分。關(guān)于這方面有更多內(nèi)容,在即將到來的“可見性與可達性”一節(jié)中進行討論。
注意:模塊接口和模塊實現(xiàn)文件中的所有導(dǎo)入聲明都必須位于文件頂部,在命名模塊聲明之后,但在任何其他聲明之前。與模塊接口文件類似,如果在模塊實現(xiàn)文件中需要任何傳統(tǒng)頭文件的 #include 指令,你應(yīng)該將它們放在全局模塊片段中,其語法與模塊接口文件相同。
警告:模塊實現(xiàn)文件不能導(dǎo)出任何內(nèi)容;只有模塊接口文件可以。
三、從實現(xiàn)中分離接口
1.使用頭文件時的建議
當(dāng)使用頭文件(.h)而非模塊時,強烈建議只在頭文件中放置聲明,并將所有實現(xiàn)移至源文件(.cpp)。這樣做的一個原因是為了提高編譯時間。如果將實現(xiàn)放在頭文件中,任何更改,即使只是修改一個注釋,也需要重新編譯包含該頭文件的所有其他源文件。對于某些頭文件,這可能會導(dǎo)致整個代碼庫的全面重新編譯。通過將實現(xiàn)放在源文件中,不觸及頭文件的情況下對這些實現(xiàn)進行修改,意味著只需要重新編譯那個單獨的源文件。
2.模塊的不同工作方式
模塊的工作方式不同。模塊接口僅包括類定義、函數(shù)原型等,但不包括任何函數(shù)或方法的實現(xiàn),即使這些實現(xiàn)直接位于模塊接口文件中。因此,更改模塊接口文件內(nèi)的函數(shù)或方法實現(xiàn),只要不觸及接口部分(例如,函數(shù)頭 = 函數(shù)名、參數(shù)列表和返回類型),就不需要重新編譯使用該模塊的用戶。
有兩個例外:使用 inline 關(guān)鍵字標記的函數(shù)/方法,以及模板定義。對于這兩者,編譯器需要在編譯使用它們的客戶端代碼時了解它們的完整實現(xiàn)。因此,對 inline 函數(shù)/方法或模板定義的任何更改都可能觸發(fā)客戶端代碼的重新編譯。
注意:當(dāng)頭文件中的類定義包含方法實現(xiàn)時,這些方法即使沒有標記 inline 關(guān)鍵字,也會被隱式地視為內(nèi)聯(lián)。但這對于模塊接口文件中類定義中的方法實現(xiàn)不成立。如果這些需要被內(nèi)聯(lián),它們需要被顯式地標記為此。
盡管從技術(shù)上講,不再需要將接口與實現(xiàn)分離,但在某些情況下,我仍然建議這樣做。主要目標應(yīng)該是擁有清晰易讀的接口。只要函數(shù)的實現(xiàn)不會遮蔽接口,使用戶難以快速理解公共接口提供了什么,就可以保留在接口中。例如,如果一個模塊有一個較大的公共接口,最好不要用實現(xiàn)來遮蔽該接口,這樣用戶可以更好地了解所提供的內(nèi)容。然而,小的 getter 和 setter 函數(shù)可以保留在接口中,因為它們對接口的可讀性影響不大。
從實現(xiàn)中分離接口可以通過幾種方式完成。一種選擇是將模塊分為接口和實現(xiàn)文件,如前一節(jié)所討論的。另一種選擇是在單個模塊接口文件內(nèi)分離接口和實現(xiàn)。例如,以下是在單個模塊接口文件(person.cppm)中定義的 Person 類,但將實現(xiàn)與接口分離:
export module person;
import <string>;
// 類定義
export class Person {
public:
Person(std::string firstName, std::string lastName);
const std::string& getFirstName() const;
const std::string& getLastName() const;
private:
std::string m_firstName;
std::string m_lastName;
};
// 實現(xiàn)
Person::Person(std::string firstName, std::string lastName)
: m_firstName { std::move(firstName) }, m_lastName { std::move(last
Name) } { }
const std::string& Person::getFirstName() const { return m_firstName; }
const std::string& Person::getLastName() const { return m_lastName; }
四、可見性與可達性
1.引入模塊的影響
正如之前提到的,當(dāng)你在非 person 模塊的另一個源文件中導(dǎo)入 person 模塊(例如在 test.cpp 文件中),你并沒有隱含地繼承 person 模塊接口文件中的 <string> 導(dǎo)入聲明。因此,如果沒有在 test.cpp 中顯式導(dǎo)入 <string>,std::string 名稱將不可見,意味著以下突出顯示的代碼行將無法編譯:
import person;
int main() {
std::string str;
Person person { "Kole", "Webb" };
const std::string& lastName { person.getLastName() };
}
然而,即使沒有向 test.cpp 添加 <string> 的顯式導(dǎo)入,以下代碼行仍能正常工作:
const auto& lastName { person.getLastName() };
auto length { lastName.length() };
2.為什么這樣工作?
在 C++ 中,實體的可見性和可達性是不同的。通過導(dǎo)入 person 模塊,<string> 中的功能變得可達但不可見??蛇_類的成員函數(shù)自動變得可見。這意味著你可以使用 <string> 中的某些功能,例如使用 auto 類型推導(dǎo)將 getLastName() 的結(jié)果存儲在變量中,并在其上調(diào)用諸如 length() 之類的方法。
要使 std::string 名稱在 test.cpp 中可見,需要顯式導(dǎo)入 <string>。當(dāng)你想使用例如 operator<< 這樣的功能時,也需要這樣的顯式導(dǎo)入。這是因為 operator<< 不是 std::string 的方法,而是一個非成員函數(shù),只有導(dǎo)入 <string> 后才會變得可見。
cout << person.getLastName() << endl;