You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

338 lines
16 KiB
Markdown

2 years ago
# 06 | 异常:用还是不用,这是个问题
你好,我是吴咏炜。
到现在为止,我们已经有好多次都提到异常了。今天,我们就来彻底地聊一聊异常。
首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。
下面我们就开始说说异常。
## 没有异常的世界
我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。
假设我们要做一些矩阵的操作,定义了下面这个矩阵的数据结构:
```c
typedef struct {
float* data;
size_t nrows;
size_t ncols;
} matrix;
```
我们至少需要有初始化和清理的代码:
```c
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;
}
```
然后,我们做一下矩阵乘法吧。函数定义大概会是这个样子:
```c
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;
}
```
调用代码则大概是这个样子:
```c
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++ 虽然支持运算符重载,可你也不能使用,因为你没法返回一个新矩阵……
我上面还只展示了单层的函数调用。事实上,如果出错位置离处理错误的位置相差很远的话,每一层的函数调用里都得有判断错误码的代码,这就既对写代码的人提出了严格要求,也对读代码的人造成了视觉上的干扰……
## 使用异常
如果使用异常的话,我们就可以在构造函数里做真正的初始化工作了。假设我们的矩阵类有下列的数据成员:
```c++
class matrix {
private:
float* data_;
size_t nrows_;
size_t ncols_;
};
```
构造函数我们可以这样写:
```c++
matrix::matrix(size_t nrows,
size_t ncols)
{
data_ = new float[nrows * ncols];
nrows_ = nrows;
ncols_ = ncols;
}
```
析构非常简单:
```c++
matrix::~matrix()
{
delete[] data_;
}
```
乘法函数可以这样写:
```c++
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;
}
```
使用乘法的代码则更是简单:
```c++
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 Googles 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 dont 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 wed like to use our open-source projects at Google and its 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 wasnt 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++ 原则。只要开启了异常,即使不使用异常你编译出的二进制代码通常也会膨胀。
* 异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。
对于第一条,开发者没有什么可做的。事实上,这也算是 C++ 实现的一个折中了。目前的主流异常实现中都倾向于牺牲可执行文件大小、提高主流程happy path的性能。只要程序不抛异常C++ 代码的性能比起完全不做错误检查的代码,都只有几个百分点的性能损失 \[4\]。除了非常有限的一些场景,可执行文件大小通常不会是个问题。
第二条可以算作是一个真正有效的批评。和 Java 不同C++ 里不会对异常规约进行编译时的检查。从 C++17 开始C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常——`noexcept`、`noexcept(true)` 或 `throw()`。这也是 C++ 的运行时唯一会检查的东西了。如果一个函数声明了不会抛出异常、结果却抛出了异常C++ 运行时会调用 `std::terminate` 来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。
当然,不声明异常是有理由的。特别是在泛型编程的代码里,几乎不可能预知会发生些什么异常。我个人对避免异常带来的问题有几点建议:
1. 写异常安全的代码,尤其在模板里。可能的话,提供强异常安全保证 \[5\],在任何第三方代码发生异常的情况下,不改变对象的内容,也不产生任何资源泄漏。
2. 如果你的代码可能抛出异常的话,在文档里明确声明可能发生的异常类型和发生条件。确保使用你的代码的人,能在不检查你的实现的情况下,了解需要准备处理哪些异常。
3. 对于肯定不会抛出异常的代码,将其标为 `noexcept`。尤其是,移动构造函数、移动赋值运算符和 `swap` 函数一般需要保证不抛异常并标为 `noexcept`(析构函数通常不抛异常且自动默认为 `noexcept`,不需要标)。
## 使用异常的理由
虽然后面我们会描述到一些不使用异常、也不使用错误返回码的错误处理方式,但异常是渗透在 C++ 中的标准错误处理方式。标准库的错误处理方式就是异常。其中不仅包括运行时错误,甚至包括一些逻辑错误。比如,在说容器的时候,有一个我没提的地方是,在能使用 `[]` 运算符的地方C++ 的标准容器也提供了 `at` 成员函数,能够在下标不存在的时候抛出异常,作为一种额外的帮助调试的手段。
```c++
#include <iostream> // std::cout/endl
#include <stdexcept> // std::out_of_range
#include <vector> // std::vector
using namespace std;
```
```c++
vector<int> v{1, 2, 3};
```
```c++
v[0]
```
> `1`
```c++
v.at(0)
```
> `1`
```c++
v[3]
```
> `-1342175236`
```c++
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](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/](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](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](https://isocpp.org/wiki/faq/exceptions)
\[5\] cppreference.com, “Exceptions”. [https://en.cppreference.com/w/cpp/language/exceptions](https://en.cppreference.com/w/cpp/language/exceptions)
\[5a\] cppreference.com, “异常”. [https://zh.cppreference.com/w/cpp/language/exceptions](https://zh.cppreference.com/w/cpp/language/exceptions)
\[6\] cppreference.com, “std::exception”. [https://en.cppreference.com/w/cpp/error/exception](https://en.cppreference.com/w/cpp/error/exception)
\[6a\] cppreference.com, “std::exception”. [https://zh.cppreference.com/w/cpp/error/exception](https://zh.cppreference.com/w/cpp/error/exception)