第6.16章: 每个函数不是 noexcept 就是 throws
noexcept(不可能失败),要么声明为 throws
(可以向调用者报告错误)。整个语言中唯一的例外是
int main(),它没有可以报告的调用者。
概述
在 第3.16章中,你学会了用 throw throws 报告错误,并用
catch throws 处理错误。这两条语句都只在被标记过的函数内部
才合法,而本章讲的就是那个标记:异常规范,
在赫布异常中它是一个取两种值的契约。
int add(int a, int b) noexcept; // cannot fail
int div(int a, int b) throws; // can fail: caller must handle or propagate
没有第三种选择,也没有“我没考虑过”这种选择。编译器不会让函数
以泄漏失败的方式保持未指定状态:调用一个 throws 函数,
只有当调用者要么处理它、要么自己也是 throws 时才被允许。
1. 为什么函数必须表态
想想调用者。它对每次调用都有两个问题:
- 这次调用会失败吗?如果会,我必须处理或向上传播。
- 我能把它放在不能失败的上下文里吗?例如析构函数、线程入口点、类信号的回调,或者我已经承诺为
noexcept的函数内部。
函数声明在编译期同时回答了这两个问题。整个设计就是如此: 错误契约是类型的一部分,因此编译器可以在没有任何文档、 也没有任何运行时开销的情况下验证整张调用图。
-
noexcept—— “我不可能失败。”如果你在里面写throw throws, 或者在没有处理的情况下调用throws函数,程序 就无法编译。 -
throws—— “我可能失败,错误会回到你那里。”
throw 任何东西”。正是这种自由让旧式异常无法分析:
调用图里你下面的任何函数都可能失败,因此每个函数都必须
按“可能失败”来编写。赫布异常移除了这种自由,也移除了
这个问题。
2. noexcept:我不可能失败
noexcept 函数承诺它没有任何要报告的东西。这个承诺由编译器
检查:
#include <fast_io.h>
using namespace ::fast_io::io;
int square(int x) noexcept
{
return x * x;
}
int bad(int x) noexcept
{
throw throws ::std::errc::invalid_argument; // ERROR: noexcept cannot report
}
int main()
{
println(square(7));
}
编译器会说:
error: 'throw throws' is only allowed inside a function declared 'throws' or 'return_failure{...}'
未处理的调用也会导致同样的情况:
::std::uint_least64_t parse_port(int raw) throws; // can fail
void forward_port(int raw) noexcept
{
println(parse_port(raw)); // ERROR: forward_port promised it cannot fail
}
error: call to 'throws' function in a non-'throws' function must be handled by an
enclosing 'try { } catch throws' block, or mark the calling function as 'throws' so
herbceptions can propagate
凡是需要保证不会失败的代码片段,noexcept 函数都无处不在:
比较运算符、大小查询、位技巧、校验辅助函数、
析构函数体,以及 API 无法传递错误的任何回调。
3. throws:我可能失败,而你会知道
throws 函数通过 throw throws 报告错误,错误会以
::std::error 的形式交给调用者(该值的每个成员函数见 第3.16章)。
调用点没有任何变化:没有
out 参数,没有错误码返回,也没有需要检查的 errno。
#include <fast_io.h>
using namespace ::fast_io::io;
::std::uint_least64_t parse_port(int raw) throws
{
if (raw <= 0 || raw > 65535)
{
throw throws ::std::errc::value_too_large;
}
return static_cast<::std::uint_least64_t>(raw);
}
void print_port(int raw) throws // propagates: no handler here
{
println("port: ", parse_port(raw));
}
int main()
{
try
{
print_port(8080);
print_port(99999); // fails here
}
catch throws (::std::error e)
{
perrln(e);
return 1;
}
}
输出:
port: 8080
[posix](75)Value too large for defined data type
注意没有发生的事情:parse_port 没有返回
哨兵值,print_port 没有去检查哨兵值,main 也没有
在每次调用之后检查错误码。失败从检测它的地方直接传到了
决定报告它的地方。
凡是与外部世界打交道的事情——打开文件、分配内存、读取、写入、
解析、建立连接——throws 函数都是合适的默认选择。
这就是 fast_io 把它们全部标记为 throws 的原因。
4. 在 noexcept 函数内部处理
noexcept 函数仍然可以调用可能失败的代码——它只是
必须自己把失败吸收掉。有两种诚实的做法:
#include <fast_io.h>
using namespace ::fast_io::io;
int read_count(int n) throws
{
if (n < 0)
{
throw throws ::std::errc::invalid_argument;
}
return n * 2;
}
// 1. recover: turn the failure into a value the caller understands
int read_count_or_zero(int n) noexcept
{
try
{
return read_count(n);
}
catch throws (::std::error)
{
return 0;
}
}
// 2. ignore: the failure genuinely cannot be acted on here
void try_log(int n) noexcept
{
try
{
println("count: ", read_count(n));
}
catch throws (::std::error)
{
}
}
int main()
{
println(read_count_or_zero(-1), " ", read_count_or_zero(21));
try_log(-5); // logs nothing, does not crash
}
第二种形式——无名处理程序
catch throws (::std::error) { }——是吞掉赫布异常唯一被
认可的方式,而且只在报告不可能或无意义的地方才是正确的。
绝不要为了“继续跑”而在一个本可以报告失败的函数里吞掉错误。
perrln(e)),那么处理程序本身就可能失败——打印是 I/O
——所以外层函数就不能是 noexcept。负责报告的处理程序
属于 throws 函数(或 main),这就是上面
的例子使用空形式的原因。
5. 构造函数与析构函数
构造函数遵循同样的规则:如果构造可能失败,该构造函数就是
throws。拥有操作系统资源的类型是典型案例——
打开文件确实可能失败,因此对象不能以半打开的状态存在:
struct resource
{
int handle;
resource(int raw) throws // construction may fail
{
if (raw < 0)
{
throw throws ::std::errc::bad_file_descriptor;
}
handle = raw;
}
~resource() // implicitly noexcept
{
}
};
析构函数隐式就是 noexcept——你愿意的话可以
显式写出(~resource() noexcept),但不能选择退出。
这是刻意的:析构函数运行时程序已经在离开某个作用域,
没有可以报告的对象。因此析构函数不得调用未处理的
throws 函数:
struct writer
{
~writer()
{
println("bye"); // ERROR: destructors cannot report failures
}
};
当某个清理步骤可能失败、且这个失败很重要时,请在对象销毁之前、
在 throws 函数里完成它:显式调用 flush() / close()
并在那里检查。让析构函数做尽力而为的清理没问题;
依赖析构函数做错误检测则不行。
6. 规范是类型的一部分
因为编译器必须能对它进行推理,所以规范像形参类型一样, 是函数类型的一部分。实践中你会遇到四个后果:
不能凭它重载
int f(int a) noexcept;
int f(int a) throws; // ERROR
error: exception specification in declaration does not match previous declaration
函数指针会携带它
int pure(int a) noexcept { return a + 1; }
int risky(int a) throws { return a + 1; }
int (*p1)(int) noexcept = pure; // ok
int (*p2)(int) throws = risky; // ok
int (*p3)(int) throws = pure; // ERROR: different types
error: cannot initialize a variable of type 'int (*)(int) throws' with an lvalue of
type 'int (int) noexcept': different exception specifications
两个方向都没有隐式转换:期望 throws 函数的调用者
不能悄悄得到一份它无法依赖其契约的指针,反之亦然。
虚函数覆盖必须匹配
struct base
{
virtual void f() noexcept {}
};
struct derived : base
{
void f() throws override {} // ERROR
};
error: overriding function has a different herbceptions ('throws'/'return_failure{...}')
specifier than the base version, which changes the calling convention
lambda 与成员函数也要带规范
struct point
{
int x, y;
int sum() const noexcept { return x + y; }
};
int main()
{
auto pure_lambda = []() noexcept { return 1; };
auto risky_lambda = []() throws { return 2; };
point p{1, 2};
::fast_io::println(pure_lambda(), risky_lambda(), p.sum(), "\n");
}
7. 条件规范(用于模板)
在模板内部,你并不能总是事先给出答案:你的函数是否可能失败
取决于对 T 的操作。两种形式都接受一个
bool 常量表达式,而 noexcept(expression) 回答
“该表达式的调用是否不可能失败?”:
int pure(int a) noexcept { return a + 1; }
int risky(int a) throws { return a; }
// 'noexcept(...)' is itself a compile-time bool
int cond1(int a) noexcept(noexcept(pure(a))) { return pure(a); }
int cond2(int a) throws(!noexcept(risky(a))) { return risky(a); }
阅读 fast_io 自己的头文件时,你会经常用到
noexcept(expression):那里每一个操作都带着由它所执行的操作
推导出的条件规范,因此
对真实文件而言 println 是 throws,而对
不可能失败的 sink 则保持 noexcept。
#include <fast_io.h>
using namespace ::fast_io::io;
// ask the library, do not guess
static_assert(!noexcept(println(1))); // I/O can fail
static_assert(!noexcept(scan(::fast_io::c_stdin(),
::std::declval<int&>()))); // input can fail
static_assert(!noexcept(::fast_io::to<::std::uint_least64_t>("12"))); // parsing can fail
static_assert(noexcept(::fast_io::cstr_len("abc"))); // pure computation
8. main 是唯一的例外
main 没有调用者,因此没有可以报告的对象。所以语言
禁止在它上面写规范:
int main() throws { } // ERROR
error: 'main' cannot be declared with a herbceptions ('throws'/'return_failure{...}')
exception specification; 'main' has no caller able to consume an error result, and
uncaught errors in 'main' trap instead
所以 main 就是错误链的终点:它在 try 块内调用
throws 函数,打印出了什么问题,并返回退出
码。这正是你在 第2.14章中已经见过的形态:
#include <fast_io.h>
int main()
{
try
{
::fast_io::println("work\n");
}
catch throws (::std::error e)
{
::fast_io::perrln(e);
return 1;
}
return 0;
}
main 也可以把某次调用留作未处理
(编译器把 main 视为能够传播),但那样一旦失败就会
在没有消息的情况下触发陷阱。请在 main 里始终捕获;
一个没有诊断信息就陷入陷阱的进程,等于一份你得自己写的
缺陷报告。
9. 一张表总结这条规则
| 情形 | 应当声明什么 |
|---|---|
| 纯计算:算术、比较、访问器、位技巧 | noexcept |
| 任何可能接触资源的事情:文件、套接字、分配、解析、I/O | throws |
| 把失败继续向上报告的辅助函数 | throws(在处理程序中用 throw throws;) |
| 不能失败、但会调用可能失败代码的函数 | noexcept + 局部处理程序(恢复,或空形式) |
| 析构函数 |
隐式 noexcept;当失败很重要时,在 throws
函数里显式做 flush()/close()
|
| 获取资源的构造函数 | throws |
| 泛型代码 | noexcept(...)/throws(...) 条件规范 |
int main() |
无规范;捕获并报告 |
要点
-
除
int main之外的每个函数,要么是noexcept,要么是throws。不要留下任何未指定。 -
noexcept表示“不可能失败”,且编译器会强制执行:内部不能有throw throws,也不能有未处理的、对throws函数的调用。 -
throws表示“可能失败”;调用者用catch throws处理它,或者自己也是throws并向 上传播。 -
必须调用可能失败代码的
noexcept函数要在本地处理它: 用一个值恢复,或者在无事可做时使用空处理程序catch throws (::std::error) { }。 -
析构函数隐式是
noexcept;获取资源的构造函数是throws。 - 规范是函数类型的一部分:不能凭它重载, 它会随函数指针一起传递,虚函数覆盖必须精确匹配它, lambda 与成员函数也携带它。
-
用
noexcept(expression)向编译器(以及库)询问 某次调用是否可能失败;fast_io把所有 I/O 都声明为throws。