跳转到内容

模块、头文件与杂项主题

本章首先会详细讨论模块如何让你编写可复用组件,并将其与老式头文件进行对比。随后还会解释什么是预处理器指令,并给出一些例子说明为什么 C 风格预处理器宏是危险的。接着,本章会讲解链接性(linkage)这一概念——它规定了具名实体可以从哪里被访问——以及单一定义规则(one definition rule)。本章最后会讨论 staticextern 关键字的不同用法,以及 C 风格可变长度参数列表。

模块(module)已在 第 1 章“C++ 与标准库速成”中介绍过,并且你在前几章中也已经亲手编写并使用过自己的简单模块。不过,关于模块还有很多内容值得继续说明。在 C++20 引入模块之前,通常使用本章后面会讲到的头文件来为可复用代码提供接口。不过,头文件确实存在不少问题,例如必须避免同一头文件被多次包含,还得确保头文件以正确顺序包含。此外,仅仅 #include 一个像 <iostream> 这样的头文件,就会额外带来成千上万行需要编译器处理的代码。如果多个源文件都 #include <iostream>,那么这些翻译单元都会变得大得多。而这还只是包含了一个头文件而已。想想看,如果你还需要 <iostream><vector><format> 等更多头文件会怎样。

模块可以解决这些问题,以及更多问题。导入模块的顺序并不重要。模块会先被编译成二进制格式,随后编译器在其他源文件中导入该模块时便可直接复用。这与头文件形成鲜明对比: 头文件每次在编译器遇到 #include 时都必须重新编译。因此,模块可以显著提升编译速度。增量编译时间也会变短,因为对模块的某些修改——例如在模块接口文件中修改一个已导出函数的实现——并不会触发该模块用户端的重新编译(本章后面会更详细解释)。模块不会受到外部定义宏的影响,并且在模块内部定义的任何宏也永远不会对模块外部代码可见,也就是说,模块是自隔离的。因此,给出如下建议:

如果可能的话,旧代码也可以逐步迁移到模块。不过,现实世界中仍存在大量遗留代码,而且许多第三方库尚未拥抱模块,因为在写作本书时,并不是所有编译器都已完整支持模块。基于这些原因,理解传统头文件的工作方式仍然非常重要。这也是为什么本章仍然保留了关于头文件的讨论。

如果你想用尚未完整支持模块的编译器来编译本书中的代码示例,可以按如下方式把代码去模块化:

  • .cppm 模块接口文件重命名为 .h 头文件。
  • 在每个 .h 头文件顶部添加 #pragma once
  • 删除 export module xyz 声明。
  • module xyz 声明替换为对应头文件的 #include
  • importexport import 声明替换为恰当的 #include 指令。如果代码里使用了 import std;,那么就需要将其替换为 #include 指令,把所有必要的单独头文件都包含进来。有关标准库头文件及其简要说明,请参见 附录 C“标准库头文件”。
  • 删除所有 export 关键字。
  • 删除所有 module; 出现的位置,它表示全局模块片段(global module fragment)的开始。
  • 如果某个函数定义 or 变量定义出现在 .h 头文件中,则需要在其前面加上 inline 关键字。

正如 第 1 章 所解释的那样,你可以通过导入标准命名模块(standard named module) std 来访问整个 C++ 标准库。这个命名模块使整个标准库对你可用,包括那些定义在 <cstddef> 等头文件中的全部 C 功能。不过,所有 C 功能都只通过 std 命名空间提供。对于遗留代码,你也可以考虑导入 std.compat 命名模块,它会导入 std 所导入的一切,但同时让所有 C 功能既在 std 命名空间中可用,也在全局空间中可用。对于新代码,不推荐使用 std.compat

模块接口文件(module interface file)定义了某个模块所提供功能的接口,通常使用 .cppm 作为文件扩展名。模块接口文件会以一条声明开头,说明该文件正在定义一个具有特定名称的模块。这称为模块声明(module declaration)。模块名可以是任意合法的 C++ 标识符。名称中可以包含点号,但不能以点号开头或结尾,也不能连续出现多个点号。合法名称的例子包括 datamodelmycompany.datamodelmycompany.datamodel.coredatamodel_core 等等。

模块必须显式声明哪些内容需要导出,也就是哪些内容在客户端代码导入该模块时应当可见。模块可以导出任何声明,例如变量声明、函数声明、类型声明、using 指令以及 using 声明。此外,import 声明本身也可以被导出。从模块中导出实体要使用 export 关键字。凡是没有从模块中导出的东西,都只能在模块内部可见。所有被导出实体的集合称为模块接口(module interface)。

下面是一个名为 Person.cppm 的模块接口文件示例,它定义了一个 person 模块,并导出了一个 Person 类。请注意,它导入了 std 提供的功能。

export module person; // 命名模块声明
import std; // 导入声明
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;
};

按照标准术语,从命名模块声明开始(也就是前面代码片段的第一行)一直到文件末尾的全部内容,都称为模块辖域(module purview)。

这个 Person 类可以通过如下方式导入 person 模块来使用(test.cpp):

import person; // person 模块的导入声明
import std;
using namespace std;
int main()
{
Person person { "Kole", "Webb" };
println("{}, {}", person.getLastName(), person.getFirstName());
}

几乎任何带名字的东西都可以从模块中导出。例如,类定义、函数原型、类枚举类型、using 声明与指令、命名空间等等。如果某个命名空间本身通过 export 关键字被显式导出,那么该命名空间内部的所有内容也都会自动被导出。例如,下面这段代码导出了整个 DataModel 命名空间; 因此,无需再显式导出其中的各个类和类型别名:

export module datamodel;
import std;
export namespace DataModel
{
class Person { /* … */ };
class Address { /* … */ };
using Persons = std::vector<Person>;
}

你还可以使用导出块(export block)来导出整块声明。示例如下:

export
{
namespace DataModel
{
class Person { /* … */ };
class Address { /* … */ };
using Persons = std::vector<Person>;
}
}

一个模块可以拆分为一个模块接口文件和一个或多个模块实现文件(module implementation file)。模块实现文件通常使用 .cpp 作为扩展名。你可以自由决定哪些实现放入模块实现文件,哪些实现保留在模块接口文件中。一种做法是把所有函数和成员函数实现都移到模块实现文件中,而模块接口文件里只保留函数原型、类定义等等。另一种做法是把小型函数和小型成员函数的实现保留在接口文件中,而把其他函数和成员函数的实现移到实现文件中。这里你有很大的灵活性。

