3 模块化

3.1 引言

一个C++程序可能由许多独立开发的部分组成,例如函数、用户自定义类型、类层次和模板。管理如此众多的组成部分的关键是清楚地定义它们之间的交互。第一步也是最重要的一步是将每个部分的接口和实现分离开来。在语法层面,C++通过声明来表示接口。声明指定了一个函数或一个类型所需要的所有东西。例如:

这个求平方根的函数接受一个double类型的参数,并返回一个double类型的值。

这个类的构造函数需要提供一个整型参数表示元素的数量,其下标操作符函数根据从参数传入的元素下标返回对应元素的引用,而size函数则返回元素的数量。

这里的关键点是函数体,即函数的定义代码位于“其它某处”。sqrt函数的定义如下所示:

对于Vector,需要定义全部四个成员函数:

一个实体,例如函数、类型等,可以有很多声明,但只能有一个定义。

3.2 分离编译

C++支持一种名为分离编译的概念,用户代码只能看见所用函数、类型的声明。有两种方法实现它:

上述两种方法均可用于将一个程序划分成一组半独立的代码片段。这样做的好处是可以尽可能地缩短编译时间,并强制性地将程序中独立的逻辑单元分离开,进而降低发生错误的概率。库通常是由一组被分离编译的代码片段组成的集合。

逻辑单元2
逻辑单元1
包含/导入
编译
链接
包含/导入
编译
链接
头文件/模块
源文件
目标文件
头文件/模块
源文件
目标文件
可执行程序/库

使用头文件组织代码的方式早在C语言的时代就已经存在,并且远比其它方式更常见。模块是C++20的新增特性,相比于头文件具有实质性的优势,对改善代码的组织,缩短编译时间益处颇多。

3.2.1 头文件

传统的做法是将表示接口描述的函数和类的声明放到头文件中,该文件的文件名指示了接口的预期用途。例如:

使用Vector类的代码,需要通过“#include”预处理指令,包含“Vector.h”头文件。例如:

而调用readAndSum函数的代码,则需要通过“#include”预处理指令,包含“UseVector.h”头文件。例如:

为了帮助编译器确保一致性,类和函数的实现文件同样应该包含其接口的声明文件。例如:

Vector.cpp、UseVector.cpp和Vector3.cpp将被分别编译成独立的目标文件,最后链接成一个可执行程序文件。这些文件间的依赖及生成关系,以Visual C++ 2022为例,如下图所示:

入口函数
使用Vector类的函数
Vector类
包含
包含
包含
包含
编译
链接
编译
链接
编译
链接
Vector3.cpp
定义main函数
UseVector.h
声明使用Vector类的函数
UseVector.cpp
定义使用Vector类的函数
Vector.h
声明Vector类
Vector.cpp
实现Vector类
Vector.obj
UseVector.obj
Vector3.obj
Vector3.exe

程序组织的最佳实践是将其作为一系列定义了良好依赖的模块的集合。头文件展现了每个模块的接口,源文件则给出了每个接口的具体实现,最后通过分离编译,充分发挥了模块化的优势。

一个可被单独编译的“.cpp”文件,连同它所包含的“.h”文件,共同组成了一个编译单元。一个程序可以由成千上万个编译单元组成。

基于头文件的模块化是一种相对传统的做法,它具有明显的缺点:

显然,这并非理想的解决方案,而且这项技术自上世纪70年代开始,已经造成了巨大的开销,并成为许多已知缺陷的根源。然而,头文件毕竟已经使用了数十年,带有“#include”的代码还会继续存活相当长的时间,更新大型系统的成本,通常是难以估量的。

3.2.2 模块

C++20为程序的模块化提供了语言级别的支持。例如:

上述代码定义了一个名为Vector的模块,该模块导出了Vector类及其所有的成员函数。扩展名为“.ixx”的文本文件称为模块接口单元,模块接口单元会被编译为目标文件,其中的模块可在源文件或其它模块接口单元中被导入。

上述代码定义了一个名为UseVector的模块,该模块导出了readAndSum函数。为了使用Vector类,该模块还导入了先前定义的Vector模块。

为了调用readAndSum函数,这里导入了先前定义的UseVector模块。

Vector.ixx、UseVector.ixx和Vector4.cpp将被分别编译成独立的目标文件,最后链接成一个可执行程序文件。这些文件间的依赖及生成关系,以Visual C++ 2022为例,如下图所示:

入口函数
UseVector模块
Vector模块
编译
链接
导入Vector模块
编译
链接
导入UseVector模块
编译
链接
Vector4.cpp
定义main函数
UseVector.ixx
定义并导出使用Vector类的函数
Vector.ixx
定义并导出Vector类
Vector.ixx.obj
UseVector.ixx.obj
Vector4.obj
Vector4.exe

