第6.16章: 每个函数不是 noexcept 就是 throws

English

重要 —— 请与第3.16章一起阅读。 在本教程中,函数绝不会没有错误契约。你写的每个函数 —— 自由函数、成员函数、构造函数、lambda、虚函数覆盖 —— 都要么声明为 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. 为什么函数必须表态

想想调用者。它对每次调用都有两个问题:

函数声明在编译期同时回答了这两个问题。整个设计就是如此: 错误契约是类型的一部分,因此编译器可以在没有任何文档、 也没有任何运行时开销的情况下验证整张调用图。

注意:在旧式 C++ 异常下,函数允许省略规范并 “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() 无规范;捕获并报告

要点