模块实现文件同样需要包含一个命名模块声明,用来说明这些实现属于哪个模块,不过这里不带 export 关键字。例如,前面的 person 模块可以按如下方式拆成接口文件和实现文件。先来看模块接口文件:

export module person; // 模块声明
import std;
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; }

请注意,这个实现文件并没有显式导入 person 模块。module person 这一声明会隐式包含一条 import person 声明。还要注意,实现文件中也没有显式导入 std,尽管它在成员函数实现中使用了 std::string。这是因为有了隐式的 import person,并且该实现文件本身就是同一个 person 模块的一部分,因此它会隐式继承模块接口文件中的 std 导入声明。相比之下,在 test.cpp 中添加 import person 并不会隐式继承 std 的导入声明,因为 test.cpp 并不是 person 模块的一部分。关于这一点后面还有更多内容要说,那会在本章后面的“可见性 vs. 可达性”一节中讨论。

模块实现文件不能导出任何内容; 只有模块接口文件才能导出内容。

如果你使用的是本章后面会讨论的头文件,而不是模块,那么强烈建议你在头文件(.h)中只放声明,并把所有实现移动到源文件(.cpp)中。这样做的一个原因是改善编译时间。如果你把实现也放进头文件,那么哪怕只是修改了一条注释,也会迫使所有包含该头文件的其他源文件重新编译。对于某些头文件来说,这甚至会波及整个代码库,导致整个程序被完全重新编译。相比之下,如果把实现放在源文件中,那么在不触碰头文件的前提下修改这些实现,就只需要重新编译这一个源文件。

模块的情况则不同。模块接口(module interface)只由类定义、函数原型等内容构成,并不包括任何函数或成员函数实现,即使这些实现代码直接写在模块接口文件中也是如此。这意味着,修改一个位于模块接口文件中的函数或成员函数实现,并不会要求模块用户重新编译,只要你没有改动接口部分,例如函数头(函数名、参数列表和返回类型)。有两个例外: 使用 inline 关键字标记的函数,以及模板定义。对于这两类内容,编译器在编译使用它们的客户端代码时,必须知道它们的完整实现。因此,任何对内联函数或模板定义的修改,都有可能触发客户端代码的重新编译。

尽管从技术上讲,现在已经不再必须把接口和实现拆开,但在某些情况下,我仍然建议这么做。最主要的目标应当是让接口保持清晰且易读。函数实现可以保留在接口中,前提是它们不会遮蔽接口本身,也不会让用户难以快速看清公共接口究竟提供了什么。例如,如果某个模块有一个相当庞大的公共接口,那么就最好不要让实现代码掺杂其中,否则用户很难快速总览它究竟提供了哪些能力。当然,像 getter 和 setter 这样的小函数仍然可以保留在接口里,因为它们通常不会真正影响接口的可读性。

将接口与实现拆分开,可以通过多种方式完成。一种做法,就是像上一节所说的那样,把模块拆成接口文件和实现文件。另一种做法,则是在单个模块接口文件内部,把接口和实现分开。例如,下面这个 Person 类就定义在一个单独的模块接口文件(person.cppm)中,但它的实现与接口是分开排列的:

export module person;
import std;
// 类定义
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(lastName) } { }
const std::string& Person::getFirstName() const { return m_firstName; }
const std::string& Person::getLastName() const { return m_lastName; }

正如前面提到的那样,当你在另一个并不属于 person 模块的源文件中导入 person 模块时——例如在 test.cpp 中——你并不会隐式继承 person 模块接口文件里的 std 导入声明。如果在 test.cpp 中没有显式导入 std,那么像 std::string 这样的名字就不是可见的(visible),这意味着下面高亮的这一行代码无法编译:

import person;
int main()
{
std::string str;
}

尽管如此,即使你没有在 test.cpp 中显式添加对 std 的导入,下面这些代码仍然可以正常工作:

Person person { "Kole", "Webb" };
const auto& lastName { person.getLastName() };
auto length { lastName.length() };

为什么这段代码能工作? 这是因为在 C++ 中,可见性(visibility)和可达性(reachability)是两回事。通过导入 person 模块,来自 std 的功能变得可达,但并非可见。可达类的成员函数会自动变得可见。这一切意味着,你依然可以使用 std 的某些功能,例如用 auto 类型推导把 getLastName() 的结果保存进变量,并在其上调用 length() 之类的成员函数。

如果你想让 std::string 这个名字在 test.cpp 中真正可见,那么就必须显式导入 std<string>

C++ 标准本身并没有专门使用子模块(submodule)这一术语; 不过,模块名中允许使用点号,这使得你可以按照自己喜欢的层次结构来组织模块。例如,前面曾给出过一个 DataModel 命名空间的例子:

export module datamodel;
import std;
export namespace DataModel
{
class Person { /* … */ };
class Address { /* … */ };
using Persons = std::vector<Person>;
}

这里的 PersonAddress 类都位于 DataModel 命名空间中,并且属于 datamodel 模块。你可以把它改造成两个子模块:datamodel.persondatamodel.addressdatamodel.person 子模块的模块接口文件如下:

export module datamodel.person; // datamodel.person 子模块
export namespace DataModel { class Person { /* … */ }; }

下面是 datamodel.address 的模块接口文件:

export module datamodel.address; // datamodel.address 子模块
export namespace DataModel { class Address { /* … */ }; }

最后,datamodel 模块本身可以这样定义。它导入并立即导出这两个子模块。

export module datamodel; // datamodel 模块
export import datamodel.person; // 导入并导出 person 子模块
export import datamodel.address; // 导入并导出 address 子模块
import std;
export namespace DataModel { using Persons = std::vector<Person>; }

当然,子模块中类的成员函数实现也可以放在模块实现文件中。例如,假设 Address 类有一个默认构造函数,它只是向标准输出打印一句话。那么这个实现可以放在名为 datamodel.address.cpp 的文件中:

module datamodel.address; // datamodel.address 子模块
import std;
using namespace std;
DataModel::Address::Address() { println("Address::Address()"); }

用子模块来组织代码的一个好处是,客户端既可以一次性导入全部内容,也可以只导入自己需要的部分。例如,如果客户端代码需要访问 datamodel 模块中的全部内容,那下面这种导入声明最省事:

import datamodel;

另一方面,如果客户端代码只关心 Address 类,那么下面这条导入声明就足够了:

import datamodel.address;

