第3章 模块化
第3章 模块化
简介
分离编译
- 头文件
- 模块
命名空间
函数参数与返回值
- 参数传递
- 值返回
- 返回类型推导
- 后缀返回类型
- 结构化绑定
建议
3.1 简介
一个C++程序由许多独立开发的部分组成,比如函数(§1.2.1)、用户自定义类型(第2章)、类层次结构(§5.5)和模板(第7章)。管理如此众多部分的关键在于清晰地定义这些部分之间的交互。第一步也是最重要的步骤是区分一个部分的接口和它的实现。在语言层面,C++通过声明来表示接口。 声明 指定了使用函数或类型所需的一切。例如:
double sqrt(double);// 平方根函数接受一个double并返回一个double
class Vector {// 使用Vector所需的内容
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem; // elem指向一个大小为sz的double数组
int sz;
};
这里的关键点是,函数体,即函数 定义 ,可以放在“别处”。对于这个例子,我们可能也希望 Vector 的表示形式也是“别处”,但我们会稍后处理这一问题(抽象类型;§5.3)。 sqrt() 的定义看起来像这样:
double sqrt(double d)// sqrt()的定义
{
// ... 如数学教科书中的算法 ...
}
对于 Vector ,我们需要定义所有三个成员函数:
Vector::Vector(int s)// 构造函数的定义
: elem{new double[s]}, sz{s} {// 初始化成员变量
}
double& Vector::operator[](int i) {// 下标运算符的定义
return elem[i];
}
int Vector::size() {// size()的定义
return sz;
}
我们必须定义 Vector 的函数,但不需要定义 sqrt() ,因为它属于标准库的一部分。然而,这并没有实质性的区别:库只是“我们恰好使用的其他代码”,它是用与我们相同的语言功能编写的。
一个实体(如函数)可以有多个声明,但只能有一个定义。
3.2 分离编译
C++支持一种分离编译的概念,即用户代码中仅能看到所使用的类型和函数的声明。这可以通过两种方式实现:
• 头文件 (§3.2.1):将声明放在单独的文件中,称为 头文件 ,并在需要其声明的地方 #include 头文件。
• 模块 (§3.2.2):定义 模块 文件,分别编译它们,并在需要的地方 import 它们。只有明确 export 的声明才会被 导入模块 的代码看到。
两者都可以用来将程序组织成一套半独立的代码片段。这种分离可以用来最小化编译时间,并强制执行程序逻辑上不同部分的分离(从而最大限度地减少错误发生的可能性)。库通常是一系列单独编译的代码片段(例如,函数)的集合。
使用头文件来组织代码的传统做法可以追溯到C语言的最早时期,迄今为止仍是最普遍的做法。模块的使用是C++20的新特性,它在代码整洁和编译时间方面提供了巨大的优势。
3.2.1 头文件
传统上,我们将声明放在一个文件中,该文件的名称表明了我们视为模块的代码段的预期用途。例如:
// Vector.h:
class Vector {
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem;
int sz;
};
此声明将被放置在名为 Vector.h 的文件中。用户随后通过 #include 该文件(称为头文件)来访问该接口。例如:
// user.cpp:
#include <cmath> // 获取标准库数学函数接口,包括sqrt()
#include "Vector.h" // 获取Vector的接口
double sqrt_sum(const Vector& v) {
double sum = 0;
for (int i = 0; i != v.size(); ++i)
sum += std::sqrt(v[i]);
return sum;
}
为了帮助编译器确保一致性,提供 Vector 实现的 .cpp 文件也将包含提供其接口的 .h 文件:
// Vector.cpp:
#include "Vector.h" // 获取Vector的接口
Vector::Vector(int s)
: elem{new double[s]}, sz{s} {
}
double& Vector::operator[](int i) {
return elem[i];
}
int Vector::size() {
return sz;
}
user.cpp 和 Vector.cpp 中的代码共享了在 Vector.h 中呈现的 Vector 接口信息,但这两个文件在其他方面是独立的,可以分别编译。图示表示的话,程序片段可以这样展示:
程序组织的最佳方法是将程序视为一组具有明确定义依赖关系的模块。头文件通过文件来表现这种模块性,并通过分离编译来利用这种模块性。
一个自身被编译的 .cpp 文件(包括它 #include 的头文件)被称为一个 翻译单元 。一个程序可以由数千个翻译单元组成。
使用头文件和 #include 是一种非常古老的模拟模块性的方式,它存在一些显著的缺点:
- 编译时间 :如果你在101个翻译单元中都 #include 了 header.h ,编译器将会处理 header.h 的内容101次。
- 顺序依赖性 :如果我们先 #include header1.h 再 #include header2.h ,那么 header1.h 中的声明和宏(§19.3.2.1)可能会对 header2.h 中的代码含义产生影响。反之,如果先包含 header2.h ,则可能会影响 header1.h 中的代码。
- 不一致性 :在一个文件中定义一个实体(如类型或函数),然后在另一个文件中以略微不同的方式定义它,可能导致崩溃或微妙的错误。这可能发生在我们不小心或故意地在两个源文件中分别声明一个实体,而不是将其放在头文件中,或者由于不同头文件之间的顺序依赖性导致。
- 传递性 :为了在头文件中表达一个声明,所有必需的代码都必须包含在那个头文件中。这导致了巨大的代码膨胀,因为头文件会 #include 其他头文件,进而导致头文件的使用者——无论是无意还是有意——依赖于这些实现细节。
显然,这并不理想,自从在20世纪70年代初首次引入C语言以来,这种方法一直是成本和bug的主要来源。然而,数十年来使用头文件的做法一直可行,而且使用 #include 的旧代码将会“存活”很长时间,因为更新大型程序可能既昂贵又耗时。
3.2.2 模块
在C++20中,我们终于有了语言直接支持的表达模块化的方式(§19.2.4)。使用 模块 来表达来自§3.2的 Vector 和 sqrt_sum() 示例:
export module Vector; // 定义名为 "Vector" 的模块
export class Vector {
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem;
int sz;
};
Vector::Vector(int s)
: elem{new double[s]}, sz{s} {}
double& Vector::operator[](int i) {
return elem[i];
}
int Vector::size() {
return sz;
}
export bool operator==(const Vector& v1, const Vector& v2) {
if (v1.size() != v2.size())
return false;
for (int i = 0; i < v1.size(); ++i)
if (v1[i] != v2[i])
return false;
return true;
}
这定义了一个名为 Vector 的 模块 ,它导出了 Vector 类、所有成员函数以及定义 == 运算符的非成员函数。
我们使用这个 模块 的方式是在需要它的地方 导入 它。例如:
// 文件 user.cpp:
import Vector;// 获取Vector的接口
#include <cmath> // 获取包括sqrt()在内的标准库数学函数接口
double sqrt_sum(Vector& v) {
double sum = 0;
for (int i = 0; i != v.size(); ++i)
sum += std::sqrt(v[i]);// 平方根之和
return sum;
}
我本也可以 import 标准库的数学函数,但为了展示我们可以混合使用旧的和新的方法,我使用了传统的 #include 。这样的混合对于逐步将旧代码从使用 #include 升级到使用 import 至关重要。
头文件和模块之间的差异不仅仅是语法上的。
- 一个模块只需编译一次(而不是在每次使用它的翻译单元中都编译)。
- 两个模块可以以任意顺序 导入 ,而不会改变它们的含义。
- 如果你在模块中 import 或 #include 了某些内容,你的模块的用户并不会隐式地获得访问权限(也不会受到它的干扰): import 不是传递的。
这对可维护性和编译时间性能的影响可能是显著的。例如,我已经测量了使用
import std;
的“Hello, World!”程序比使用
#include <iostream>
的版本快10倍。尽管模块 std 包含整个标准库,其信息量是 <iostream> 头文件的十倍以上。原因是模块只导出接口,而头文件直接或间接包含的所有内容都会传递给编译器。这允许我们使用大型模块,这样我们就不必记住从一堆令人困惑的头文件(§9.3.4)中应该 #include 哪一个。从这里开始,我将假设所有示例都使用 import std 。
不幸的是, 模块std 并不是C++20的一部分。附录A解释了如果标准库实现还没有提供它,如何获取一个模块 std 。
定义模块时,我们 不必将声明和定义分离到不同的文件中 ;如果这能改善源代码组织,我们可以这样做,但并非必须。我们可以像这样定义简单的 Vector 模块:
export module Vector; // 定义名为 "Vector" 的模块
export class Vector {
// ...
};
export bool operator==(const Vector& v1, const Vector& v2) {
// ...
}
图示表示的话,程序片段可以这样展示:
编译器根据 export 指定符将模块的接口与其实现细节分开。因此, Vector 接口由编译器生成,用户永远无法显式命名它。
使用 模块 ,我们不必为了从用户那里隐藏实现细节而使我们的代码变得复杂;模块只会向用户授予对 export 声明的访问权限。考虑以下示例:
export module vector_printer;
import std;
export template<typename T>
void print(std::vector<T>& v) // 这是用户唯一可见的函数
{
cout << "{\n";
for (const T& val : v)
std::cout << " " << val << '\n';
cout << '}';
}
通过 import 这个简单的模块,我们并不会突然获得对整个标准库的访问。
template<typename T> 是我们使用类型参数化函数的方式(§7.2)。
3.3 命名空间
除了函数(§1.3)、类(§2.3)和枚举(§2.4)之外,C++还提供了 命名空间 作为一种机制,用于表达某些声明应该归在一起,并且它们的名称不应与其它名称冲突。例如,我可能想要尝试使用我自己定义的复数类型(§5.2.1, §17.4):
namespace My_code {
class complex {
// ...
};
complex sqrt(complex);
// ...
int main();
}
int My_code::main()
{
complex z {1,2};
auto z2 = sqrt(z);
std::cout << '{' << z2.real() << ',' << z2.imag() << "}\n";
// ...
}
int main(){
returnMy_code::main();
}
通过将我的代码置于命名空间 My_code 中,确保了我的名称与 std 命名空间中的标准库名称不会发生冲突(§ 3.3)。这种预防措施是明智的,因为标准库确实为 complex 运算提供了支持(§ 5.2.1,§ 17.4)。
访问另一个命名空间中名称的最简单方法是在名称前加上命名空间名称(例如, std::cout 和 My_code::main )。"真正的 main() " 定义在全局命名空间中,即不隶属于任何已定义的命名空间、类或函数。
如果反复限定名称变得繁琐或分散注意力,我们可以使用 using-声明 将名称引入作用域:
void my_code(std::vector<int>& x, std::vector<int>& y)
{
using std::swap; // 使标准库的 swap 在本地可用
// ...
swap(x, y); // 调用 std::swap()
other::swap(x, y); // 调用其他 swap()
// ...
}
using-声明 使得来自命名空间的名称可以如同在它出现的作用域中声明一样被使用。在使用了 using std::swap 后, swap 就好像在 my_code() 内部声明的一样。
为了获得对标准库命名空间中所有名称的访问权限,我们可以使用 using-指令 :
using namespace std;
using-指令 使得从指定命名空间中的未限定名称可以从放置该指令的作用域中访问。因此,在使用了针对 std 的 using-指令 后,我们可以直接写 cout 而不必写 std::cout 。例如,我们可以在我们的简易 vector_printer 模块中避免重复的 std:: 限定符:
export module vector_printer;
import std;
using namespace std;
export
template<typename T>
void print(vector<T>& v) // 这是用户可见的(唯一)函数
{
cout << "{\n";
for (const T& val : v)
cout << " " << val << '\n';
cout << '}';
}
重要的是,命名空间指令的这种使用并不会影响我们模块的用户;它是实现细节,仅限于模块内部。
通过使用 using-指令 ,我们失去了有选择地从该命名空间中使用名称的能力,因此应谨慎使用此功能,通常用于应用程序中普及的库(如 std )或未使用 命名空间 的应用程序过渡期间。
命名空间主要用于组织较大的程序组件,如库。它们简化了从单独开发的部分组成程序的过程。
3.4 函数参数和返回值
程序中各个部分之间传递信息的主要且推荐的方式是通过函数调用。执行任务所需的信息作为参数传递给函数,而产生的结果则作为返回值传回。例如:
int sum(const vector<int>& v)
{
int s = 0;
for (const int i : v)
s += i;
return s;
}
vector<int> fib = {1, 2, 3, 5, 8, 13, 21};
int x = sum(fib); // x 变为 53
除了函数调用外,信息还可以通过其他途径在函数间传递,比如全局变量(§ 1.5)和类对象中的共享状态(第 5 章)。由于全局变量是已知的错误来源,因此强烈不建议使用。状态通常应该只在共同实现了一个明确定义的抽象的函数间共享(例如,类的成员函数;§ 2.3)。
鉴于向函数传递信息以及从函数返回信息的重要性,存在多种实现方式也就不足为奇了。主要的关注点包括:
• 对象是被复制还是被共享?
• 如果对象被共享,它是否可变?
• 对象是否被移动,从而留下一个“空对象”?(§ 6.2.2)
对于参数传递和返回值,默认行为都是“制作一份副本”(§ 1.9),但许多副本操作实际上可以被优化为移动操作。
在 sum() 示例中,计算出的 int 值会从 sum() 中被复制出来,但如果复制一个可能非常大的 vector 进入 sum() 则既低效又无意义,因此参数是以引用的方式传递(由 & 表示;§ 1.7)。
sum() 没有理由修改其参数。通过声明 vector 参数为 const (§ 1.6)来表明这一不可变性,因此 vector 是通过 const 引用传递的。
3.4.1 参数传递
首先考虑如何将值传递给函数。默认情况下,我们会进行复制(“按值传递”),如果我们想引用调用者环境中的对象,则使用引用(“按引用传递”)。例如:
void test(vector<int> v, vector<int>& rv) // v 按值传递;rv 按引用传递
{
v[1] = 99; // 修改 v(一个局部变量)
rv[2] = 66; // 修改 rv 所引用的对象
}
int main()
{
vector<int> fib = {1, 2, 3, 5, 8, 13, 21};
test(fib, fib);
cout << fib[1] << ' ' << fib[2] << '\n';// 输出:2 66
}
当关心性能时,我们通常传递小值时采用按值传递,而大值则采用按引用传递。这里的“小”意味着“复制成本非常低廉”。确切的“小”界限取决于机器架构,但“两个或三个指针大小或更小”是一个很好的经验法则。如果这对你程序的性能可能有显著影响,那么最好进行测量。
如果我们出于性能原因希望按引用传递,但又不需要修改参数,我们就应该像 sum() 示例那样采用按 常量引用传递 。这是在普通优质代码中最常见的情况:它既快速又不易出错。
函数参数具有默认值是很常见的,即一个被认为是首选或最常见的值。我们可以通过 默认函数参数 来指定这样的默认值。例如:
void print(int value, int base = 10); // 以"base"进制打印value
print(x, 16); // 十六进制
print(x, 60); // 六十进制(苏美尔计数法)
print(x); // 使用默认值:十进制
这是一种记法上更为简单的重载替代方案:
void print(int value, int base); // 以"base"进制打印value
void print(int value)
{
print(value, 10);// 以十进制打印value
}
使用默认参数意味着函数只有一个定义。这对于理解和代码量通常是有利的。当我们需要为不同类型的相同语义实现不同的代码时,可以使用重载。
3.4.2 值返回
一旦我们计算出了结果,就需要将其从函数中取出并返回给调用者。同样,值返回的默认方式也是复制,对于小型对象来说这是最理想的。只有当我们希望允许调用者访问并非函数局部变量的内容时,才会“按引用返回”。例如,一个 Vector 类可以通过下标操作给予用户访问其元素的权限:
class Vector {
public:
// ...
double& operator[](int i) { return elem[i]; } // 返回对第i个元素的引用
private:
double* elem; // elem 指向一个大小为 sz 的数组
// ...
};
Vector 的第 i 个元素独立于下标运算符的调用而存在,因此我们可以返回对其的引用。
另一方面,局部变量在函数返回时会消失,所以我们不应该返回指向它的指针或引用:
int& bad() {
int x;
// ...
return x; // 错误:返回指向局部变量 x 的引用
}
幸运的是,所有主流的 C++ 编译器都能捕捉到 bad() 中的这个明显错误。
返回引用或“小型”类型的值是高效的,但我们如何从函数中传递大量信息出去呢?如:
Matrix operator+(const Matrix& x, const Matrix& y)
{
Matrix res;
// ... for all res[i,j], res[i,j] = x[i,j]+y[i,j] ...
return res;
}
Matrix m1, m2;
// ...
Matrix m3 = m1+m2;// no copy
一个 Matrix 可能非常大,即使在现代硬件上复制也非常昂贵。因此,我们不进行复制,而是为 Matrix 提供一个移动构造函数(§ 6.2.2),该函数能以极低成本将 Matrix 从 operator+() 中移出。即使我们没有定义移动构造函数,编译器也常常能够优化掉复制操作(消除复制),并在真正需要的地方直接构造 Matrix 。这被称为 复制省略 (copy elision)。
我们不应退回到手动内存管理的方式:
Matrix* add(const Matrix& x, const Matrix& y) {// 复杂且易出错的 20 世纪风格
Matrix* p = new Matrix;
// ... 对于所有的 *p[i][j],执行 *p[i][j] = x[i][j] + y[i][j] ...
return p;
}
Matrix m1, m2;
// ...
Matrix* m3 = add(m1, m2);// 只复制了一个指针
// ...
delete m3; // 易被遗忘
不幸的是,在旧代码中,通过返回指向大型对象的指针来返回大型对象的做法很常见,也是难以发现错误的主要来源之一。请避免编写这样的代码。 Matrix 的 operator+() 至少与 Matrix 的 add() 函数一样高效,但定义起来更容易,使用更方便,且出错几率更低。
如果函数无法完成其必要的任务,它可以抛出异常(§ 4.2)。这有助于避免代码中充斥着针对“异常问题”的错误代码检查。
3.4.3 返回类型推导
函数的返回类型可以从其返回值中推断出来。例如:
auto mul(int i, double d) { return i * d; } // 在这里,"auto"意味着“推断返回类型”
这在某些情况下非常方便,特别是对于泛型函数(§7.3.1)和lambda表达式(§7.3.3),但应谨慎使用,因为推断的类型不提供一个稳定的接口:对函数(或lambda)实现的任何改动都可能改变其类型。
3.4.4 后置返回类型
为什么函数的返回类型要放在函数名和参数之前呢?原因主要是历史遗留。Fortran、C语言以及Simula(现在依然如此)都是这样规定的。然而,有时候我们需要查看参数来确定结果的类型。返回类型推导就是其中一个例子,但并非唯一情况。在本书讨论范围之外的一些例子中,这个问题会与命名空间(§3.3)、lambda表达式(§7.3.3)和概念(§8.2)的使用相关联。因此,我们允许在参数列表之后添加返回类型,以便明确指定返回类型。这样一来, auto 就表示“返回类型将会后续提及或被推导”。例如:
auto mul(int i, double d) -> double { return i * d; } // 返回类型是 "double"
与变量的定义(§1.4.2)类似,我们可以利用这种语法来更整齐地对齐名称。将这种后置返回类型的用法与(§1.3)中的版本进行比较:
auto next_elem() // Elem*;
auto exit(int) // void;
auto sqrt(double) // double;
我个人觉得这种后置返回类型的表示法比传统的前置表示法更为逻辑清晰,但由于绝大多数代码都采用传统表示法,本书中我也沿用了这一惯例。
3.4.5 结构化绑定
函数只能返回单一的值,但这个值可以是一个包含多个成员的类对象。这让我们能够优雅地返回多个值。例如:
struct Entry {
string name;
int value;
};
Entry read_entry(istream& is) {// 简单的读取函数(更好的版本见§11.5)
string s;
int i;
is >> s >> i;
return {s, i};
}
auto e = read_entry(cin);
cout << "{ " << e.name << " , " << e.value << " }\n";
在这里, {s,i} 用于构造 Entry 的返回值。同样地,我们可以“解包”一个 Entry 的成员到局部变量中:
auto [n, v] = read_entry(is);
cout << "{ " << n << " , " << v << " }\n";
这里的 auto [n,v] 声明了两个局部变量 n 和 v ,它们的类型从 read_entry() 的返回类型中推导得出。这种将类对象的成员赋予局部名称的机制称为 结构化绑定 。
请看另一个例子:
map<string, int> m;
// ... 填充m ...
for (const auto [key, value] : m)
cout << "{" << key << "," << value << "}\n";
和往常一样,我们可以在 auto 前加上 const 和 & 。例如:
void incr(map<string, int>& m) {
for (auto& [key, value] : m)
++value;
// 递增m中每个元素的值
}
当结构化绑定用于没有私有数据的类时,很容易理解绑定是如何完成的:必须为绑定定义与类对象中数据成员数量相同的名字,绑定中引入的每个名字对应命名类成员中的相应成员。与显式使用复合对象相比,生成的机器代码质量上不会有差异。特别是,使用结构化绑定并不意味着复制 结构体 。此外,简单 结构体 的返回很少涉及复制,因为简单的返回类型可以直接在所需位置构造(§3.4.2)。结构化绑定的使用完全关乎如何最好地表达一个想法。
处理通过成员函数访问的类也是可能的。例如:
complex<double> z = {1, 2};
auto [re, im] = z + 2;
// re=3; im=2
复数有两个数据成员,但其接口由访问函数组成,如 real() 和 imag() 。将 complex<double> 映射到两个局部变量(如 re 和 im )是可行且高效的,但实现这一操作的技术超出了本书的范围。
3.5 建议
- 区分声明(用作接口)和定义(用作实现);§3.1。
- 在支持 模块 的情况下,优先使用 模块 而非头文件;§3.2.2。
- 使用头文件来表示接口并强调逻辑结构;§3.2;[CG: SF.3]。
- 在实现其函数的源文件中 #include 头文件;§3.2;[CG: SF.5]。
- 避免在头文件中使用非内联函数定义;§3.2;[CG: SF.2]。
- 使用命名空间来表达逻辑结构;§3.3;[CG: SF.20]。 为过渡、
- 基础库(如 std )或局部作用域使用 using 指令;§3.3;[CG: SF.6] [CG: SF.7]。
- 不要在头文件中放置 using 指令;§3.3;[CG: SF.7]。
- 传递“小”值时使用值传递,传递“大”值时使用引用传递;§3.4.1;[CG: F.16]。
- 优先使用 const 引用传递而非普通引用传递;§3.4.1;[CG: F.17]。
- 通过函数返回值返回值(而不是通过输出参数);§3.4.2;[CG: F.20] [CG: F.21]。
- 不要过度使用返回类型推导;§3.4.2。
- 不要过度使用结构化绑定;命名的返回类型通常使代码更易读;§3.4.5。