一个C++程序可能由许多独立开发的部分组成,例如函数、用户自定义类型、类层次和模板。管理如此众多的组成部分的关键是清楚地定义它们之间的交互。第一步也是最重要的一步是将每个部分的接口和实现分离开来。在语法层面,C++通过声明来表示接口。声明指定了一个函数或一个类型所需要的所有东西。例如:
xxxxxxxxxx
11double sqrt(double);
这个求平方根的函数接受一个double类型的参数,并返回一个double类型的值。
xxxxxxxxxx
101class Vector {
2public:
3 Vector(int size);
4 double& operator[](int i);
5 const double& operator[](int i) const;
6 int size() const;
7
8private:
9 ...
10};
这个类的构造函数需要提供一个整型参数表示元素的数量,其下标操作符函数根据从参数传入的元素下标返回对应元素的引用,而size函数则返回元素的数量。
这里的关键点是函数体,即函数的定义代码位于“其它某处”。sqrt函数的定义如下所示:
xxxxxxxxxx
31double sqrt(double d) {
2 ... 依据数学教科书中的算法实现 ...
3}
对于Vector,需要定义全部四个成员函数:
xxxxxxxxxx
131Vector::Vector(int size) : m_elem{ new double[size] }, m_size{ size } {}
2
3double& Vector::operator[](int i) {
4 return m_elem[i];
5}
6
7const double& Vector::operator[](int i) const {
8 return const_cast<Vector&>(*this)[i];
9}
10
11int Vector::size() const {
12 return m_size;
13}
一个实体,例如函数、类型等,可以有很多声明,但只能有一个定义。
C++支持一种名为分离编译的概念,用户代码只能看见所用函数、类型的声明。有两种方法实现它:
头文件:将有关函数、类型的声明放到一个独立的文件中,即头文件,在需要使用这些声明的源代码文件的开始部分,通过“#include”预处理指令,包含该头文件
模块:将有关函数、类型的声明,独立地编译为一个module文件,在需要使用这些声明的源代码文件的开始部分,通过“import”指令,导入该module文件,其中只有被显式export的声明是可见的
上述两种方法均可用于将一个程序划分成一组半独立的代码片段。这样做的好处是可以尽可能地缩短编译时间,并强制性地将程序中独立的逻辑单元分离开,进而降低发生错误的概率。库通常是由一组被分离编译的代码片段组成的集合。
使用头文件组织代码的方式早在C语言的时代就已经存在,并且远比其它方式更常见。模块是C++20的新增特性,相比于头文件具有实质性的优势,对改善代码的组织,缩短编译时间益处颇多。
传统的做法是将表示接口描述的函数和类的声明放到头文件中,该文件的文件名指示了接口的预期用途。例如:
xxxxxxxxxx
221// Vector.h
2
3
4
5class Vector {
6public:
7 // 初始化
8 Vector(int size);
9
10 // 通过下标访问特定的元素
11 double& operator[](int i);
12
13 // 通过下标访问特定的元素
14 const double& operator[](int i) const;
15
16 // 获取元素的数量
17 int size() const;
18
19private:
20 double* m_elem; // 指向元素序列的指针
21 int m_size; // 元素的数量
22};
xxxxxxxxxx
61// UseVector.h
2
3
4
5// 从cin读入size个数据,返回它们的和
6double readAndSum(int size);
使用Vector类的代码,需要通过“#include”预处理指令,包含“Vector.h”头文件。例如:
xxxxxxxxxx
231// UseVector.cpp
2
3import std;
4
5using namespace std;
6
7
8
9
10// 从cin读入size个数据,返回它们的和
11
12double readAndSum(int size) {
13 Vector vec(size); // 创建一个可容纳size个元素的Vector对象
14
15 for (int i = 0; i < vec.size(); ++i)
16 cin >> vec[i]; // 读取数据
17
18 double sum = 0;
19 for (int i = 0; i < vec.size(); ++i)
20 sum += vec[i]; // 累加求和
21
22 return sum; // 返回结果
23}
而调用readAndSum函数的代码,则需要通过“#include”预处理指令,包含“UseVector.h”头文件。例如:
xxxxxxxxxx
111// Vector3.cpp
2
3import std;
4
5using namespace std;
6
7
8
9int main() {
10 cout << readAndSum(5) << endl;
11}
为了帮助编译器确保一致性,类和函数的实现文件同样应该包含其接口的声明文件。例如:
xxxxxxxxxx
251// Vector.cpp
2
3
4
5// 初始化
6
7Vector::Vector(int size) : m_elem{ new double[size] }, m_size{ size } {}
8
9// 通过下标访问特定的元素
10
11double& Vector::operator[](int i) {
12 return m_elem[i];
13}
14
15// 通过下标访问特定的元素
16
17const double& Vector::operator[](int i) const {
18 return const_cast<Vector&>(*this)[i];
19}
20
21// 获取元素的数量
22
23int Vector::size() const {
24 return m_size;
25}
Vector.cpp、UseVector.cpp和Vector3.cpp将被分别编译成独立的目标文件,最后链接成一个可执行程序文件。这些文件间的依赖及生成关系,以Visual C++ 2022为例,如下图所示:
程序组织的最佳实践是将其作为一系列定义了良好依赖的模块的集合。头文件展现了每个模块的接口,源文件则给出了每个接口的具体实现,最后通过分离编译,充分发挥了模块化的优势。
一个可被单独编译的“.cpp”文件,连同它所包含的“.h”文件,共同组成了一个编译单元。一个程序可以由成千上万个编译单元组成。
基于头文件的模块化是一种相对传统的做法,它具有明显的缺点:
编译时间:如果在100个编译单元中都包含了某个头文件,该头文件中的代码将被编译器处理100次
依赖顺序:如果在包含“b.h”前先包含了“a.h”,则“a.h”中的某些内容会影响对“b.h”的编译,反之亦然
协调一致:头文件中的声明与源文件中的实现不一致,导致编译失败、崩溃或其它不易察觉的错误
包含传染:一个头文件可以再包含其它头文件,头文件的使用者将隐式获得对这些头文件的访问权
显然,这并非理想的解决方案,而且这项技术自上世纪70年代开始,已经造成了巨大的开销,并成为许多已知缺陷的根源。然而,头文件毕竟已经使用了数十年,带有“#include”的代码还会继续存活相当长的时间,更新大型系统的成本,通常是难以估量的。
C++20为程序的模块化提供了语言级别的支持。例如:
xxxxxxxxxx
281// Vector.ixx
2
3export module Vector;
4
5export class Vector {
6public:
7 // 初始化
8 Vector(int size) : m_elem{ new double[size] }, m_size{ size } {}
9
10 // 通过下标访问特定的元素
11 double& operator[](int i) {
12 return m_elem[i];
13 }
14
15 // 通过下标访问特定的元素
16 const double& operator[](int i) const {
17 return const_cast<Vector&>(*this)[i];
18 }
19
20 // 获取元素的数量
21 int size() const {
22 return m_size;
23 }
24
25private:
26 double* m_elem; // 指向元素序列的指针
27 int m_size; // 元素的数量
28};
上述代码定义了一个名为Vector的模块,该模块导出了Vector类及其所有的成员函数。扩展名为“.ixx”的文本文件称为模块接口单元,模块接口单元会被编译为目标文件,其中的模块可在源文件或其它模块接口单元中被导入。
xxxxxxxxxx
241// UseVector.ixx
2
3export module UseVector;
4
5import std;
6
7using namespace std;
8
9import Vector;
10
11// 从cin读入size个数据,返回它们的和
12
13export double readAndSum(int size) {
14 Vector vec(size); // 创建一个可容纳size个元素的Vector对象
15
16 for (int i = 0; i < vec.size(); ++i)
17 cin >> vec[i]; // 读取数据
18
19 double sum = 0;
20 for (int i = 0; i < vec.size(); ++i)
21 sum += vec[i]; // 累加求和
22
23 return sum; // 返回结果
24}
上述代码定义了一个名为UseVector的模块,该模块导出了readAndSum函数。为了使用Vector类,该模块还导入了先前定义的Vector模块。
xxxxxxxxxx
111// Vector4.cpp
2
3import std;
4
5using namespace std;
6
7import UseVector;
8
9int main() {
10 cout << readAndSum(5) << endl;
11}
为了调用readAndSum函数,这里导入了先前定义的UseVector模块。
Vector.ixx、UseVector.ixx和Vector4.cpp将被分别编译成独立的目标文件,最后链接成一个可执行程序文件。这些文件间的依赖及生成关系,以Visual C++ 2022为例,如下图所示:
包含头文件和导入模块可以混合使用,因为从使用“#include”的旧代码过渡到使用“import”的新代码,需要一个循序渐进的过程。
头文件和模块的区别并非只在语法上:
模块只被编译一次,不会在每个导入它的编译单元中都被重新编译一遍
不同模块被导入的先后顺序,不影响编译结果
在模块内部导入其它模块或包含头文件,该模块的使用者不会隐式获得对这些模块和头文件的访问权,不传染
模块在可维护性和编译时间方面的改进非常显著。经测试,使用“import std;”的“Hello World”程序,比使用“#incluce <iostream>”的版本,编译速度快了将近十倍!事实上,std模块涵盖了整个C++标准库,而<iostream>头文件则仅只包含C++标准库中的I/O流部分。模块能够显著提升编译速度的根本原因是,它只包含接口信息,而不象头文件那样将所有直接或间接信息统统传递给编译器。程序员完全可以放心地导入大规模的模块,而不必象遴选头文件那样,时刻小心翼翼,非必要不包含。
当定义一个模块时,不需要将声明和实现分成两个文件(.h和.cpp),而是写在一个文件中,即模块接口单元(.ixx)。类似下面这样:
xxxxxxxxxx
111export module 模块名;
2
3export class 需要导出的类名 {
4 ...
5};
6
7export ... 需要导出的函数名(...) {
8 ...
9}
10
11...
编译器负责将模块的接口,即通过export关键字导出的函数、类型等,从实现中抽离出来。接口由编译器自动生成,无需手动编写接口描述代码。在模块接口单元中,只有被export关键字显式标记为导出的部分,对用户可见,其它实现细节对用户是隐藏的。
C++20:模块
除了函数、类(结构)和枚举,C++还提供了一种称为命名空间的机制,一方面表示某些声明是属于一个整体的,另一方面保证其中的名字不会与其它命名空间中的相同名字发生冲突。例如:
xxxxxxxxxx
241namespace mycode {
2 class complex {
3 ...
4 };
5
6 complex sqrt(complex) {
7 ...
8 }
9
10 ...
11
12 int main();
13}
14
15int mycode::main() {
16 complex x{ 1, 2 };
17 auto y = sqrt(x);
18 std::cout << '{' << y.real() << ',' << y.imag() << '}' << std::endl;
19 ...
20}
21
22int main() {
23 return mycode::main();
24}
把代码放在mycode命名空间中,就可以确保其中的名字不会和std命名空间中的标准库名字发生冲突。标准库中也有一个表示复数的类complex和对其求平方根的函数sqrt。为防止名字冲突,采取必要的预防措施,显然是非常明智的。
为了访问命名空间中的某个名字,最简单的方法就是在这个名字前面加上命名空间的名字作为限定,例如“std::cout”或者“mycode::main”。真正的main函数定义在全局命名空间中,它不属于任何自定义的命名空间、类或者函数。
如果觉得在一段代码中,反复使用命名空间限定,显得冗长且干扰了可读性,可以借助using声明,将特定命名空间中的名字引入当前作用域。例如:
xxxxxxxxxx
71void foo(std::vector<int>& x, std::vector<int>& y) {
2 using std::swap; // 将std命名空间中的swap引入当前作用域
3 ...
4 swap(x, y); // 调用std命名空间中的swap函数
5 other.swap(x, y); // 调用其它某个命名空间中的swap函数
6 ...
7}
using声明将特定命名空间中名字复制到当前作用域,就象该名字是在当前作用域中声明的一样。在“using std::swap;”之后,std命名空间中的swap即被视为在foo函数局部作用域中声明的名字,可以直接引用。
若想获得std命名空间中所有名字的访问权,可以使用using指令。例如:
xxxxxxxxxx
81using namespace std; // std命名空间中的所有名字在当前作用域中可见
2
3void foo(vector<int>& x, vector<int>& y) { // std命名空间中的vector
4 ...
5 swap(x, y); // 调用std命名空间中的swap函数
6 other.swap(x, y); // 调用其它某个命名空间中的swap函数
7 ...
8}
using指令使特定命名空间中的所有名字在当前作用域中可见。在“using namespace std;”之后,所有“std::”命名空间限定均可省略不写。需要强调的是,在模块中使用using指令,所影响的仅限于该模块内部,对模块的使用者没有任何影响。例如:
xxxxxxxxxx
121export module mymodule;
2
3import std;
4
5using namespace std; // std命名空间中的所有名字在当前作用域中可见
6
7export void foo(vector<int>& x, vector<int>& y) { // std命名空间中的vector
8 ...
9 swap(x, y); // 调用std命名空间中的swap函数
10 other.swap(x, y); // 调用其它某个命名空间中的swap函数
11 ...
12}
xxxxxxxxxx
91import mymodule;
2import std; // 不能因为mymodule模块内部导入了std模块而省略对std模块的导入
3
4int main() {
5 ...
6 // 不能因为mymodule模块内部使用了using指令而省略命名空间限定
7 std::cout << "Hello World" << std::endl;
8 ...
9}
使用using指令,会丧失对特定命名空间中名字的选择权,因此需要谨慎使用。通常当特定命名空间,如std,在应用中被普遍使用,或移植一个没有使用命名空间限定的历史代码时,使用using指令。
命名空间主要用于组织比函数或类型更大规模的程序组件,比如库。借助命名空间,可以很容易地将若干独立开发的部件组织成一个程序。
函数调用是程序部件之间传递信息的主要方法,也是建议使用的方法。用于执行特定任务的信息以参数的形式被传递给函数,任务执行的结果则以返回值的形式从函数返回给调用者。例如:
xxxxxxxxxx
61int sum(const vector<int>& v) {
2 int s = 0;
3 for (const int n : v)
4 s += n;
5 return s;
6}
xxxxxxxxxx
21vector fib{ 1, 1, 2, 3, 5, 8, 13, 21 };
2int s = sum(fib); // 54
函数之间传递信息还有其它途径,比如使用全局变量或在类对象中共享状态。强烈不建议使用全局变量,已知的很多问题皆源于此,而状态共享只适用于在具备良好抽象且协同开发的函数间传递信息,比如同一个类的成员函数。
选择在函数间传递参数的方法,主要依据如下考量:
对象是被复制还是被共享?
共享的对象是否可被修改?
对象是否被移动,即留下一个空对象?
向函数传递参数和从函数中返回值的默认行为是复制。但在很多情况下,对象复制可以被编译器隐式优化为转移。
在上面sum函数的示例中,int型的返回值从sum函数中复制出来,但vector型的参数或许会很大,没有必要复制一份,因此该参数采用引用方式传入。同时,sum函数没有修改其参数的理由,因此将其声明为const。最终,vector型的参数以const引用的方式传递。
首先要考虑的是,函数获取值的方法。默认情况是使用复制,即传值。如果希望直接获取调用者环境中的对象本身,而非其副本,可以选择传引用的方式。例如:
xxxxxxxxxx
41void test(vector<int> v, vector<int>& rv) { // v是传值,rv是传引用
2 v[1] = 99; // 修改局部对象v,对调用者环境中的对象没有影响
3 rv[2] = 66; // 修改rv引用的目标,即调用者环境中的对象
4}
xxxxxxxxxx
31vector fib{ 1, 1, 2, 3, 5, 8, 13, 21 };
2test(fib, fib);
3cout << fib[1] << ' ' << fib[2] << endl; // 1 66
出于性能的考虑,通常对小数据传值,而对大数据传引用。这里的“小”和“大”意味着对象复制的开销低还是高。它的准确定义取决于具体的机器架构。一般而言,把字节数不超过两到三个指针的数据,视为小数据,反之则为大数据。但如果参数传递方式对性能的影响非常显著,最好还是根据实际测量的结果而定。
如果决定以引用方式传参,而又不需要在函数内部修改参数,那么就可以选择const引用,就象前面的sum函数一样,以避免对参数的意外修改。在普通代码中,这其实是最常见的信息输入方式,高效且不易出错。
函数的参数可以拥有默认值。如果某个参数经常取特定的值,那么就可以将该值指定为此参数的默认值。例如:
xxxxxxxxxx
11void print(int value, int base = 10); // 以base进制打印value,默认取10进制
xxxxxxxxxx
31print(x, 16); // 以16进制打印x
2print(x, 60); // 以60进制打印x
3print(x); // 以10进制打印x
借助函数重载也能达到同样的效果,但使用默认参数会更简单。例如:
xxxxxxxxxx
41void print(int value, int base); // 以base进制打印value
2void print(int value) { // 以10进制打印value
3 print(value, 10);
4}
使用默认参数意味着函数只有一份定义。通常而言,这对于理解代码及缩减代码规模更有帮助。当需要用不同代码实现针对不同类型的相同语义时,使用函数重载是最佳选择。
当函数计算出结果后,需要将其传递到函数之外,即返回给函数的调用者。与参数传递的情况类似,返回值的默认行为也是复制。对小数据而言,这时理想的处理方式。返回引用的情况只应当出现在所返回的内容不属于函数局部作用域的场合。例如Vector对象可以返回一个针对元素的引用:
xxxxxxxxxx
131class Vector {
2public:
3 ...
4 double& operator[](int i) {
5 return m_elem[i]; // 返回数组中特定元素的引用
6 }
7 ...
8
9private:
10 ...
11 double* m_elem; // 指向一个double型的数组
12 ...
13};
Vector中的第i个元素可以独立于下标操作而存在,因此返回对它的引用是安全的。
另一方面,函数的局部变量在其返回后即消失,因此不应返回针对局部变量的引用或指针。例如:
xxxxxxxxxx
51int& bad() {
2 int x;
3 ...
4 return x; // 返回局部变量的引用是危险的
5}
幸运的是,目前几乎所有主流C++编译器,都会对上述情形给出警告或报错。
对小数据,返回值或返回引用都是高效的,但如何将大数据传递到函数之外呢?例如:
xxxxxxxxxx
51Matrix operator+(const Matrix& x, const Matrix& y) {
2 Matrix z;
3 ... 计算矩阵x和y的和,放到矩阵z中 ...
4 return z;
5}
xxxxxxxxxx
31Matrix a, b;
2...
3Matrix c = a + b; // 不发生复制
一个Matrix对象可能会非常大,即使是在现代硬件上,复制开销可能也会大到惊人的地步。解决方案是为Matrix类定义一个转移构造函数,以非常小的代价将Matrix对象转移到operator+函数之外。即使不为Matrix类定义转移构造函数,编译器也有能力将这次复制优化掉,使c成为z的别名,并将z的生命周期延长到c的作用域内。这叫省略复制优化。
某些历史代码曾试图通过手动管理内存解决这个问题。此方法在今天看来已不可取。例如:
xxxxxxxxxx
51Matrix* add(const Matrix& x, const Matrix& y) {
2 Matrix* z = new Matrix(...);
3 ... 计算矩阵x和y的和,放到矩阵z中 ...
4 return z;
5}
xxxxxxxxxx
51Matrix a, b;
2...
3Matrix* c = add(a, b); // 只复制指针
4...
5delete c; // 很容易忘记
不幸的是,在旧代码中,使用指针返回大数据的做法非常常见,这正是部分问题难以排查的主要原因之一。
C++编译器有能力根据函数的返回值,自动推导出函数的返回类型。例如:
xxxxxxxxxx
31auto mul(int n, double d) { // 此处auto的意思是让编译器根据函数的返回值,自动推导出函数的返回类型
2 return n * d; // 函数的返回类型应为double
3}
对于函数和匿名函数而言,这会非常方便,但当返回类型推导无法提供稳定的接口时,应当慎用,函数实现的改变有可能导致其返回类型发生变化。例如:
xxxxxxxxxx
31auto mul(int n, double d) {
2 return n * (int)d; // 函数的返回类型变成int
3}
函数调用者的代码可能不得不因此做出调整,以适应该返回类型的变化。
C++14:函数的返回类型推导
为什么函数的返回类型必须放在函数名的前面?这其实是历史原因造成的,在C++语言诞生之前,Fortran、C和Simula等语言都是这么做的,而且至今依然如此。但有时先看到参数类型,再看到返回类型可能会更为合理,这包括但不限于返回类型推导这种情形。这个问题与命名空间、匿名函数、概念等都有一定联系。因此,C++允许将函数的返回类型放到其参数表之后。例如:
xxxxxxxxxx
31auto mul(int n, double d) -> double { // 此处auto的意思是返回类型在后面,或由编译器自动推导
2 return n * d;
3}
这种写法还有一个好处,就是便于将函数名对齐,使代码显得更加工整。例如:
xxxxxxxxxx
31auto next() -> Elem*;
2auto exit(int) -> void;
3auto sqrt(double) -> double;
显然比
xxxxxxxxxx
31Elem* next();
2void exit(int);
3double sqrt(double);
看上去更整洁。
一般而言,函数返回类型的后置写法比前置写法更符合逻辑,毕竟函数的返回值反映的是任务执行的结果,而非前提。但是,截至目前的绝大多数C++代码,仍然采用的是相对传统的前置写法,因此至少在现阶段,除非有特殊需求,返回类型前置仍然是主流写法。
C++11:返回类型后置语法
一个函数只能有一个返回值,但这个值可以是一个拥有多个成员的类对象。这往往是让函数体面地返回多个值的方法之一。例如:
xxxxxxxxxx
41struct Entry {
2 string key;
3 int value;
4};
xxxxxxxxxx
61Entry readEntry(istream* is) {
2 string key;
3 int value;
4 is >> key >> value;
5 return { key, value };
6}
这里的“{ key, value }”被用于构造Entry类型的返回值。
xxxxxxxxxx
21auto entry = readEntry(cin);
2cout << '{' << entry.key << ',' << entry.value << '}' << endl;
等价的另一种做法是将一个Entry类型的对象解包为单个的变量:
xxxxxxxxxx
21auto [k, v] = readEntry(cin);
2cout << '{' << k << ',' << v << '}' << endl;
这里的“auto [k, v]”声明了两个变量k和v,它们类型来自对readEntry返回类型的推导。这种把类对象的成员,赋予一个有名变量集合的机制,就叫作结构化绑定。另一个例子:
xxxxxxxxxx
41map<string, int> m;
2...
3for (const auto [k, v] : m)
4 cout << '{' << k << ',' << v << '}' << endl;
结构化绑定也支持写操作。例如:
xxxxxxxxxx
41map<string, int> m;
2...
3for (auto& [k, v] : m)
4 ++v;
对完全没有私有数据的类对象使用结构化绑定时,绑定行为是明显的——集合中的有名变量与对象中的成员变量,个数相同,按顺序一一对应绑定。
结构化绑定并不意味着需要复制对象的全部内容,编译优化的基本策略是按需构造,无需构造的自然也无需复制。结构化绑定的优点在于,更清晰地表达代码编写者的想法和意图。
结构化绑定也可以用于需要通过成员函数访问成员变量的类对象。例如:
xxxxxxxxxx
31complex<double> c{ 1, 2 };
2auto [r, i] = c + 3;
3cout << '{' << r << ',' << i << '}' << endl; // {4,2}
complex的两个成员变量分表代表一个复数的实部和虚部,它们需要调用real和imag成员函数才能获得。将一个complex对象,即“c + 3”表达式的值,绑定到由r和i组成的集合上是有效的。
C++17:结构化绑定
区分作为接口的声明和作为实现的定义
在支持模块的地方,优先选择模块而非头文件
使用头文件描述接口、强调逻辑结构
使用“#include”预处理指令,将用于声明的头文件,包含到实现该声明的源文件中
在头文件中应尽量避免定义非内联函数
用命名空间划分逻辑结构
对象标准库这样的基础库中的命名空间,使用using指令
不要在头文件中使用using指令
给函数传参,小数据传值,大数据传引用
以引用的方式为函数传参,优先选择const引用,除非必须使用普通引用
函数向外传递信息,优先选择返回值而非输出参数
不要过分使用返回类型推导
不要过度使用结构化绑定,显式写出返回值的类型使代码更容易理解