一次性导入全部内容,通常比选择性地只导入所需部分更方便,尤其是对于那些很少变化的稳定模块而言。不过,对于不那么稳定的模块,如果采用按需选择性导入,那么在模块发生变动时,有可能提升构建速度。例如,如果 datamodel.address 子模块的接口发生了变化,那么就只需要重新编译那些导入了该子模块的文件。

组织模块的另一种方式,是把模块拆分为若干独立的分区(partition)。子模块和分区的区别在于: 子模块这种组织方式对模块用户是可见的,因此用户可以有选择地只导入自己想用的子模块。而分区则是用来对模块进行内部组织的。分区不会暴露给模块的用户。所有在模块接口分区文件(module interface partition file)中声明的分区,最终都必须由主模块接口文件(primary module interface file)直接或间接导出。一个模块永远只有一个这样的主模块接口文件,也就是那个包含 export module name 声明的接口文件。

创建模块分区的方法,是用冒号把模块名和分区名分开。分区名可以是任意合法标识符。例如,上一节中的 DataModel 模块就可以用分区而不是子模块来重新组织。下面是 person 分区,它位于一个名为 datamodel.person.cppm 的模块接口分区文件中:

export module datamodel:person; // datamodel:person 分区
export namespace DataModel { class Person { /* … */ }; }

下面是 address 分区,其中还包含一个默认构造函数:

export module datamodel:address; // datamodel:address 分区
export namespace DataModel
{
class Address
{
public:
Address();
/* … */
};
}

不过,当你把实现文件与分区结合使用时,这里有个注意点: 某个特定分区名只能对应一个文件。因此,如果你有一个实现文件以如下声明开头,那就是不合法的:

module datamodel:address;

正确的做法是,把 address 分区中的实现放在 datamodel 模块自己的实现文件中,如下所示:

module datamodel; // 不是 datamodel:address!
import std;
using namespace std;
DataModel::Address::Address() { println("Address::Address()"); }

多个文件不能拥有相同的分区名。多个模块接口分区文件使用相同分区名是非法的,而且模块接口分区文件中声明的实现也不能放到一个使用相同分区名的实现文件里。正确做法是,把这些实现放进该模块自己的模块实现文件中。

编写使用分区来组织的模块时,有一个重要点必须牢记: 每个模块接口分区最终都必须由主模块接口文件直接或间接导出。导入某个分区时,你只需写上以冒号开头的分区名,例如 import :person。像 import datamodel:person 这样的写法是非法的。请记住,分区不会暴露给模块用户; 分区仅用于模块的内部组织。因此,用户不能导入某个特定分区,他们只能导入整个模块。分区只能在模块内部导入,所以在冒号前再写模块名既多余又非法。下面是 datamodel 模块的主模块接口文件:

export module datamodel; // datamodel 模块 (主模块接口文件)
export import :person; // 导入并导出 person 分区
export import :address; // 导入并导出 address 分区
import std;
export namespace DataModel { using Persons = std::vector<Person>; }

这个按分区组织的 datamodel 模块可以这样使用:

import datamodel;
int main() { DataModel::Address a; }

前面解释过,module name 声明会隐式包含一个 import name 声明。但这一点对分区并不成立。

例如,datamodel:person 分区并不会隐式拥有 import datamodel 声明。在这个例子中,你甚至也不能显式给 datamodel:person 接口分区文件加上 import datamodel,因为那会导致循环依赖: datamodel 接口文件里包含 import :person,而 datamodel:person 接口分区文件里又包含 import datamodel

若要打破这种循环依赖,你可以把 datamodel:person 分区所需的、原本位于 datamodel 接口文件中的某些功能移到另一个分区里,然后让 datamodel:person 接口分区文件和 datamodel 接口文件都去导入这个新分区。

分区不一定非要声明在模块接口分区文件中,它也可以声明在模块实现分区文件(module implementation partition file)中——这是一种扩展名为 .cpp 的普通源代码文件——这种情况下,它就是一个实现分区(implementation partition),有时也称为内部分区(internal partition)。与模块接口分区不同,这种分区不能被导出。

例如,假设你有如下 math 主模块接口文件(math.cppm):

export module math; // math 模块声明
export namespace Math
{
double superLog(double z, double b);
double lerchZeta(double lambda, double alpha, double s);
}

再假设,这些数学函数的实现需要一些不应被模块导出的辅助函数。此时,实现分区就是存放这些辅助函数的绝佳位置。下面这段代码就在名为 math_helpers.cpp 的文件中定义了这样一个实现分区:

module math:details; // math:details 实现分区
double someHelperFunction(double a) { return /* … */ ; }

其他 math 模块实现文件可以通过导入这个实现分区来访问这些辅助函数。例如,某个 math 模块实现文件(math.cpp)可以写成这样:

module math;
import :details;
double Math::superLog(double z, double b) { return /* … */; }
double Math::lerchZeta(double lambda, double alpha, double s) { return /* … */; }

借助 import :details; 声明,superLog()lerchZeta() 就都可以调用 someHelperFunction()

当然,只有在多个其他源文件都需要使用这些辅助函数时,像这样用实现分区来安放辅助函数才真正划算。

主模块接口可以包含一个私有模块片段(private module fragment)。这个私有模块片段以如下这一行开始:

module :private;

这行之后的所有内容都属于私有模块片段。凡是在这个私有模块片段中定义的东西,都不会被导出,因此对模块使用者不可见。

第 9 章“精通类和对象”演示了 pimpl 惯用法,也称为私有实现(private implementation)惯用法。它会把一个类的所有实现细节都隐藏起来,不暴露给该类的使用者。第 9 章 中的解决方案需要两个文件: 一个主模块接口文件和一个模块实现文件。而借助私有模块片段,你可以仅用一个文件就实现这种分离。下面是一个简洁的示例:

export module adder;
import std;
export class Adder
{
public:
Adder();
virtual ~Adder();
int add(int a, int b) const;
private:
class Impl;
std::unique_ptr<Impl> m_impl;
};
module :private;
class Adder::Impl
{
public:
~Impl() { std::println("Destructor of Adder::Impl"); }
int add(int a, int b) const { return a + b;}
};
Adder::Adder() : m_impl { std::make_unique<Impl>() } { }
Adder::~Adder() {}
int Adder::add(int a, int b) const { return m_impl->add(a, b); }

这个类可以这样测试:

Adder adder;
println("Value: {}", adder.add(20, 22));

现在,为了证明私有模块片段中的东西确实都被隐藏了,我们在 Adder 类末尾再增加一个 public 成员函数 getImplementation():