包含头文件和导入模块可以混合使用,因为从使用“#include”的旧代码过渡到使用“import”的新代码,需要一个循序渐进的过程。

头文件和模块的区别并非只在语法上:

模块在可维护性和编译时间方面的改进非常显著。经测试,使用“import std;”的“Hello World”程序,比使用“#incluce <iostream>”的版本,编译速度快了将近十倍!事实上,std模块涵盖了整个C++标准库,而<iostream>头文件则仅只包含C++标准库中的I/O流部分。模块能够显著提升编译速度的根本原因是,它只包含接口信息,而不象头文件那样将所有直接或间接信息统统传递给编译器。程序员完全可以放心地导入大规模的模块,而不必象遴选头文件那样,时刻小心翼翼,非必要不包含。

当定义一个模块时,不需要将声明和实现分成两个文件(.h和.cpp),而是写在一个文件中,即模块接口单元(.ixx)。类似下面这样:

编译器负责将模块的接口,即通过export关键字导出的函数、类型等,从实现中抽离出来。接口由编译器自动生成,无需手动编写接口描述代码。在模块接口单元中,只有被export关键字显式标记为导出的部分,对用户可见,其它实现细节对用户是隐藏的。

C++20:模块

3.3 命名空间

除了函数、类(结构)和枚举,C++还提供了一种称为命名空间的机制,一方面表示某些声明是属于一个整体的,另一方面保证其中的名字不会与其它命名空间中的相同名字发生冲突。例如:

把代码放在mycode命名空间中,就可以确保其中的名字不会和std命名空间中的标准库名字发生冲突。标准库中也有一个表示复数的类complex和对其求平方根的函数sqrt。为防止名字冲突,采取必要的预防措施,显然是非常明智的。

为了访问命名空间中的某个名字,最简单的方法就是在这个名字前面加上命名空间的名字作为限定,例如“std::cout”或者“mycode::main”。真正的main函数定义在全局命名空间中,它不属于任何自定义的命名空间、类或者函数。

如果觉得在一段代码中,反复使用命名空间限定,显得冗长且干扰了可读性,可以借助using声明,将特定命名空间中的名字引入当前作用域。例如:

using声明将特定命名空间中名字复制到当前作用域,就象该名字是在当前作用域中声明的一样。在“using std::swap;”之后,std命名空间中的swap即被视为在foo函数局部作用域中声明的名字,可以直接引用。

若想获得std命名空间中所有名字的访问权,可以使用using指令。例如:

using指令使特定命名空间中的所有名字在当前作用域中可见。在“using namespace std;”之后,所有“std::”命名空间限定均可省略不写。需要强调的是,在模块中使用using指令,所影响的仅限于该模块内部,对模块的使用者没有任何影响。例如:

使用using指令,会丧失对特定命名空间中名字的选择权,因此需要谨慎使用。通常当特定命名空间,如std,在应用中被普遍使用,或移植一个没有使用命名空间限定的历史代码时,使用using指令。

命名空间主要用于组织比函数或类型更大规模的程序组件,比如库。借助命名空间,可以很容易地将若干独立开发的部件组织成一个程序。

程序
命名空间一
函数
类型
命名空间二
函数
类型
函数
类型

3.4 函数参数与返回值

函数调用是程序部件之间传递信息的主要方法,也是建议使用的方法。用于执行特定任务的信息以参数的形式被传递给函数,任务执行的结果则以返回值的形式从函数返回给调用者。例如:

函数之间传递信息还有其它途径,比如使用全局变量或在类对象中共享状态。强烈不建议使用全局变量,已知的很多问题皆源于此,而状态共享只适用于在具备良好抽象且协同开发的函数间传递信息,比如同一个类的成员函数。

选择在函数间传递参数的方法,主要依据如下考量:

向函数传递参数和从函数中返回值的默认行为是复制。但在很多情况下,对象复制可以被编译器隐式优化为转移。

在上面sum函数的示例中,int型的返回值从sum函数中复制出来,但vector型的参数或许会很大,没有必要复制一份,因此该参数采用引用方式传入。同时,sum函数没有修改其参数的理由,因此将其声明为const。最终,vector型的参数以const引用的方式传递。

3.4.1 参数传递

首先要考虑的是,函数获取值的方法。默认情况是使用复制,即传值。如果希望直接获取调用者环境中的对象本身,而非其副本,可以选择传引用的方式。例如:

出于性能的考虑,通常对小数据传值,而对大数据传引用。这里的“小”和“大”意味着对象复制的开销低还是高。它的准确定义取决于具体的机器架构。一般而言,把字节数不超过两到三个指针的数据,视为小数据,反之则为大数据。但如果参数传递方式对性能的影响非常显著,最好还是根据实际测量的结果而定。

