不可恢复错误与编译器插入的检查
前面的章节其实已经见过两类失败了:
- 有些程序会在编译期直接被拒绝,因为编译器已经能证明它是错的;以及
- 有些操作虽然会通过编译,但 scpp 会先插入一个运行期检查,一旦执行真的走到不可能 或不安全的状态,程序就会立刻中止。
这一节讲的就是第二类。对今天的 scpp 来说,算术溢出、运行期下标越界之类的受检失 败都不会尝试恢复,而是会立刻终止整个程序。
对下面每个可以运行的例子,把文件保存成
fail-fast.scpp,然后这样编译运行:
scpp fail-fast.scpp -o fail-fast
./fail-fast对于那些本来就应该中止的例子,程序本身会成功编译,但一旦真的碰到坏状态,就会通
过 std::abort() 结束运行。
运行期下标会在读取元素前先做检查
如果下标不是编译期常量,scpp 就没法在源码阶段直接拒绝程序。这时它会围绕真正的 访问点插入边界检查。
import std;
int read_at(std::span<const int> values, int index) {
return values[index];
}
int main() {
int numbers[4]{};
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
std::println("{}", read_at(numbers, 2));
return 0;
}输出:
30
关键不只是 2 恰好合法,而是函数体里写的是普通的
values[index],即使精确下标要 等到运行期才知道,scpp
也仍然会把这次访问保持在受检状态里。
同一处受检下标在运行期值越界时会直接中止
如果同一处访问在运行时拿到了错误下标,插入进去的检查就会在任何非法读取发生之 前把程序停下来。
import std;
int read_at(std::span<const int> values, int index) {
return values[index];
}
int main() {
int numbers[4]{};
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
return read_at(numbers, 9);
}运行时行为:程序会直接中止,而不会去读取 span 末尾之外的数据。
这就是第 8.1 节那个“编译期越界报错”例子的运行期对应物。编译器如果能证明下标错 了,就直接拒绝源码;如果只有正在运行的程序才知道下标是多少,编译器就插入检查。
有符号整数溢出默认也是受检的
普通整数算术同样带检查。程序不会在溢出时悄悄回绕。
int main() {
int limit = 2147483647;
int next = limit + 1;
return next;
}运行时行为:程序会在计算 limit + 1 时直接中止。
所以对今天的 scpp 来说,这个边界上采用的是“快速失败”的受检算术,而不是 C++ 风 格的溢出惊喜或未定义行为。
除法会在真正计算前先检查除数
除法也会被检查。除数为零时,程序会直接中止,而不是把问题留给机器层面的未定义行 为。
int main() {
int total = 10;
int count = 0;
return total / count;
}运行时行为:程序会在真正做除法之前直接中止。
同一个插入检查也会覆盖另一个有符号除法陷阱,也就是
INT_MIN / -1,原因相同:结 果装不进一个
int。
到目前为止,快速失败规则是
- 如果 scpp 只靠源码本身就能证明某个操作有问题,它会在编译期直接拒绝程序;
- 如果正确性取决于运行期值,scpp 可能会插入一个检查,并在值非法时直接中止程序;
- 受检下标、受检算术溢出、受检除法,都是这种快速失败模型的例子;
- 在今天的语言模型里,这些都属于不可恢复错误,而不是可以捕获后继续执行的异常。
下一节仍然会留在“错误处理”这个主题里,但会把重心转向那些程序今天就能主动处理 的问题,也就是通过显式返回值来表达的可恢复错误。