export class Adder
{
/* … 同前,为简洁起见省略 …… */
private:
class Impl;
std::unique_ptr<Impl> m_impl;
public:
Impl* getImplementation() { return m_impl.get(); }
};

下面这段代码可以正常编译并运行:

Adder adder;
auto impl { adder.getImplementation() };

Adder 模块使用者的角度来看,getImplementation() 返回的是一个指向不完整类型(incomplete type)的指针。上面的代码片段只是把这个指针存到一个名为 impl 的变量中。只要你借助 auto 类型推导,那么单纯存储一个指向不完整类型的指针是完全没问题的。不过,你不能拿这个指针去做任何真正有意义的事。在这个不完整指针上调用 add() 会导致错误:

auto result { impl->add(20, 22) }; // 错误!

报错通常会类似于: use of undefined type Adder::Impl。原因就在于,Adder::Impl 类属于私有模块片段,因此对 Adder 模块的使用者而言是不可访问的。

如果你从模块接口文件中移除 module :private; 这一行,那么前面的代码片段就会顺利编译并运行。初看之下你或许会有些惊讶; 毕竟 Adder::Impl 类明明没有被显式导出。没错——它确实没有被显式导出,但由于 Adder 类本身已被导出,而 Impl 类又是在 Adder 类内部声明的,所以它会被隐式导出。

导入一个模块时,你通常会写如下这样的 import 声明:

import person;

如果你手头有遗留代码,比如一个定义了 Person 类的 person.h 头文件,那你可以把它模块化,方法是把它转换成一个真正的模块 person.cppm,然后使用 import 声明让客户端代码访问它。不过,有时你并不能这样做。也许你的 Person 类还必须继续供那些尚不支持模块的编译器使用。或者,也许 person.h 属于某个你无法修改的第三方库。在这些情况下,你可以直接导入头文件本身,如下所示:

import "person.h";

使用这种声明时,person.h 头文件中的所有内容都会被隐式导出。此外,头文件中定义的宏也会对客户端代码可见,而真正的模块则不会这样——无论是你自己的模块,还是命名模块 stdstd.compat,都不会导出其中的宏。

这样的 import 声明中,你既可以使用相对路径或绝对路径来引用头文件,也可以使用 < > 去系统头文件目录中搜索:

import "include/person.h"; // 可以包含相对或绝对路径。
import <person.h>; // 在系统包含目录中搜索。

与使用 #include 引入头文件相比,使用 import 能改善构建吞吐量,因为 person.h 会被隐式转换为一个模块,从而只编译一次,而不是每当它在某个源文件中被包含时都重新编译。因此,它也可以作为一种标准化方式来支持预编译头文件(precompiled header files),而不必依赖特定编译器的预编译头支持机制。

对于每一条指向头文件的 import 声明,编译器都会创建一个模块,其导出接口与该头文件定义的内容类似,也就是说,它会隐式导出头文件中的所有内容。这就称为头单元(header unit)。这整个过程与具体编译器相关,因此请查阅你编译器的文档,了解如何使用头单元。

所有 C++ 头文件,例如 <iostream><vector><string> 等,都属于可导入头文件(importable headers),因此可以通过 import 声明来导入。这意味着,例如,你可以这样写:

import <vector>;

当然,从 C++23 开始,更方便的做法是直接导入名为 std 的命名模块,而不是手动去逐个导入你所需的那些可导入头文件。例如,下面这条语句会让整个标准库都对你可用:

import std;

正如你现在已经知道的那样,可导入的 C++ 标准库头文件都没有 .h 扩展名,例如 <vector>,并且它们所定义的一切都位于 std 命名空间或其子命名空间中。

在 C 语言中,标准库头文件名通常都以 .h 结尾,例如 <stdio.h>,而且 C 并不使用命名空间。

标准库中大多数源自 C 的功能在 C++ 中依然可用,不过它们通常通过两套不同的头文件来提供:

  • 推荐使用的是不带 .h 扩展名、但带有 c 前缀的版本,例如 <cstdio>。这些头文件会把所有内容放进 std 命名空间。
  • 另一套是 C 风格版本,保留 .h 扩展名,例如 <stdio.h>。它们不使用命名空间。除非你需要编写同时既是合法 C++、又是合法 C 的代码,否则不推荐使用它们。本书不会继续讨论这种场景。

从技术上讲,旧版本头文件也允许把内容放进 std 命名空间中,而新版本头文件也允许额外把内容放进全局命名空间中。但这种行为并没有被标准化,因此你不应依赖它。

如前面所说,当你使用 import std; 时,也会自动获得来自 C 风格头文件的函数,例如定义在 <cmath> 中的数学函数。它们会位于 std 命名空间中,例如 std::sqrt()。如果你导入的是 std.compat,那么这些 C 风格函数还会额外在全局命名空间中可用,例如 ::sqrt()

不过,如果你无法使用 stdstd.compat 这两个命名模块,那就要记住:C 标准库头文件并不保证一定可以通过 import 声明来导入。在这种情况下,为了稳妥起见,应当使用 #include <cxyz>,而不是 import <cxyz>;

此外,正如上一节提到的那样,导入一个真正的模块(例如 stdstd.compat)不会让其中定义的任何 C 风格宏对导入方代码可见。当你需要使用来自 C 标准库的 C 风格宏时,这一点尤其需要牢记。好在这种宏并不多! 其中一个例子就是 <cassert>,这是一个 C 标准库头文件,定义了 assert() 宏,它会在 第 31 章“征服调试”中进一步解释。由于命名模块 stdstd.compat 不会让 assert() 宏对导入方可见,而 <cassert> 又是一个 C 标准库头文件,因此并不保证可通过 import 导入,所以要想访问 assert(),你必须使用 #include <cassert>

如果你确实需要在模块接口文件或模块实现文件中使用 #include,那么这些 #include 指令应当放在全局模块片段(global module fragment)中。全局模块片段必须位于任何命名模块声明之前,并以前导的无名模块声明开始。全局模块片段中只能包含预处理指令,例如 #include#define 等。除了全局模块片段和注释之外,任何内容都不能出现在命名模块声明之前。例如,如果你需要使用 <cassert> C 头文件中的功能,就可以这样写:

module; // 全局模块片段的开始
#include <cassert> // 包含旧版头文件
export module person; // 命名模块声明
import std;
export class Person { /* … */ };

在模块接口文件或模块实现文件中,把所有 #include 指令都放进全局模块片段里。

第 1 章 介绍了 #include 预处理器指令,它用来把头文件内容包含进来。除此之外,还有一些其他预处理器指令可用。下表列出了一些最常见的预处理器指令:

