C++之旅(学习笔记)第3章 模块化
3.1 分离编译
C++支持一种名为分离编译的概念,用户代码只能看见所用类型和函数的声明。
有两种方法可以实现它:
- 头文件:将声明放进一个名为头文件的独立文件,然后将头文件以文本方式#include到代码中你需要声明的地方。
- 模块:定义module文件,独立地编译它们,然后在需要时import它们。在import对应module时,只有其中显示export的声明是可见的。
优点:
- 可以尽可能地减少编译时间,并且强制要求程序中逻辑独立地部分分离开来(从而尽可能降低发生错误地概率)。
模块技术是在C++20中出现地新特性,其提供了实质性地优势,对改善代码组织与编译耗时都有好处。
一个单独编译的.cpp文件(包含它#include的.h文件)被称作一个翻译单元。
使用#include及头文件实现模块化是一种传统方法,它具有明显地缺点:
- **编译时间:**如果你在101个翻译单元中#include header.h,这个header.h的头文件将被编译器处理101次。
- **依赖顺序:**如果你在header2.h之前#include header1.h,在header1.h中的定义与宏可能会影响header2.h中代码的含义,反之亦然。
- **不协调:**如果你在一个文件中定义一个实体,比如类型或者函数,然后在另一个文件中定义一个稍微不同的版本,则可能导致崩溃或者难以觉察的错误。
- **传染性:**所有表达头文件中某一个声明所需的代码,都必须出现在头文件中。这会导致代码膨胀,因为头文件为了完成声明需要#include其他头文件,这会导致头文件的用户需要(有意或者无意地)依赖头文件包含地实现细节。
3.2 模块
C++20中出现了语言级的方式来直接实现模块化。
export module Vector; //定义一个module,名为Vector
export class Vector {
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem; //elem指向一个数组,该数组包含sz个double类型的元素
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;
}
上诉代码定义了一个module,名为Vector,这个模块导出了Vector类及所有成员函数,还有成员函数的操作符==。
要使用上述module,只要在需要用到它的地方import就可以了。
例如:
//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;
}
#inclue <cmath>
也可以改为import cmath;
这里仅仅是为了演示新旧方式的混合。
3.3 模块与头文件两种方法的区别
头文件与模块的区别不仅仅是在语法上:
- 模块只被编译一次,不会在每个用到它的翻译单元那里都被重新编译。
- 两个模块import的顺序不影响其含义。
- 如果你在模块内部import或者#include其他内容,模块的使用者不会隐式地获得那些模块地访问权:这意味着import没有传染性。
- 模块在维护性与编译时间方面地改进非常显著。
例如:作者测试过使用import std;的“Hello,world!"程序,它的编译速度比使用#include 的版本**快10倍。**这还是在std模块因包含了整个标准库足足有10倍大小的前提下实现的。
提升的原理:
- 模块只导出接口,但头文件需要传递所有直接的或者间接的信息给编译器。
**不幸的是,module std还没有进入C++20。**附录A介绍了如何获得一份module std的方法,这里不详细展开。
示例:
当定义一个模块时,不需要将实现与声明分开写成两个文件,如果想改进你的源代码,可以这么做:
export module Vector; //定义module,名叫Vector
export class Vector {
//...
};
export bool operator==(const Vector& v1, const Vector& v2){
//...
}
编译器负责把(用export指定的)模块的接口从实现细节中分离出来,因此,Vector接口由编译器生成,不需要由用户指定。
使用module时,不需要为了在接口文件内隐藏实现细节而将代码变得复杂;因为模块只导出显示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 << '}';
}
3.4 命名空间
- C++还提供了一种名为命名空间的机制,一方面表达某些声明是属于一个整体的,另一方面表面它们的名字不会与其他命名空间中的名字冲突。
- 要想访问其他命名空间中的某个名字,最简单的方法是在这个名字前加上命名空间的名字作为限定(例如,std::cout和My_code::main)。
- ”真正的main()“定义在全局命名空间中,换句话说,它不属于任何自定义的命名空间、类或者函数。
- 如果觉得反复使用命名空间限定显得冗长及干扰了可读性,可以使用using声明将命名空间中的名字放进当前作用域。
void my_code(vector<int>& x,vector<int>& y) {
using std::swap; //将标准库的swap放进本地作用域
//...
swap(x,y); //std::swap()
other::swap(x,y);//某个其他的swap()
}
3.5 函数参数与返回值
默认情况使用复制(传值),如果希望直接指向调用者环境中的对象,我们使用引用(传引用)的方式。
从性能方面考虑,我们通常对小数据传值、对大数据传引用。这里的小意味着”复制开销很低“。通常而言,”尺寸在两到三个指针以内“是一个不错的标准。
3.5.1 结构化绑定
一个函数只能返回一个值,但这个值可以是拥有很多成员的类对象。这往往是函数体面地返回多个值地方法。
例如:
struct Entry {
string name;
int value;
};
Entry read_entry(istream& is)//简单地读函数
{
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()返回类型的推导。
这种把类对象成员的名称赋予局部变量名称的机制叫作结构化绑定。
3.6 建议
区分声明(用作接口)和定义(用作实现);
优先选择module而非头文件(在支持module的地方);
使用头文件描述接口、强调逻辑结构;
在头文件中应避免定义非内联函数;
不要在头文件中使用using指令;
采用传值方式传递“小”值,采用传引用方式传递“大”值;
优先选择传const引用方式而非传普通引用方式;
不要过度使用返回类型推断;
不要过度使用结构化绑定;使用命名的返回类型通常可以使代码更为清晰。