如果决定以引用方式传参,而又不需要在函数内部修改参数,那么就可以选择const引用,就象前面的sum函数一样,以避免对参数的意外修改。在普通代码中,这其实是最常见的信息输入方式,高效且不易出错。

函数的参数可以拥有默认值。如果某个参数经常取特定的值,那么就可以将该值指定为此参数的默认值。例如:

借助函数重载也能达到同样的效果,但使用默认参数会更简单。例如:

使用默认参数意味着函数只有一份定义。通常而言,这对于理解代码及缩减代码规模更有帮助。当需要用不同代码实现针对不同类型的相同语义时,使用函数重载是最佳选择。

3.4.2 返回值

当函数计算出结果后,需要将其传递到函数之外,即返回给函数的调用者。与参数传递的情况类似,返回值的默认行为也是复制。对小数据而言,这时理想的处理方式。返回引用的情况只应当出现在所返回的内容不属于函数局部作用域的场合。例如Vector对象可以返回一个针对元素的引用:

Vector中的第i个元素可以独立于下标操作而存在,因此返回对它的引用是安全的。

另一方面,函数的局部变量在其返回后即消失,因此不应返回针对局部变量的引用或指针。例如:

幸运的是,目前几乎所有主流C++编译器,都会对上述情形给出警告或报错。

对小数据,返回值或返回引用都是高效的,但如何将大数据传递到函数之外呢?例如:

一个Matrix对象可能会非常大,即使是在现代硬件上,复制开销可能也会大到惊人的地步。解决方案是为Matrix类定义一个转移构造函数,以非常小的代价将Matrix对象转移到operator+函数之外。即使不为Matrix类定义转移构造函数,编译器也有能力将这次复制优化掉,使c成为z的别名,并将z的生命周期延长到c的作用域内。这叫省略复制优化。

某些历史代码曾试图通过手动管理内存解决这个问题。此方法在今天看来已不可取。例如:

不幸的是,在旧代码中,使用指针返回大数据的做法非常常见,这正是部分问题难以排查的主要原因之一。

3.4.3 返回类型推导

C++编译器有能力根据函数的返回值,自动推导出函数的返回类型。例如:

对于函数和匿名函数而言,这会非常方便,但当返回类型推导无法提供稳定的接口时,应当慎用,函数实现的改变有可能导致其返回类型发生变化。例如:

函数调用者的代码可能不得不因此做出调整,以适应该返回类型的变化。

C++14:函数的返回类型推导

3.4.4 返回类型后置

为什么函数的返回类型必须放在函数名的前面?这其实是历史原因造成的,在C++语言诞生之前,Fortran、C和Simula等语言都是这么做的,而且至今依然如此。但有时先看到参数类型,再看到返回类型可能会更为合理,这包括但不限于返回类型推导这种情形。这个问题与命名空间、匿名函数、概念等都有一定联系。因此,C++允许将函数的返回类型放到其参数表之后。例如:

这种写法还有一个好处,就是便于将函数名对齐,使代码显得更加工整。例如:

显然比

看上去更整洁。

一般而言,函数返回类型的后置写法比前置写法更符合逻辑,毕竟函数的返回值反映的是任务执行的结果,而非前提。但是,截至目前的绝大多数C++代码,仍然采用的是相对传统的前置写法,因此至少在现阶段,除非有特殊需求,返回类型前置仍然是主流写法。

C++11:返回类型后置语法

3.4.5 结构化绑定

一个函数只能有一个返回值,但这个值可以是一个拥有多个成员的类对象。这往往是让函数体面地返回多个值的方法之一。例如:

这里的“{ key, value }”被用于构造Entry类型的返回值。

等价的另一种做法是将一个Entry类型的对象解包为单个的变量:

这里的“auto [k, v]”声明了两个变量k和v,它们类型来自对readEntry返回类型的推导。这种把类对象的成员,赋予一个有名变量集合的机制,就叫作结构化绑定。另一个例子:

结构化绑定也支持写操作。例如:

对完全没有私有数据的类对象使用结构化绑定时,绑定行为是明显的——集合中的有名变量与对象中的成员变量,个数相同,按顺序一一对应绑定。

对象
集合
int x;
int y;
int z;
a
b
c

结构化绑定并不意味着需要复制对象的全部内容,编译优化的基本策略是按需构造,无需构造的自然也无需复制。结构化绑定的优点在于,更清晰地表达代码编写者的想法和意图。

结构化绑定也可以用于需要通过成员函数访问成员变量的类对象。例如:

complex的两个成员变量分表代表一个复数的实部和虚部,它们需要调用real和imag成员函数才能获得。将一个complex对象,即“c + 3”表达式的值,绑定到由r和i组成的集合上是有效的。

C++17:结构化绑定

3.5 建议