❶
优秀的程序组织方法是: 把程序当作一组明确定义过依赖关系的模块,利用语言特性表达这种模块化的逻辑关系,再把这种关系通过文件以实体形式暴露出来,以实现高效的分离编译。
除了函数、类以及枚举,C++还提供一个叫命名空间(namespace)的机制, 用来表示某些声明相互依托,以便这些名称不会跟其它名称发生冲突。 比如说,我想弄个复数类型:
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() {
return My_code::main();
}
void my_code(vector<int>& x, vector<int>& y) {
using std::swap; // 使用标准库中的 swap
// ...
swap(x,y); // std::swap()
other::swap(x,y); // 别的 swap()
// ...
}
❷
假设我们想从越界访问的错误中恢复运行,解决方案是:实现Vector的人检测越界访问的企图,然后把它告知用户。然后用户就可以采取适当的措施。举例来说:Vector::operator能检测越界访问的企图,并抛出out_of_range异常:
double& Vector::operator[](int i) {
if (i<0 || size()<=i)
throw out_of_range{"Vector::operator[]"};
return elem[i];
}
throw
把控制权转给处理out_of_range
异常的代码,这个代码位于某些函数里,而这些函数直接或间接地调用了Vector::operator[]。 要做到这些,编译器就得展开函数调用堆栈,以便回退到调用者的代码环境。就是说,异常处理机制将根据需要退出作用域和函数,以便回退到有意处理该类异常的调用者,必要时沿途调用析构函数。例如:
void f(Vector& v) {
// ...
try { // 此处的异常将被下面定义的代码处理
v[v.size()] = 7;// 试图访问v到末尾之后
} catch (out_of_range& err) { // 坏菜了:out_of_range错误
// ... 处理越界错误 ...
cerr << err.what() << '\n';
}
// ...
}
我们把有意异常处理的代码放到try
-代码块里。 给v[v.size()]
赋值的企图不会得逞。 因此,会进入catch
-子句,里面包含处理out_of_range
异常的代码。 out_of_range
异常定义在标准库中(<stdexcept>
里), 并且实际上已经被标准库里某些容器的访问函数用到了。
我以引用方式捕捉此异常以避免复制,并使用what()
函数打印错误信息, 这个信息是在throw
-位置放进异常里的。
使用异常处理机制可以让错误处理更简洁、更系统化,也更具可读性。 要确保这一点,就别滥用try
-语句。 以简洁和系统化方式实现错误处理的主要技术(被称为 资源请求即初始化(Resource Acquisition Is Initialization; RAII
))。RAII
的大体思路是:让类的构造函数获取正常运作所需的全部资源,然后让析构函数释放全部资源,这样资源的释放就可以有保障地隐式执行。
如果一个函数绝对不应该抛出异常,可以用noexcept
声明它。例如:
void user(int sz) noexcept {
Vector v(sz);
iota(&v[0],&v[sz],1); // 用1,2,3,4...填充v
// ...
}
万一user()还是抛出了异常,就会立即调用std::terminate()
以终止程序。
既然operator运算符要操作Vector类型的对象,那么,如果Vector的成员不具备“合理的”值,这个运算就毫无意义。确切的说,我们指出了“elem指向承载sz个元素的数组”,但仅仅止步于注释中。这种为类声称某个假设为真的语句被称为 类的不变式
,简称不变式
。 为类制定不变式(以确保成员函数有的放矢)的职责归构造函数,而成员函数运行完成之后,要确保不变式依然成立。不巧的是,我们Vector的构造函数有点虎头蛇尾了。它出色地为Vector的成员变量完成了初始化,却没留意传入的参数是否合理。考虑一下这个:
Vector v(-27);
基本上,这就要出事了。
更靠谱的定义是这样的:
Vector::Vector(int s) {
if (s<0)
throw length_error{"Vector constructor: negative size"};
elem = new double[s];
sz = s;
}
我使用标准库里的length_error
异常报告“元素数量不是正整数”的问题, 因为标准库也用这个异常报告这类问题。 如果new运算符没找到可分配的内存,将抛出std::bad_alloc
。 我们可以这么写:
void test() {
try {
Vector v(-27);
} catch (std::length_error& err) {
// 处理容量为负数的情况
} catch (std::bad_alloc& err) {
// 处理内存耗尽的问题
}
}
一般来说,函数在捕获异常之后就已经没法搞定待处理的任务了。 然后,异常“处理”就意味着低限度的局部资源清理,然后重新抛出该异常。例如:
void test() {
try {
Vector v(-27);
} catch (std::length_error&) {// 处理一下,然后重新抛出
cerr << "test failed: length error\n";
throw;// 重新抛出
} catch (std::bad_alloc&) {// 糟!这程序没法处理内存耗尽的问题
std::terminate();// 终止程序
}
}
目前只能依赖权宜之计,例如用命令行里的宏控制运行时检查:
double& Vector::operator[](int i) {
if (RANGE_CHECK && (i<0 || size()<=i))
throw out_of_range{"Vector::operator[]"};
return elem[i];
}
标准库提供了调试用的宏assert()
,以确保某个条件在运行时成立。例如:
void f(const char* p) {
assert(p!=nullptr); // p绝不能是nullptr
// ...
}
如果这个assert()条件在“调试模式”不成立,程序将终止。 如果不在调试模式,assert()就不做检查。 这个方法忒糙还不灵活,不过通常也凑合够用了。
❸
异常给运行时发现的问题报错。如果能在编译时发现错误就该大力推广。 对于绝大多数类型系统、区分用户定义类型的接口那些语言特性而言,这就是意义所在。 最起码,我们可以对编译期已知的大多数属性进行基本检查, 以编译器错误信息的形式汇报不满足需求的情况。例如:
static_assert(4<=sizeof(int), "integers are too small");// 检查整数容量
在不满足4<=sizeof(int)时,这段代码输出integers are too small; 就是说在系统里的int不足4个字节时。 我们把这种陈述预期的语句称为断言(assertion)。
static_assert
机制可用于任意——可以通过常量表达式表示的——情形。例如:
constexpr double C = 299792.458; // km/s
void f(double speed) {
constexpr double local_max = 160.0/(60*60); // 160 km/h == 160.0/(60*60) km/s
static_assert(speed<C,"can't go that fast"); // 错误:speed必须是常量
static_assert(local_max<C,"can't go that fast"); // OK
// ...
}
如果A不为true,那么static_assert(A,S)就会把S作为编译器错误信息输出。 如果不想输出特定信息就把S留空,编译器会采用默认信息:
static_assert(4<=sizeof(int)); // 采用默认信息
默认信息的内容通常是static_assert所在的源码位置,外加一个表示断言谓词的字母。
静态断言static_assert最重要的用途体现在泛型编程中对用作参数的类型有特定要求时。
❹
把信息从程序的一个位置向另一个位置传递,最主要且推荐的方法是通过函数调用。 执行功能所需的信息作为参数传入函数,生成的结果以返回值形式传出。例如:
int sum(const vector<int>& v) {
int s = 0;
for (const int i : v)
s += i;
return s;
}
vector fib = {1,2,3,5,8,13,21};
int x = sum(fib); // x变成53
既然函数信息的传入和传出如此重要,就不难想见它们有多种方式。主要涉及:
- 该对象是被复制还是被共享?
- 如果该对象被共享,是否可变?
- 如果对象转移了,是否要留下一个“空对象”?
在sum()例子中,作为结果的int是以复制方式传出sum()的,但对于可能容量巨大的vector,让它以复制方式进入sum()将会低效且毫无意义, 所以参数以引用方式传入。sum()无需修改其参数,这种不可变更性通过将vector声明为const来标示,因此vector通过const-引用传递。
完成计算之后,需要把结果弄出函数并交回给调用者。 跟参数一样,值返回也默认采用复制方式,并且这对于较小的对象很完美。 只有在把不属于函数局部作用域的东西转给调用者时,才通过“传引用”返回。例如:
class Vector {
public:
// ...
double& operator[](int i) { return elem[i]; } // 返回对第i个元素的引用
private:
double* elem; // elem指向一个数组,该数组承载sz个double
// ...
};
Vector的第i个元素的存在不依赖于取下标运算符,因此可以返回对它的引用。
另一方面,在函数的返回操作结束后,局部变量就消失了,所以不能返回指向它的指针或引用:
int& bad() {
int x;
// ...
return x; // 糟糕:返回了指向局部变量x的引用
}
返回引用或者较“小”类型的值很高效,但是要把大量信息传出函数该怎么办呢? 考虑这个示例:
Matrix operator+(const Matrix& x, const Matrix& y) {
Matrix res;
// ... 对所有 res[i,j], res[i,j] = x[i,j]+y[i,j] ...
return res;
}
Matrix m1, m2;
// ...
Matrix m3 = m1+m2; // 没有复制
即便对时下的硬件而言,Matrix可能都非常大,而且复制的代价高昂。 因此我们不进行复制,而是为Matrix定义一个转移构造函数(move constructor),从而以低廉的代价把Matrix从operator+()传出。 此处不需要抱残守缺地使用手动内存管理:
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; // 这个操作太容易忘记
很遗憾,通过指针返回大型对象在老式代码里很常见,而且是个难以捕获错误的主要根源。 别写这样的代码。 注意,operator+()跟add()同样高效,但是定义简单、易于使用还不易出错。
函数的返回类型可以从返回值本身推断出来。例如:
auto mul(int i, double d) { return i*d; } // 此处的“auto”意思是“推断返回类型”
这很方便,尤其对于泛型函数(函数模板(function template))以及 lambda表达式来说,但是请谨慎采用,因为推导出来的类型会让接口不稳定:对函数(或lambda表达式)内容的修改,会改变返回类型。
❺
函数只能返回单独的一个值,但这个值可以是包含多个成员的类对象。 这使得我们得以高效地返回多个值。例如:
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()的返回类型推导出来。 这个给类对象成员命名的机制叫结构化绑定(structured binding)。
考虑以下示例:
map<string,int> m;
// ... 填充 m ...
for (const auto [key,value] : m)
cout << "{" << key "," << value << "}\n";
按惯例,可以用const和&限定auto,例如:
void incr(map<string,int>& m) { // 为m的每个元素自增1
for (auto& [key,value] : m)
++value;
}
在结构化绑定用于不包含私有数据的类时,绑定方式显而易见: 绑定行为定义的名称数量必须跟类里面的非静态数据成员数量相同, 绑定行为中的引入名称按次序对应成员变量。 与显式使用复合对象相比,这种代码的质量并无差异; 使用结构化绑定的主旨在于恰如其分地表达意图。
通过成员函数访问类对象的操作同样可行。例如:
complex<double> z = {1,2};
auto [re,im] = z+2;// re=3, im=2
忠告
[1] 注意区分声明(用做接口)和定义(用做实现);
[2] 使用头文件代表接口,以强调逻辑结构;
[3] 在实现函数的源文件里#include它的头文件;
[4] 不要在头文件里定义非内联函数;
[5] (在支持module的地方)以module替代头文件;
[6] 使用命名空间表达逻辑结构;
[7] 为代码迁移、基本类库(比如std),或在局部作用域内使用using-指令;
[8] 别把using-指令放在头文件里;
[9] 在无法搞定待处理的任务时,抛出异常指出这种情况;
[10] 仅在错误处理的情形下使用异常;
[11] 在直接调用者该处理某个错误的时候,采用错误码;
[12] 在错误将要穿过大量函数调用层级的时候,抛出异常;
[13] 拿不定该用异常还是错误码的时候,用异常;
[14] 在设计早期就确定错误处理的策略;
[15] 使用能够反映设计意图的用户定义类型作为异常(而非内置类型);
[16] 不要在每个函数中都捕捉所有异常;
[17] 用 RAII 代替try-代码块;
[18] 如果函数不该抛出异常,用noexcept声明它;
[19] 让构造函数建立不变式,如果做不到就抛出异常;
[20] 围绕不变式设计错误处理策略;
[21] 能在编译期检查的就在编译期检查;
[22] “小”值传值,“大”值传引用;
[23] 尽可能用常(const)引用而非普通引用;
[24] 使用函数返回值进行返回(而不要用传出参数);
[25] 别滥用返回类型推断;
[26] 别滥用结构化绑定;使用具名返回类型可以让文档更清晰。
网友评论