预处理器指令功能常见用途
#include [file]将名为 [file] 的文件内容插入到当前指令所在的位置。几乎总是用于包含头文件,从而让代码能够使用别处定义的功能。
#define [id] [value]将标识符 [id] 的每次出现替换为 [value]在 C 中常用于定义常量值或宏。C++ 对常量和大多数宏场景都提供了更好的机制。宏可能很危险,因此要谨慎使用。下一节会给出一些示例。
#undef [id]取消此前通过 #define 定义的标识符 [id]当某个已定义标识符只在有限代码范围内需要时使用。
#if [expression] #elif [expression] #else #endif根据给定表达式的结果,条件性地包含一段代码。常用于为特定平台提供特定代码。
#ifdef [id] #endif #ifndef [id] #endif根据某个标识符是否已通过 #define 定义,条件性地包含代码。#ifdef [id] 等价于 #if defined(id),而 #ifndef [id] 等价于 #if !defined(id)最常见的用途是防止循环包含。每个头文件都以一个 #ifndef 检查某个标识符尚未定义开始,随后用 #define 定义这个标识符。头文件末尾再以 #endif 结束。这样就能防止同一文件被多次包含; 本章后面的“头文件”一节会继续讨论。
#elifdef [id] #elifndef [id]#elifdef [id] 等价于 #elif defined(id),#elifndef [id] 等价于 #elif !defined(id)针对已有功能的简写语法。
#pragma [xyz]控制编译器特定行为。[xyz] 取决于具体编译器。大多数编译器都支持 once,用来防止头文件被多次包含。后面“头文件”一节会给出例子。
#error [message]让编译立即终止,并给出指定消息。可用于当用户试图在不受支持的平台上编译代码时中止编译。
#warning [message]让编译器把指定消息作为一条警告输出,但编译继续进行。用于在不影响编译结果的前提下向用户显示警告。

你可以使用 C++ 预处理器来编写(macro),它们有点像一些小函数。下面是一个例子:

#define SQUARE(x) ((x) * (x)) // 宏定义后没有分号!
int main()
{
println("{}", SQUARE(5));
}

宏是从 C 沿袭下来的遗留机制,它们在某种程度上类似于 inline 函数,但不同的是,宏不会进行类型检查,而且预处理器只是机械地把对它们的调用替换成展开后的文本。预处理器并不会应用真正的函数调用语义。这种行为可能导致意料之外的结果。例如,想想看,如果你不是用 5,而是像下面这样用 2+3 来调用 SQUARE 宏,会发生什么:

println("{}", SQUARE(2 + 3));

你当然期望 SQUARE 计算出 25,而它也的确算出了 25。但是,如果你在宏定义中漏掉了一些括号,让它变成下面这样呢?

#define SQUARE(x) (x * x)

现在,调用 SQUARE(2+3) 得到的结果就是 11,而不是 25! 请记住,宏会完全不顾函数调用语义,只做机械展开。这意味着宏体中的每一个 x 都会被替换成 2 + 3,最终展开成这样:

println("{}", (2 + 3 * 2 + 3));

按照正常的运算优先级,这条语句会先做乘法,再做加法,从而得到 11,而不是 25!

宏还可能带来性能问题。假设你这样调用 SQUARE 宏:

println("{}", SQUARE(veryExpensiveFunctionCallToComputeNumber()));

预处理器会把它替换成下面这样:

println("{}", ((veryExpensiveFunctionCallToComputeNumber()) *
(veryExpensiveFunctionCallToComputeNumber())));

现在,你把那个昂贵函数调用了两次——这又是一个应该避免使用宏的理由。

宏还会给调试带来麻烦,因为你写下的代码并不是编译器真正看到的代码,也不是调试器中最终呈现出来的代码(这是由于预处理器的查找替换行为)。基于这些原因,你应该尽量完全避免使用宏,转而使用内联函数。本节之所以仍然介绍这些细节,只是因为现实中仍有相当多的 C++ 代码在使用宏。你必须理解它们,才能阅读和维护这类代码。

本节将讨论 C++ 中的链接性(linkage)概念。正如 第 1 章 所说,C++ 源文件首先会由预处理器处理,所有预处理器指令都会在这一阶段被处理,最终形成翻译单元(translation unit)。接着,所有翻译单元会被分别编译成目标文件(object file),其中包含机器可执行代码,但对函数等实体的引用尚未被解析。解析这些引用的是最后一个阶段——链接器(linker),它会把所有目标文件链接成最终的可执行文件(executable)。从技术上讲,编译过程其实还包含更多阶段,不过对本节来说,这个简化视图已经足够。

C++ 翻译单元中的每一个名字——包括函数和全局变量——要么有链接性,要么没有链接性,而这决定了这个名字可以在哪里被定义,以及可以从哪里访问。链接性一共有四种:

  • 无链接性(No linkage): 该名字只能在其定义所在的作用域中访问。
  • 外部链接性(External linkage): 该名字可以从任何翻译单元访问。
  • 内部链接性(Internal linkage, 也称静态链接性): 该名字只能从当前翻译单元访问,不能被其他翻译单元访问。
  • 模块链接性(Module linkage): 该名字可以从同一模块中的任意翻译单元访问。

默认情况下,函数和全局变量都具有外部链接性。不过,你也可以通过使用匿名命名空间(anonymous namespace)来指定内部(或静态)链接性。例如,假设你有两个源文件:FirstFile.cppAnotherFile.cpp。下面是 FirstFile.cpp:

void f();
int main()
{
f();
}

请注意,这个文件提供了 f() 的原型,但并没有给出定义。下面是 AnotherFile.cpp:

import std;
void f();
void f()
{
std::println("f");
}

这个文件同时提供了 f() 的原型和定义。请注意,在两个不同文件中为同一个函数写原型是合法的。如果你把原型放在头文件中,再在每个源文件里 #include 该头文件,那么预处理器本质上就是在为你做这件事。这个示例中我没有使用头文件。以前之所以使用头文件,是因为维护(并保持同步)一份函数原型拷贝会更容易; 但现在既然 C++ 已经支持模块,那么相较于头文件,更推荐使用模块。

这两个源文件都能顺利编译,程序也能正常链接: 因为 f() 具有外部链接性,main() 可以从另一个文件调用它。

不过,假设你在 AnotherFile.cpp 中把 f() 包进匿名命名空间里,从而让它具有内部链接性,如下所示:

import std;
namespace
{
void f();
void f()
{
std::println("f");
}
}

