错误处理是一个大而复杂的主题,其内容和涉及面都远远超越了语言设施层面,而深入到了程序设计技术和工具的范畴。不过C++还是提供了一些对此有帮助的特性,其中最主要的一个工具就是类型系统。我们不应基于内置类型(如char、int和double)和语句(如if、while和for)来费力地构造应用程序,而是应构造适合我们应用的类型(如string、map和regex)和算法(如sort()、find_if()和draw_all())。这些高级构造简化了程序设计,减少了产生错误的可能(例如,你不太可能对一个对话框应用数遍历算法),同时也增加了编译器捕捉错误的机会。大多数C++构造都致力于设计并实现优雅且高效的抽象(如用户自定义类型和使用这些自定义类型的算法)。这种抽象机制的一个效果就是运行时错误的捕获位置与错误处理的位置被分离开来。随着程序规模不断增大,特别是库的广泛使用,处理错误的标准变得愈加重要。在程序开发中,尽早地明确错误处理策略是一个好办法。
让我们重新考虑Vector的例子。对2.3节中的向量,当我们试图访问某个越界的元素时,应该发生什么呢?
假设越界访问是一种错误,我们希望能从中恢复,合理的解决方案是由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将程序的控制权从某个直接或间接调用Vector::operator[ ] ( )的函数转移到out_of_range异常处理代码。为此,C++实现需能展开(unwind)函数调用栈以便返回调用者的上下文。换句话说,异常处理机制会退出一系列作用域和函数以便回到对处理这种异常表达出兴趣的某个调用者,一路上会按需要调用析构函数。例如:
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语句。我们将在4.2.2节中介绍令错误处理简单且系统的主要技术(称为资源请求即初始化(Resource Aquisition 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()立即终止当前程序的执行。
使用异常报告越界访问错误是一个典型的函数检查其实参的例子,因为基本假设,即所谓的前置条件(precondition)没有满足,函数拒绝执行。如果我们正式说明Vector的下标运算符,我们将定义类似于“索引必须在[0:size())范围内”的规则,而这正是在operator[ ] ( )中要检查的。符号[a:b)指定了一个半开区间,表示a是区间的一部分,而b不是。每当定义一个函数时,就应考虑它的前置条件是什么以及如何检验它(参见3.5.3节)。对大多数应用来说,检验简单的不变式是一个好主意,参见3.5.4节。
但是,operator[ ] ( )对Vector类型的对象进行操作,而且只在Vector的成员有“合理”的值时才有意义。特别是,我们说过“elem指向一个含有sz个double的数组”,但这只是注释中的说明而已。对于类来说,这样一条关于假设某事为真的声明称为类不变式(class invariant),简称为不变式(invariant)。建立类的不变式是构造函数的任务(从而成员函数可以依赖该不变式),成员函数的责任是确保当它们退出时不变式仍然成立。不幸的是,我们的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_error& err){ //处理内存耗尽 } }
你可以定义自己的异常类,并令它们将任意信息从异常检测点传递到异常处理点(参见3.5.1节)。
通常,当抛出异常后,函数就无法继续完成分配给它的任务了。于是,“处理”异常的含义是做一些简单的局部清理然后重新抛出异常。例如:
void test() { try{ Vector v(-27); } catch(std::length_error&){ //做一些处理并重新抛出异常 cerr<<“test failed: length error\n”; throw; //重新抛出 } catch(std::bad_alloc&){ //哎哟!这个程序根本就没设计如何处理内存耗尽 std::terminate(); //终止程序 } }
设计良好的代码中很少见到try块,你可以通过系统地使用RAII技术(参见4.2.2节、5.3节)来避免过度使用try块。
不变式的概念是设计类的核心,而前置条件在函数设计中也起到类似的作用。不变式
不变式的概念是C++中由构造函数和析构函数支撑的资源管理概念的基础。
错误处理在现实世界的所有软件中都是一个主要问题,因此很自然地有很多解决方法。如果错误被检测出来后无法在函数内局部处理,函数就必须以某种方法与某个调用者沟通这个问题。抛出异常是C++解决此问题的最一般的方法。
在有的语言中,提供异常机制的目的是为返回值提供一种替代机制。但C++不是这样的语言:异常是用来报告错误、完成给定任务的。异常与构造函数和析构函数一起为错误处理和资源管理提供一个一致的框架(参见4.2.2节、5.3节)。当前的编译器都针对返回值进行了优化,使其比抛出一个相同的值作为异常高效很多。
对于错误不能局部处理的问题,抛出异常不是报告错误的唯一方法。函数可用如下方式指出它无法完成分配给它的任务:
在下列情况下,我们返回一个错误指示符(一个“错误码”):
在下列情况下我们抛出异常:
在如下情况下,我们终止程序:
确保程序终止的一种方法是向函数添加noexcept(),从而在函数实现的任何地方抛出异常都会进入terminate()。注意,有的应用不能接受无条件终止,这就需要使用替代方法。
不幸的话,上述条件并不总是逻辑上互斥的,也不总是容易应用,程序的规模和复杂程序都会对此有影响。有时,随着应用的进化,各种因素间的权衡会发生改变,这时就需要程序员的经验了。如果存疑,你应该优先选择异常机制,因为其伸缩性更好,也不需要外部工具来检查是否所有的错误都被处理了。
不要认为所有的错误码或所有的异常都是糟糕的,它们都有清晰的用途。而且,不要相信异常处理很缓慢的传言,它通常比正确处理复杂的或罕见的错误条件以及重复检验错误码要更快。
对于使用异常实现简单、高效的错误处理,RAII(参见4.2.2节、5.3节)是很必要的。充斥着try块代码通常反映了基于错误码构思的错误处理策略最糟糕的那一面。
我们经常需要为不变式、前置条件等编写可选的运行时检验,目前对此还没有通用的、标准的方法。为此,已为C++20提出了一种合约机制[Garcia, 2016] [Garcia 2018]。一些用户想依赖检验来保证程序的正确性——在调试时进行全面的运行时检验,而随后部署的代码包含尽量少的检验,合约的目标是为此提供支持。一些组织依赖系统、全面的检验,在其高性能应用中这一需求就很常见。
到目前为止,我们还不得不依赖特别的机制。例如,我们可以使用命令行宏来控制运行时检验:
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)不成立,即当前系统中一个int占据的空间不足4字节,则输出integers are too small信息。将这种表达我们的期望的机制称为断言(assertion)。
static_assert机制能用于任何可以表示为常量表达式(参见1.6节)的东西。例如:
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”); //错误:速度必须是一个常量 static_assert(local_max<C, “can’t go that fast”); //正确 //... }
一般而言,static_assert(A, S)的作用是当A不为true时,将S作为一条编译器错误信息输出。如果你不希望打印特定信息,可以忽略S,编译器会提供一条默认消息:
static_assert(4<=sizeof(int)); //使用默认消息
默认消息通常是static_assert所在位置加上表示断言谓词的字符。
static_assert最重要的用途是在泛型编程中为类型参数设置断言(参见7.2节、13.9节)。