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