匿名命名空间中的实体都具有内部链接性,因此它们在同一翻译单元中、从其声明之后的任何位置都可以访问,但不能从其他翻译单元访问。这样修改之后,每个源文件本身仍然都能通过编译,但链接阶段会失败,因为 f() 具有内部链接性,从而在 FirstFile.cpp 中不可用。

除了使用匿名命名空间赋予名字内部链接性之外,另一种做法是在声明前加上 static 关键字。前面的匿名命名空间示例也可以写成下面这样。请注意,你不需要在 f() 的定义前再次重复写 static。只要它出现在该函数名第一次出现的位置之前,就无需重复。

import std;
static void f();
void f()
{
std::println("f");
}

这个版本的代码在语义上与使用匿名命名空间的版本完全相同。

如果某个翻译单元需要一个仅在该翻译单元内部使用的辅助实体,请将它包在匿名命名空间中,以赋予它内部链接性。不推荐为此使用 static 关键字。

另一个相关关键字 extern,乍一看似乎应当是 static 的反义词,用于为其前面的名字指定外部链接性; 在某些情况下,它确实可以这样用。例如,consttypedef 默认具有内部链接性。你可以使用 extern 来赋予它们外部链接性。不过,extern 还带来了一些额外复杂性。当你将某个名字声明为 extern 时,编译器会把它视为声明而不是定义。对于变量来说,这意味着编译器不会为该变量分配存储空间。你必须在别处提供一个不带 extern 关键字的独立定义。例如,下面是 AnotherFile.cpp 的内容:

extern int x;
int x { 3 };

或者,你也可以直接在 extern 语句里初始化 x,这样它就同时兼具声明和定义的作用:

extern int x { 3 };

在这个例子里,extern 并没有太大意义,因为 x 无论如何本来就默认具有外部链接性。extern 真正的用途是在你想从另一个源文件 FirstFile.cpp 中使用 x 时:

import std;
extern int x;
int main()
{
std::println("{}", x);
}

这里,FirstFile.cpp 使用了一个 extern 声明,从而能够使用 x。编译器必须先看到 x 的声明,才能在 main() 中使用 it。如果你声明 x 时不加 extern,编译器就会把它当作定义,并为 x 分配存储空间,从而导致链接步骤失败(因为全局作用域中就会存在两个 x 变量)。借助 extern,你可以让变量在多个源文件之间全局可访问。

根本上说,并不推荐使用全局变量。它们令人困惑且容易出错,尤其是在大型程序里。请谨慎使用!

在 C++20 模块引入之前,头文件(header file),也常简称为 header,被用作向某个子系统或某段代码提供接口的机制。头文件最常见的用途,是声明那些将在别处定义的函数。所谓声明(declaration),是告诉编译器某个具名实体(函数、变量等)是存在的。对于函数而言,声明会给出函数的调用方式,也就是参数数量、参数类型以及返回类型。而定义(definition)同样会告诉编译器某个具名实体存在,但它还会给出实体本身的具体内容。对于函数而言,定义中会包含函数的实际代码。所有定义都是声明,但并非所有声明都是定义。声明,以及类定义(类定义本身也是声明,见 第 8 章“精通类和对象”),通常会放进头文件中,扩展名一般是 .h。定义,包括非内联类成员的定义,通常会放进源文件中,扩展名一般是 .cpp。本书全程使用模块,但本节仍会简要讨论一些使用头文件时较棘手的问题,例如如何避免重复定义和循环依赖,因为你在遗留代码库中一定会遇到这些情况。

单个翻译单元中,某个变量、函数、类类型、枚举类型、概念或模板都只能有且仅有一个定义。对于某些类型,允许有多个声明,但不允许有多个定义。此外,在整个程序范围内,非内联函数和非内联变量也都只允许有且仅有一个定义。

使用头文件时,很容易违反单一定义规则,从而产生重复定义。下一节将讨论如何避免通过头文件造成的这类重复定义。

在模块之间,违反单一定义规则要困难得多,因为每个模块都比其他模块隔离得好得多。其中一个主要原因在于: 某个模块中未被导出的实体拥有模块链接性,因此对其他模块中的代码不可访问。也就是说,多个模块完全可以各自定义自己本地的、未导出的同名实体,而不会有任何问题。相反,在非模块化源文件中,本地实体默认具有外部链接性。当然,即使是在模块内部,你仍然需要确保自己不违反单一定义规则。

假设 A.h 包含了定义 Logger 类的 Logger.h,而 B.h 也同样包含了 Logger.h。如果你有一个名为 App.cpp 的源文件,同时包含 A.hB.h,那么你最终就会得到 Logger 类的重复定义,因为 Logger.h 通过 A.hB.h 各被包含了一次。

要避免这种重复定义问题,可以使用一种叫作 include guard(也称 header guard) 的机制。下面的代码片段展示了带有 include guard 的 Logger.h 头文件写法。每个头文件开头的 #ifndef 指令都会检查某个特定键值尚被定义。如果这个键值已经定义,编译器就会直接跳到与之匹配的 #endif,而这个 #endif 通常写在文件末尾。如果这个键值尚未定义,文件就会继续执行,并先定义该键值,这样以后再次包含同一个文件时就会被跳过。

#ifndef LOGGER_H
#define LOGGER_H
class Logger { /* … */ };
#endif // LOGGER_H

另一种做法是,如今几乎所有编译器都支持 #pragma once 指令,它可以替代 include guard。在头文件开头加上 #pragma once,就能确保该头文件只会被包含一次,从而避免因多次包含头文件而导致的重复定义。示例如下:

#pragma once
class Logger { /* … */ };

另一个用来避免头文件问题的工具是前向声明(forward declaration)。如果你需要引用某个类,但又不能包含它的头文件(例如因为它本身又严重依赖于你正在编写的这个类),那么你可以先告诉编译器“这个类存在”,而不通过 #include 机制提供其正式定义。当然,在这种情况下你实际上并不能在代码中真正“使用”这个类,因为编译器除了知道这个具名类在最终链接完成后会存在之外,对它一无所知。不过,你仍然可以在代码中使用指向前向声明类的指针和引用。你还可以声明那些按值返回前向声明类,或者把前向声明类按值作为参数的函数。当然,无论是定义该函数的代码,还是调用该函数的代码,最终都必须包含正确的头文件,以获得前向声明类的完整定义。

