你好,我是吴咏炜。
到现在为止,我们已经有好多次都提到异常了。今天,我们就来彻底地聊一聊异常。
首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。
下面我们就开始说说异常。
我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。
假设我们要做一些矩阵的操作,定义了下面这个矩阵的数据结构:
typedef struct {
float* data;
size_t nrows;
size_t ncols;
} matrix;
我们至少需要有初始化和清理的代码:
enum matrix_err_code {
MATRIX_SUCCESS,
MATRIX_ERR_MEMORY_INSUFFICIENT,
…
};
int matrix_alloc(matrix* ptr,
size_t nrows,
size_t ncols)
{
size_t size =
nrows * ncols * sizeof(float);
float* data = malloc(size);
if (data == NULL) {
return MATRIX_ERR_MEMORY_INSUFFICIENT;
}
ptr->data = data;
ptr->nrows = nrows;
ptr->ncols = ncols;
}
void matrix_dealloc(matrix* ptr)
{
if (ptr->data == NULL) {
return;
}
free(ptr->data);
ptr->data = NULL;
ptr->nrows = 0;
ptr->ncols = 0;
}
然后,我们做一下矩阵乘法吧。函数定义大概会是这个样子:
int matrix_multiply(matrix* result,
const matrix* lhs,
const matrix* rhs)
{
int errcode;
if (lhs->ncols != rhs->nrows) {
return MATRIX_ERR_MISMATCHED_MATRIX_SIZE;
// 呃,得把这个错误码添到 enum matrix_err_code 里
}
errcode = matrix_alloc(
result, lhs->nrows, rhs->ncols);
if (errcode != MATRIX_SUCCESS) {
return errcode;
}
// 进行矩阵乘法运算
return MATRIX_SUCCESS;
}
调用代码则大概是这个样子:
matrix c;
// 不清零的话,错误处理和资源清理会更复杂
memset(&c, 0, sizeof(matrix));
errcode = matrix_multiply(c, a, b);
if (errcode != MATRIX_SUCCESS) {
goto error_exit;
}
// 使用乘法的结果做其他处理
error_exit:
matrix_dealloc(&c);
return errcode;
可以看到,我们有大量需要判断错误的代码,零散分布在代码各处。
可这是 C 啊。我们用 C++、不用异常可以吗?
当然可以,但你会发现结果好不了多少。毕竟,C++ 的构造函数是不能返回错误码的,所以你根本不能用构造函数来做可能出错的事情。你不得不定义一个只能清零的构造函数,再使用一个 init
函数来做真正的构造操作。C++ 虽然支持运算符重载,可你也不能使用,因为你没法返回一个新矩阵……
我上面还只展示了单层的函数调用。事实上,如果出错位置离处理错误的位置相差很远的话,每一层的函数调用里都得有判断错误码的代码,这就既对写代码的人提出了严格要求,也对读代码的人造成了视觉上的干扰……
如果使用异常的话,我们就可以在构造函数里做真正的初始化工作了。假设我们的矩阵类有下列的数据成员:
class matrix {
…
private:
float* data_;
size_t nrows_;
size_t ncols_;
}
构造函数我们可以这样写:
matrix::matrix(size_t nrows,
size_t ncols)
{
data_ = new float[nrows * ncols];
nrows_ = nrows;
ncols_ = ncols;
}
析构非常简单:
matrix::~matrix()
{
delete[] data_;
}
乘法函数可以这样写:
class matrix {
…
friend matrix
operator*(const matrix&,
const matrix&);
};
matrix operator*(const matrix& lhs,
const matrix& rhs)
{
if (lhs.ncols != rhs.nrows) {
throw std::runtime_error(
"matrix sizes mismatch");
}
matrix result(lhs.nrows, rhs.ncols);
// 进行矩阵乘法运算
return result;
}
使用乘法的代码则更是简单:
matrix c = a * b;
你可能已经非常疑惑了:错误处理在哪儿呢?只有一个 throw
,跟前面的 C 代码能等价吗?
异常处理并不意味着需要写显式的 try
和 catch
。异常安全的代码,可以没有任何 try
和 catch
。
如果你不确定什么是“异常安全”,我们先来温习一下概念:异常安全是指当异常发生时,既不会发生资源泄漏,系统也不会处于一个不一致的状态。
我们看看可能会出现错误/异常的地方:
首先是内存分配。如果 new
出错,按照 C++ 的规则,一般会得到异常 bad_alloc
,对象的构造也就失败了。这种情况下,在 catch
捕捉到这个异常之前,所有的栈上对象会全部被析构,资源全部被自动清理。
如果是矩阵的长宽不合适不能做乘法呢?我们同样会得到一个异常,这样,在使用乘法的地方,对象 c
根本不会被构造出来。
如果在乘法函数里内存分配失败呢?一样,result
对象根本没有构造出来,也就没有 c
对象了。还是一切正常。
如果 a
、b
是本地变量,然后乘法失败了呢?析构函数会自动释放其空间,我们同样不会有任何资源泄漏。
总而言之,只要我们适当地组织好代码、利用好 RAII,实现矩阵的代码和使用矩阵的代码都可以更短、更清晰。我们可以统一在外层某个地方处理异常——通常会记日志、或在界面上向用户报告错误了。
但大名鼎鼎的 Google 的 C++ 风格指南不是说要避免异常吗 [1]?这又是怎么回事呢?
答案实际已经在 Google 的文档里了:
Given that Google’s existing code is not exception-tolerant, the costs of using exceptions are somewhat greater than the costs in a new project. The conversion process would be slow and error-prone. We don’t believe that the available alternatives to exceptions, such as error codes and assertions, introduce a significant burden.
Our advice against using exceptions is not predicated on philosophical or moral grounds, but practical ones. Because we’d like to use our open-source projects at Google and it’s difficult to do so if those projects use exceptions, we need to advise against exceptions in Google open-source projects as well. Things would probably be different if we had to do it all over again from scratch.
我来翻译一下(我的加重):
鉴于 Google 的现有代码不能承受异常,使用异常的代价要比在全新的项目中使用异常大一些。转换[代码来使用异常的]过程会缓慢而容易出错。我们不认为可代替异常的方法,如错误码或断言,会带来明显的负担。
我们反对异常的建议并非出于哲学或道德的立场,而是出于实际考虑。因为我们希望在 Google 使用我们的开源项目,而如果这些项目使用异常的话就会对我们的使用带来困难,我们也需要反对在 Google 的开源项目中使用异常。如果我们从头再来一次的话,事情可能就会不一样了。
这个如果还比较官方、委婉的话,Reddit 上还能找到一个更个人化的表述 [2]:
I use [sic] to work at Google, and Craig Silverstein, who wrote the first draft of the style guideline, said that he regretted the ban on exceptions, but he had no choice; when he wrote it, it wasn’t only that the compiler they had at the time did a very bad job on exceptions, but that they already had a huge volume of non-exception-safe code.
我的翻译(同样,我的加重):
我过去在 Google 工作,写了风格指南初稿的 Craig Silverstein 说过他对禁用异常感到遗憾,但他当时别无选择。在他写风格指南的时候,不仅他们使用的编译器在异常上工作得很糟糕,而且他们已经有了一大堆异常不安全的代码了。
当然,除了历史原因以外,也有出于性能等其他原因禁用异常的。美国国防部的联合攻击战斗机(JSF)项目的 C++ 编码规范就禁用异常,因为工具链不能保证抛出异常时的实时性能。不过在那种项目里,被禁用的 C++ 特性就多了,比如动态内存分配都不能使用。
一些游戏项目为了追求高性能,也禁用异常。这个实际上也有一定的历史原因,因为今天的主流 C++ 编译器,在异常关闭和开启时应该已经能够产生性能差不多的代码(在异常未抛出时)。代价是产生的二进制文件大小的增加,因为异常产生的位置决定了需要如何做栈展开,这些数据需要存储在表里。典型情况,使用异常和不使用异常比,二进制文件大小会有约百分之十到二十的上升。LLVM 项目的编码规范里就明确指出这是不使用 RTTI 和异常的原因 [3]:
In an effort to reduce code and executable size, LLVM does not use RTTI (e.g.
dynamic_cast<>;
) or exceptions.
我默默地瞅了眼我机器上 88MB 大小的单个 clang-9 可执行文件,对 Chris Lattner 的决定至少表示理解。但如果想跟这种项目比,你得想想是否值得这么去做。你的项目对二进制文件的大小和性能有这么渴求吗?需要这么去拼吗?
异常当然不是一个完美的特性,否则也不会招来这些批评和禁用了。对它的批评主要有两条:
对于第一条,开发者没有什么可做的。事实上,这也算是 C++ 实现的一个折中了。目前的主流异常实现中,都倾向于牺牲可执行文件大小、提高主流程(happy path)的性能。只要程序不抛异常,C++ 代码的性能比起完全不做错误检查的代码,都只有几个百分点的性能损失 [4]。除了非常有限的一些场景,可执行文件大小通常不会是个问题。
第二条可以算作是一个真正有效的批评。和 Java 不同,C++ 里不会对异常规约进行编译时的检查。从 C++17 开始,C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常——noexcept
、noexcept(true)
或 throw()
。这也是 C++ 的运行时唯一会检查的东西了。如果一个函数声明了不会抛出异常、结果却抛出了异常,C++ 运行时会调用 std::terminate
来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。
当然,不声明异常是有理由的。特别是在泛型编程的代码里,几乎不可能预知会发生些什么异常。我个人对避免异常带来的问题有几点建议:
noexcept
。注意类的特殊成员(构造函数、析构函数、赋值函数等)会自动成为 noexcept
,如果它们调用的代码都是 noexcept
的话。所以,像 swap
这样的成员函数应当尽可能标成 noexcept
。虽然后面我们会描述到一些不使用异常、也不使用错误返回码的错误处理方式,但异常是渗透在 C++ 中的标准错误处理方式。标准库的错误处理方式就是异常。其中不仅包括运行时错误,甚至包括一些逻辑错误。比如,在说容器的时候,有一个我没提的地方是,在能使用 []
运算符的地方,C++ 的标准容器也提供了 at
成员函数,能够在下标不存在的时候抛出异常,作为一种额外的帮助调试的手段。
#include <iostream> // std::cout/endl
#include <stdexcept> // std::out_of_range
#include <vector> // std::vector
using namespace std;
vector<int> v{1, 2, 3};
v[0]
1
v.at(0)
1
v[3]
-1342175236
try {
v.at(3);
}
catch (const out_of_range& e) {
cerr << e.what() << endl;
}
_M_range_check: __n (which is 3) >= this->size() (which is 3)
C++ 的标准容器在大部分情况下提供了强异常保证,即,一旦异常发生,现场会恢复到调用函数之前的状态,容器的内容不会发生改变,也没有任何资源泄漏。前面提到过,vector
会在元素类型没有提供保证不抛异常的移动构造函数的情况下,在移动元素时会使用拷贝构造函数。这是因为一旦某个操作发生了异常,被移动的元素已经被破坏,处于只能析构的状态,异常安全性就不能得到保证了。
只要你使用了标准容器,不管你自己用不用异常,你都得处理标准容器可能引发的异常——至少有 bad_alloc
,除非你明确知道你的目标运行环境不会产生这个异常。这对普通配置的 Linux 环境而言,倒确实是对的……这也算是 Google 这么规定的一个底气吧。
虽然对于运行时错误,开发者并没有什么选择余地;但对于代码中的逻辑错误,开发者则是可以选择不同的处理方式的:你可以使用异常,也可以使用 assert
,在调试环境中报告错误并中断程序运行。由于测试通常不能覆盖所有的代码和分支,assert
在发布模式下一般被禁用,两者并不是完全的替代关系。在允许异常的情况下,使用异常可以获得在调试和发布模式下都良好、一致的效果。
标准 C++ 可能会产生哪些异常,可以查看参考资料 [6]。
今天我们讨论了使用异常的理由和不使用异常的理由。希望通过本讲,你能够充分理解为什么异常是 C++ 委员会和很多大拿推荐的错误处理方式,并在可以使用异常的地方正确地使用异常这一方便的错误处理机制。
如果你还想进一步深入了解异常的话,可以仔细阅读一下参考资料 [4]。
你的 C++ 项目里使用异常吗?为什么?
欢迎留言和我交流你的看法。
[1] Google, “Google C++ style guide”. https://google.github.io/styleguide/cppguide.html#Exceptions
[2] Reddit, Discussion on “Examples of C++ projects which embrace exceptions?”. https://www.reddit.com/r/cpp/comments/4wkkge/examples_of_c_projects_which_embrace_exceptions/
[3] LLVM Project, “LLVM coding standards”. https://llvm.org/docs/CodingStandards.html#do-not-use-rtti-or-exceptions
[4] Standard C++ Foundation, “FAQ—exceptions and error handling”. https://isocpp.org/wiki/faq/exceptions
[5] cppreference.com, “Exceptions”. https://en.cppreference.com/w/cpp/language/exceptions
[5a] cppreference.com, “异常”. https://zh.cppreference.com/w/cpp/language/exceptions
[6] cppreference.com, “std::exception”. https://en.cppreference.com/w/cpp/error/exception
[6a] cppreference.com, “std::exception”. https://zh.cppreference.com/w/cpp/error/exception