例如,假设 Logger 类要使用另一个名为 Preferences 的类,后者负责追踪用户设置。反过来,Preferences 类也可能会使用 Logger 类,于是就形成了一个不能仅靠 include guard 解决的循环依赖(circular dependency)。这类情况下就需要使用前向声明。下面的代码中,Logger.h 头文件使用了对 Preferences 类的前向声明,随后在不包含其头文件的情况下引用了 Preferences 类:

#pragma once
#include <string_view>
class Preferences; // 前向声明
class Logger
{
public:
void setPreferences(const Preferences& preferences);
void logError(std::string_view error);
};

建议在头文件中尽可能多地使用前向声明,而不是包含其他头文件。这样可以减少编译和重新编译时间,因为它打断了当前头文件对其他头文件的依赖。当然,你的实现文件仍然必须为那些前向声明过的类型包含正确的头文件; 否则,代码将无法编译。

如果你想查询某个头文件是否存在,可以使用 __has_include("filename")__has_include(<filename>) 这两个预处理器常量表达式。若头文件存在,它们的值为 1; 若不存在,则为 0。例如,在 <optional> 头文件被 C++17 完全批准之前,一些编译器就已经在 <experimental/optional> 中提供了初步版本。你可以使用 __has_include() 来检查系统上可用的是这两个头文件中的哪一个:

#if __has_include(<optional>)
#include <optional>
#elif __has_include(<experimental/optional>)
#include <experimental/optional>
#endif

头文件中不应包含任何模块 import 声明。标准规定,模块 import 声明必须位于文件开头,出现在任何其他声明之前,并且不能来自头文件包含或预处理器宏展开。这会让构建系统更容易发现模块依赖,进而决定模块需要按何种顺序进行构建。

你可以使用特性测试宏(feature-test macro)来检测编译器支持哪些核心语言特性。这些宏的名字都以 __cpp___has_cpp_ 开头。下面列出的是一些例子。完整列表请查阅你喜欢的 C++ 参考资料。

  • __cpp_range_based_for
  • __cpp_binary_literals
  • __cpp_char8_t
  • __cpp_generic_lambdas
  • __cpp_consteval
  • __cpp_coroutines
  • __has_cpp_attribute([attribute_name])

这些宏的值都是一个数字,表示某项特性被加入或更新时的年月。日期格式为 YYYYMM。例如,__cpp_binary_literals 的值是 201304,也就是 2013 年 4 月,即二进制字面量引入的时间。再比如,__has_cpp_attribute(nodiscard) 的值可能是 201603,即 2016 年 3 月,这是 [[nodiscard]] 属性首次引入的时间。它也可能是 201907,即 2019 年 7 月,这是该属性被更新为允许指定原因(例如 [[nodiscard("Reason")]])的时间。

所有这些核心语言特性的特性测试宏都无需包含任何特定头文件即可使用。下面是一个使用示例:

int main()
{
#ifdef __cpp_range_based_for
println("Range-based for loops are supported!");
#else
println("Bummer! Range-based for loops are NOT supported!");
#endif
}

第 16 章“C++ 标准库概览”解释了,标准库特性也有类似的特性测试宏。

C++ 中 static 关键字有多种不同用途,乍看之下彼此并不相关。把多个不同语义都“塞”进同一个关键字里,部分原因是为了避免在语言中引入更多新关键字。本章前面已经在链接性的上下文中讨论过 static 的一种用途。本节则讨论它的其他几种用途。

你可以为类声明 static 数据成员和成员函数。与非 static 数据成员不同,static 数据成员并不属于每个对象本身。相反,对于某个类的 static 数据成员,整个程序中只有一份拷贝,它存在于该类所有对象之外。

static 成员函数同样也属于类级别,而不是对象级别。static 成员函数并不是在某个特定对象的上下文中执行的; 因此,它没有隐式 this 指针。这也意味着 static 成员函数不能被标记为 const

第 9 章 中给出了 static 数据成员和 static 成员函数的示例。

C++ 中 static 关键字的另一种用途,是创建那种在多次离开并重新进入其作用域之间仍能保留值的变量。例如,函数内部的一个 static 局部变量,就很像一个只能在该函数内部访问的全局变量。static 变量的一个常见用途,是“记住”某个函数是否已经执行过特定初始化。例如,采用这种技巧的代码可能会长成这样:

void performTask()
{
static bool initialized { false };
if (!initialized) {
println("initializing");
// 执行初始化。
initialized = true;
}
// 执行所需的任务。
}

不过,static 变量可能会让代码变得难以理解,而且通常都有更好的代码组织方式可以避免它们。在这个例子里,你也许更应该编写一个类,并把所需初始化放进构造函数中完成。

当然,在某些场景下它们也确实有用。一个例子就是实现 Meyers 单例设计模式,这会在 第 33 章“应用设计模式”中解释。

在离开 static 变量这个话题之前,还需要考虑这类变量的初始化顺序。程序中的所有全局变量和 static 变量都会在 main() 开始之前完成初始化。同一个源文件中的变量会按照它们在源文件中出现的顺序初始化。例如,在下面这个文件中,可以保证 Demo::x 会先于 y 完成初始化:

class Demo
{
public:
static int x;
};
int Demo::x { 3 };
int y { 4 };

不过,C++ 对不同源文件中的非局部变量的初始化顺序既没有规范也没有保证。如果你在一个源文件里有全局变量 x,在另一个源文件里有全局变量 y,那你根本无法知道哪个会先初始化。通常来说,这种未指定性并不值得担心。不过,如果某个全局变量或 static 变量依赖于另一个,问题就会出现。请记住,对象的初始化意味着会运行它们的构造函数。一个全局对象的构造函数可能会访问另一个全局对象,并假定后者已经完成构造。如果这两个全局对象位于两个不同源文件中,你就无法指望其中一个一定先于另一个被构造,也无法控制初始化顺序。不同编译器之间、甚至同一编译器的不同版本之间,初始化顺序都可能不同; 甚至仅仅是往项目里再添加一个文件,这个顺序都可能改变。

不同源文件中的非局部变量初始化顺序是未定义的。

非局部变量会按与初始化相反的顺序析构。而不同源文件中的非局部变量初始化顺序本身就是未定义的,这也就意味着析构顺序同样未定义。

在遗留代码中,你可能会遇到 C 风格可变长参数列表的用法。在新代码里,你应当避免使用它们,而应改用类型安全的可变参数模板(variadic templates),它们会在 第 26 章“高级模板”中介绍。

为了让你至少了解 C 风格可变长参数列表,我们来看一下 <cstdio> 中的 C 函数 printf()。你可以向它传入任意数量的参数:

printf("int %d\n", 5);
printf("String %s and int %d\n", "hello", 5);
printf("Many ints: %d, %d, %d, %d, %d\n", 1, 2, 3, 4, 5);

C/C++ 提供了相应语法以及一些工具宏,让你能够编写自己的可变参数函数。这类函数通常看起来都很像 printf()。例如,假设你想写一个临时凑合用的调试函数: 当调试标志被设置时,它把字符串打印到 stderr; 而当调试标志未设置时,则什么也不做。与 printf() 一样,这个函数应当能够打印带任意数量参数、参数类型也任意的字符串。一个简单实现如下:

import std;
#include <cstdarg>
#include <cstdio>
bool debug { false };
void debugOut(const char* str,)
{
if (debug) {
va_list ap;
va_start(ap, str);
vfprintf(stderr, str, ap);
va_end(ap);
}
}

这段代码使用了 va_list()va_start()va_end(),它们都是在 <cstdarg> 中定义的宏,因此必须显式 #include <cstdarg>,因为 import std; 不会导出任何宏。同样地,stderr 也是定义在 <cstdio> 中的一个宏,所以也必须显式 #include <cstdio>

debugOut() 的原型中包含了一个带类型且具名的参数 str,后面跟着 (省略号)。省略号表示任意数量、任意类型的参数。若要访问这些参数,你必须先声明一个 va_list 类型变量,并用 va_start 初始化它。传给 va_start() 的第二个参数必须是参数列表中最右侧那个具名变量。所有可变参数函数都至少需要一个具名参数。debugOut() 只是简单地把这份参数列表传给 vfprintf()(这是 <cstdio> 中的一个标准函数)。在 vfprintf() 返回之后,debugOut() 会调用 va_end() 来终止对可变参数列表的访问。调用过 va_start() 之后,你必须始终调用 va_end(),以确保函数结束时栈处于一致状态。

你可以像下面这样使用这个函数:

debug = true;
debugOut("int %d\n", 5);
debugOut("String %s and int %d\n", "hello", 5);
debugOut("Many ints: %d, %d, %d, %d, %d\n", 1, 2, 3, 4, 5);

如果你想亲自访问这些实参,可以使用 va_arg()。它接受一个 va_list 作为第一个参数,以及你希望按其解释当前参数值的类型。不幸的是,除非你自己显式提供一种结束方式,否则根本没有办法知道参数列表何时结束。例如,你可以让第一个参数表示后面参数的数量。或者,如果参数是一组指针,你可以要求最后一个指针为 nullptr。办法有很多,但无一例外都会给程序员带来额外负担。

下面的例子演示了第一种技术: 由调用方在第一个具名参数中指定实际提供了多少参数。这个函数接受任意数量的 int,并把它们打印出来。

void printInts(unsigned num,)
{
va_list ap;
va_start(ap, num);
for (unsigned i { 0 }; i < num; ++i) {
int temp { va_arg(ap, int) };
print("{} ", temp);
}
va_end(ap);
println("");
}

你可以像下面这样调用 printInts()。请注意,第一个参数指定了后面会跟着多少个整数。

printInts(5, 5, 4, 3, 2, 1);

为什么你不应该使用 C 风格可变长参数列表

Section titled “为什么你不应该使用 C 风格可变长参数列表”

访问 C 风格可变长参数列表其实并不安全。从 printInts() 函数中你就能看出,这里存在几个明显风险。

  • 你并不知道参数的数量。在 printInts() 这种情况下,你只能相信调用方会把正确的参数个数作为第一个参数传进来。而在 debugOut() 的情形下,你则只能相信调用方会在 str 字符串之后,传入与字符串中替换字段数量一致的参数个数。
  • 你并不知道参数的类型。va_arg() 接受一个类型参数,并按照这个类型去解释当前位置上的值。然而,你完全可以告诉 va_arg() 用任意类型去解释该值。它没有任何办法验证你提供的类型是否正确。

避免使用 C 风格可变长参数列表。更好的做法是传入一个 std::arrayvector 值集合,或者使用 第 1 章 中介绍的初始化列表,又或者像 第 26 章 所述,使用类型安全的可变参数模板。

本章首先详细讨论了如何编写和使用模块,并介绍了几个使用老式头文件时较棘手的方面。你还学习了预处理器指令、预处理器宏、链接性的细节、单一定义规则,以及 staticextern 关键字的不同用法。本章最后则讨论了如何编写 C 风格可变长参数列表。

预处理器指令和 C 风格可变长参数列表之所以重要,是因为你很可能会在遗留代码库中遇到它们。不过,在任何新编写的代码中,它们都应尽量避免。

下一章将开始讨论模板,以及如何借助模板编写泛型代码。

通过完成下面这些练习,你可以练习本章所讨论的内容。所有练习的解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道练习上卡住了,请先回头重读本章相关部分,尽量自己找到答案,然后再去查看网站上的解答。

  1. 练习 11-1: 编写一个名为 simulator 的单文件模块,其中包含两个类 CarSimulatorBikeSimulator,位于 Simulator 命名空间内。这两个类的具体内容对本练习并不重要。只需为它们提供一个默认构造函数,并让其向标准输出打印一条消息。在 main() 函数中测试你的代码。
  2. 练习 11-2: 以练习 11-1 的解答为基础,把模块拆分成多个文件: 一个不包含任何实现的主模块接口文件,以及两个模块实现文件,分别对应 CarSimulatorBikeSimulator 类。
  3. 练习 11-3: 以练习 11-2 的解答为基础,将其改造为使用一个主模块接口文件以及两个模块接口分区文件,其中 simulator:car 分区包含 CarSimulator 类,simulator:bike 分区包含 BikeSimulator 类。
  4. 练习 11-4: 以练习 11-3 的解答为基础,增加一个名为 internals 的实现分区,其中包含一个位于 Simulator 命名空间中的辅助函数 convertMilesToKm(double miles)。一英里等于 1.6 公里。再给 CarSimulatorBikeSimulator 类各添加一个名为 setOdometer(double miles) 的成员函数,它会调用该辅助函数把给定英里数转换成公里数,然后将结果打印到标准输出。请在 main() 中确认 setOdometer() 对这两个类都能正常工作,并同时确认 main() 无法调用 convertMilesToKm()
  5. 练习 11-5: 编写一个源文件,其中包含一个值为 0 或 1 的预处理器标识符。使用预处理器指令检查该标识符的值。如果值为 1,则让编译器输出一条警告; 如果值为 0,则忽略它; 如果是任何其他值,则让编译器生成一个错误。