从零开始 / 一次只解释眼前的一步
错误处理与
输入解析。
从合法的零、缺失和失败出发,先说清函数怎样交回结果。再读取实际输入,沿正常返回和异常传播的路径,检查对象是否释放、原状态是否保留。
10小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。
这一章怎样学
先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。
每次写下输入、结果状态和实际走过的控制路径,再检查输出、资源清理与状态变化。先完成第15章模板与concepts,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。
16.1 先写失败合同:零究竟是结果,还是失败?
先完成15章的模板与约束。本章把输入从源码里的已知整数推进到外部文本:用户输入的可能是0,也可能是12x。类型正确只是一层要求,我们还要说明输入不合要求时,函数怎样报告、已有对象会发生什么。
想象一个初值为7的配置项。更新为0完全合法;如果把“返回0”同时用作失败信号,调用者就无法判断配置真的变成了0,还是更新根本没有成功。解决前先写合同:允许哪些值,怎样表示成功,失败时旧值是否保留。
把状态与数据分开
第04章已经用bool和整数退出码报告结果。这里可以让函数返回bool,并通过一个引用参数更新原对象:true表示接受候选并完成更新;false表示拒绝,原值保持。返回的bool回答“这件事成功了吗”,引用所指的int保存“结果是多少”。两者不要混在一起。
#include <iostream>
bool accept_nonnegative(int candidate, int& output) {
if (candidate < 0) {
return false;
}
output = candidate;
return true;
}
int main() {
int current{7};
const bool first = accept_nonnegative(0, current);
std::cout << first << ' ' << current << '\n';
current = 7;
const bool second = accept_nonnegative(-1, current);
std::cout << second << ' ' << current << '\n';
}
逐个实验都从旧值7出发。候选0在合同内,函数返回true、保存0;非法候选被拒绝,函数返回false、仍保存7。输出中的1/0是bool的默认显示,不是把配置结果又编码成了成功码。检查条件必须在修改原对象之前,否则“失败保留旧值”就只剩一句口号。
三种情况,需要不同的回应
| 情况 | 具体例子 | 调用方应该知道什么 |
|---|---|---|
| 正常缺失 | 可选配置没有填写 | 可以采用默认值;缺失不等于数字零 |
| 可报告的失败 | 要求整数,却收到12x | 本次更新未完成,可以提示用户修正 |
| 违反程序合同 | 没检查空容器就访问首项 | 修正调用逻辑与测试,不能假装得到一个正常结果 |
这是接口设计中的分类,不是说某一类必须永远用某种语法。我们随后用optional表达一个小接口的“有值/无值”,用异常表达不能在当前层完成的操作。异常不是所有错误的自动检测器。越界或悬空访问不会因为外面套了try就变成可靠的错误处理。
先自己写一句合同:输入为非负整数时更新,否则返回false且保留旧值。再核对代码的每一条return和每一次赋值,能否分别对应合同中的一句话。
16.2 optional:一个整数,或者没有整数
引用输出参数是一种选择。如果接口只需要交回一个可缺失的值,std::optional<int>把两种状态放进一个返回对象:有一个int,或没有int。使用时包含<optional>;给模板填int沿用08与15章的读法。
std::optional<int> value{0};建立一个有值的optional,内部整数为0。std::optional<int> empty{};默认建立无值状态;std::nullopt是标准库表示无值的标记,也能用于初始化或返回空结果。它不是nullptr,也不是一个特别的整数。
返回类型为optional<int>的函数可以用return value;把普通int构造成有值结果,也可以用return std::nullopt;交回无值结果。调用者收到的是optional对象,不是一个未经初始化、等着猜测的整数。
先看有没有,再看里面是什么
value.has_value()返回bool;if (value)在条件里也检查“有没有值”。因此内部保存0时,条件仍然为真。它没有把内部int转换成bool来判定成功。
确定有值后,*value读取里面的对象。星号写法与指针解引用相似,但这里调用的是optional提供的访问接口,不表示optional就是裸指针。直接解引用空optional违反前提,不能把结果预测为0,也不把它当作本章可以运行的找错实验。
value.value()也是访问里面的值:有值时返回它;无值时报告std::bad_optional_access异常。异常的执行和捕获在16.4正式讲,下面只在已知有值时调用value。value.value_or(7)则按值返回已有整数,或在无值时返回后备值7;它不会把7填回原来空的optional。本章给后备值使用普通整数,不把这个调用当作推迟执行后备函数的机制。
#include <iostream>
#include <optional>
int main() {
const std::optional<int> zero{0};
const std::optional<int> missing{std::nullopt};
std::cout << zero.has_value() << ' ' << missing.has_value() << '\n';
if (zero) {
std::cout << zero.value() << '\n';
}
std::cout << missing.value_or(7) << '\n';
}
先给两个对象各画一个状态框:一个写“有值:0”,另一个写“无值”。再对每次操作提问:是在问状态,还是在取数据?有值对象的has_value为1、取出的数为0;空对象的has_value为0、value_or(7)得到7。这三个数互不矛盾。
这里的optional只表达两种状态,不会自动保存失败原因。如果界面要分别显示“含非法字符”和“数值太大”,就应在接口中另行表达这些信息;不要从一个nullopt猜出它原来是哪种失败。本章先把二分合同落实,不扩成另一套错误框架。
只把有值对象里的0改为5,再预测状态检查与取值结果:第一行仍为1 0,第二行变为5,第三行仍为7。有值状态没有改变,改变的是其中的数据。
16.3 从终端读入,再严格解析整段文本
之前的输入写在源码里;现在先练习读取真实的标准输入,再判断文本是否合法。标准输入是程序接收输入的一条通道,在普通终端中通常由你键入内容。程序等候输入时暂时没有新输出,并不表示编译失败或程序已经结束。
读取一个整数:cin与提取运算符
std::cin由<iostream>提供。std::cin >> value尝试读取一个整数并写入已有的value;这里的>>是流提取运算符。它返回流的引用,因此可以整体放进if条件:读取没有进入失败状态时走成功分支,否则走失败分支。
默认的整数提取会跳过前面的空白,并尝试读取整数前缀。它并不保证消费了整行。输入12x时,它可以得到整数12,把后面的x留在输入通道中。这是流提取本身的合同;若你的配置必须是一整行合法整数,还需要下一步的严格解析。
#include <iostream>
int main() {
int value{};
if (std::cin >> value) {
std::cout << "value " << value << '\n';
} else {
std::cout << "input-failed\n";
}
}
下载后,在该文件所在目录编译并运行:
clang++ -std=c++20 numeric-input.cpp -o numeric-input
./numeric-input
看到程序等待时,输入12并按Enter。终端通常会回显你键入的12;那一行是你的输入,后面程序打印的结果才是程序输出。每次重新启动,再试12x或bad。前两者成功读到12,bad走失败分支,不能拿失败后的value当作有效结果。
在macOS/Linux终端的新空行处按Ctrl+D,可以让这次读取遇到输入结束(EOF)。单独按Enter只是给出换行:整数提取会继续等非空白内容;如果空行后才结束输入,读取失败。这里不把“按了一次Enter还在等待”说成死循环。
读取一整行:getline保留空行这个事实
std::getline(std::cin, line)把字符写入已有的string,读到换行时消耗换行,但不把换行放进line。它同样返回流引用,可以放在if条件中检查。这个接口不会先跳过行首空格,因此空白也是读到的文本的一部分。
只输入Enter是一条存在但内容为空的行,getline成功,line.size()为0。尚未读到任何字符就遇到EOF,才是没有读到一行。非空文本后直接EOF,即使没有最后的换行,也可以成功读到那段文本。
#include <iostream>
#include <string>
int main() {
std::string line;
if (std::getline(std::cin, line)) {
std::cout << line.size() << '\n';
std::cout << line << '\n';
} else {
std::cout << "no-line\n";
}
}
把上一组命令中的numeric-input换成line-input,重新编译运行。分别输入12、12x、空行,再在另一次全新运行中直接结束输入:前三次得到长度2、3、0的文本,最后一次走no-line分支。两个程序每次各自运行,不把它们当作共享同一个流状态的连续步骤。
如果以后在同一程序里先用整数提取再用getline,前一次留下的换行也会影响下一次读取;不能照抄这两个独立实验的结果。这里的固定比较只依赖各自的新进程,不引入额外的流恢复协议。
严格整数解析:成功码与停止位置都要看
读到一行不代表这一行是合法数字。下面的函数接收string_view,沿用09章的借用合同:调用期间文本有效,函数返回独立整数或空结果,不把字符指针带出去。
本例的show(std::string_view text)可以直接接收show("12x")或show("")这样的调用:string_view提供了不带explicit的字符指针构造函数,因此这些字符串字面量可以在调用入口建立这个参数。视图借用原来的字符,不复制文本;长度数到末尾的终止零之前,所以“12x”的长度是3,空串的长度是0。这些字符串字面量本身具有静态存储期,字符在程序运行期间一直存在,因此本例调用期间借用有效。这个结论来自原字符的寿命,不是view延长了寿命。可核对固定C++20草案的string_view构造合同。
std::from_chars(first, last, value)由<charconv>提供。first与last描述半开字符范围,value是待写入的整数引用。整数版本默认按十进制解析;它不跳过空白,不接受前导加号,可以为有符号整数读取减号。本章要求一个完整、无额外字符、数值在−1,000,000到1,000,000之间的十进制整数。
返回的结果对象有两个成员:ptr表示停止的位置,ec表示错误码。const auto [end, error] = ...;沿用11章的结构化绑定,为这两个成员分别命名。也可以先用const auto parsed = ...;保存整个结果,再用parsed.ptr和parsed.ec访问成员;两种写法读取的是同一个接口的结果。std::errc由<system_error>提供,是标准库枚举类型,std::errc{}是表示转换成功的零值;std::errc::invalid_argument表示没有读到合适的数字模式,std::errc::result_out_of_range表示数值无法放进目标整数类型。后两种失败时输出整数保持原值,不能把那个原值当成解析成功的结果。这次整数from_chars调用通过返回状态报告问题,本身不抛异常。
完整合同需要依次检查:
- 空文本直接拒绝;先做这一判断,再对非空有效范围计算data()+size()。
- 错误码必须表示转换成功。
- 停止位置必须等于整段文本的末尾,不能剩下x或空格。
- 转换所得整数还必须满足本接口的业务范围。
第2项检查int能否保存它,第4项检查应用是否接受它。即使某数完全能放进本机int,也可能超过这里的一百万上限。errc没有替应用自动检查这个上限。本章运行环境的int能表示该范围,源码与验证记录只声明这些明确的输入条件。
#include <charconv>
#include <iostream>
#include <optional>
#include <string_view>
#include <system_error>
std::optional<int> parse_integer(std::string_view text) {
if (text.empty()) {
return std::nullopt;
}
int value{};
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), value);
if (error != std::errc{} || end != text.data() + text.size()) {
return std::nullopt;
}
if (value < -1000000 || value > 1000000) {
return std::nullopt;
}
return value;
}
void show(std::string_view text) {
const auto result = parse_integer(text);
if (result) {
std::cout << "value " << *result << '\n';
} else {
std::cout << "invalid\n";
}
}
int main() {
show("0");
show("12");
show("12x");
show("");
show("-1000000");
show("1000001");
show("3x");
show("9999999999999999999999999999999999999999");
}
完整例一:每个输入到底在哪一步停下来?
| 输入文本 | 转换层发生什么 | 本函数的结果 |
|---|---|---|
| 0 | 得到0,消费1个字符,到达末尾 | 有值0 |
| 12 | 得到12,消费2个字符,到达末尾 | 有值12 |
| 12x | 得到前缀12,停在x之前,错误码仍成功 | 无值,整段文本不合法 |
| 空文本 | 由空输入检查直接返回,不调用转换 | 无值 |
| -1000000 | 得到下边界,完整消费 | 有值−1000000 |
| 1000001 | 本机int可保存,但超过业务上限 | 无值 |
| 3x | 得到前缀3,仍有x未消费 | 无值 |
| 源码中的超长数字 | 不能由本机int表示 | 无值,转换范围失败 |
以12x逐步画:first指向1,解析经过1和2,end指向x;last在x后面。error == std::errc{}为真,但end与last不同,所以最终返回nullopt。若只盯着value等于12,就会把前缀读取成功误认作整个输入成功。
分别从原文件做两次单条件实验:只把1000001输入改为1000000,对应那一行从invalid变成value 1000000;另一次只把−1000000改为−1000001,对应那一行从接受变为invalid。它们检查的是闭区间的两个端点,不能合成一次修改后仍说只改了一个条件。
三个检查决定是否产生整数结果
文本0的一个字符被完整消费,错误码成功,数值0落在业务范围内;optional有值。if(result)检测的是有值状态。
文本12x中数字部分被读为12,但结束位置在x处,未到输入尾后。错误码成功不能代替完整消费检查。
输入没有字符,最前面的empty检查返回nullopt,不将初始化的value=0误当作解析结果。
−1000000完整解析并位于闭区间内,所以接受;1000001可完整解析,却超过本例业务上界,所以拒绝。
源码最后的长数字不能由本机int表示。from_chars报告解析范围错误,函数立即返回nullopt;调用者不把value当作成功结果。
查看所有步骤的文字与数值
- 1 · 成功解析合法零
输入:"0";三项检查:均通过;结果:有值 0
文本0的一个字符被完整消费,错误码成功,数值0落在业务范围内;optional有值。if(result)检测的是有值状态。
- 2 · 前缀成功仍可能拒绝
输入:"12x";消费位置:12 | x;结果:无值
文本12x中数字部分被读为12,但结束位置在x处,未到输入尾后。错误码成功不能代替完整消费检查。
- 3 · 空串先拒绝
输入:空串;解析调用:不执行;结果:无值
输入没有字符,最前面的empty检查返回nullopt,不将初始化的value=0误当作解析结果。
- 4 · 业务范围是额外约束
允许区间:−1000000…1000000;−1000000:有值;1000001:无值
−1000000完整解析并位于闭区间内,所以接受;1000001可完整解析,却超过本例业务上界,所以拒绝。
- 5 · 表示范围失败不提交
输入:超int范围长数;解析状态:范围错误;结果:无值
源码最后的长数字不能由本机int表示。from_chars报告解析范围错误,函数立即返回nullopt;调用者不把value当作成功结果。
找错:只检查错误码为什么不够?
#include <charconv>
#include <iostream>
#include <optional>
#include <string_view>
#include <system_error>
std::optional<int> parse_integer(std::string_view text) {
if (text.empty()) {
return std::nullopt;
}
int value{};
const auto parsed =
std::from_chars(text.data(), text.data() + text.size(), value);
if (parsed.ec != std::errc{}) {
return std::nullopt;
}
if (value < -1000000 || value > 1000000) {
return std::nullopt;
}
return value;
}
void show(std::string_view text) {
const auto result = parse_integer(text);
if (result) {
std::cout << "value " << *result << '\n';
} else {
std::cout << "invalid\n";
}
}
int main() {
show("12x");
show("12");
}
这个版本故意漏掉完整消费的检查,12x与12都会被接受为12。它的错误是合同实现不完整,能够安全编译运行并不证明解析器正确。保留题目要求,只补上停止位置的判断:12x应被拒绝,12仍然接受。不能把测试输入里的x删掉,就宣布程序已修好。
严格解析不意味着所有接口都必须采用相同格式。某些界面允许空格,某些协议允许前缀;它们需要自己的明确合同。本例拒绝空白和尾随字符,是我们已经写清的规则,不是对所有文本解析器的统一规定。
16.4 throw、try与catch:失败怎样跳过中间调用?
前面的函数用普通返回值交回失败。有时一层调用不能完成操作,也没有能力恢复,希望把失败交给更外层处理。异常机制允许这样传播;它会改变接下来执行哪一行,因此必须单独跟踪控制流。
throw std::runtime_error{"invalid"};先建立一个携带说明文本的异常对象,再开始寻找能处理它的catch。std::runtime_error由<stdexcept>提供,花括号里的invalid是本例给出的说明,不是编译器自动诊断。throw不是普通return:它不会给当前调用表达式返回一个正常结果,紧接着的普通语句不会因此继续执行。
try { ... }把可能传播异常的调用放在一个区域里;紧随其后的catch (const std::runtime_error& error) { ... }处理匹配的runtime_error异常。const引用借用异常对象,避免为了查看信息另复制一份对象;error这个名字在catch体内使用。error.what()取得说明文字,本例打印的是创建异常时提供的invalid。需要输出时才访问这段借来的文字,不把它的指针带到异常对象寿命之外。
标准库还提供std::invalid_argument表示参数不合要求,写法同样来自<stdexcept>,后面的配置例子会使用它。它与runtime_error是不同异常类型,不能认为catch runtime_error就一定能接住invalid_argument。处理器也可以按合适的公开基类匹配,例如两者都属于std::exception体系;这里的完整例子都按具体类型捕获,不要求你手写异常继承体系。
#include <iostream>
#include <stdexcept>
void inner() {
throw std::runtime_error{"invalid"};
}
void outer() {
std::cout << "outer before\n";
inner();
std::cout << "outer after\n";
}
int main() {
try {
outer();
} catch (const std::runtime_error& error) {
std::cout << "caught " << error.what() << '\n';
}
std::cout << "done\n";
}
按调用顺序写一条时间线:
- main进入try,调用outer。
- outer打印outer before,再调用inner。
- inner抛出runtime_error。inner没有处理器,outer也没有,于是向外寻找main中的匹配catch。
- catch打印caught invalid。它处理完成后,执行try/catch之后的语句,打印done。
完整输出只有outer before、caught invalid、done三行。outer after不会打印,也不会在catch结束后补执行。异常处理后继续的位置是处理器所在结构之后,不是刚才失败的函数内部。
若不需要异常名字,可以写catch (const std::runtime_error&),仍按同一个类型匹配,只是不在处理器里访问异常对象。若存在多个catch,它们按书写顺序尝试匹配,应把更具体的处理放在能笼统接住它的处理之前。
补充 · 读懂项目中的未实现占位(另估10分钟)
std::logic_error也来自<stdexcept>,用于表达程序逻辑或合同方面的错误。Reduction Lab的待写函数以throw std::logic_error("TODO: implement ...");明确报告尚未实现;这里的圆括号建立携带说明的异常对象,throw使当前函数没有正常返回值。运行到它会失败,不能把项目能编译当作已经实现。
占位体里还会看到(void)input;。这是把表达式转换为void的弃值写法:input表达式仍被求值,只是不保留一个供后续使用的结果。对这里的普通已初始化参数,单独弃值没有完成任何计算任务。尤其(void)f();仍会调用f;若f抛异常,该异常仍传播,括号不会吞掉它。不能删掉整个调用来“去掉一种转换语法”,那可能一起删掉测试。
此处只认读保留的学习者占位代码,不展开其他C风格转换。TODO仍需你依据接口完成;本补充另估10分钟,不计入原16章学时。
没人捕获,不代表自动回收就是析构证据
找不到匹配处理器时,程序会调用std::terminate而不能按普通成功路径继续。此时是否先展开栈由实现决定;不能把“进程最后退出、系统收回了内存”当作某个C++析构函数已经执行的证据。下面要研究RAII的异常清理,所以会明确设置一个外层catch,并观察进入它之前发生了什么。
回看16.2:空optional调用value()会抛出bad_optional_access,检查后解引用则要求有值。这是两种不同的接口合同,不能把所有空访问都说成同一种异常。教学例子没有运行空解引用,也没有触发无人处理的终止路径。
16.5 清理资源与保住旧值,是两个问题
13章已经用unique_ptr拥有真实的堆对象,并观察普通退出和提前return时的清理。现在保持相同拥有关系,只把离开函数的原因换成异常传播。理解“谁拥有资源”以后,才有东西可沿展开过程追踪。
完整例二:到达catch之前,谁先被销毁?
下面的Trace依旧是一个真实分配的对象,打印构造与析构用于观察。std::make_unique<Trace>(9)建立这个堆对象并交给局部owner独占拥有。异常从run向外传播、越过owner所在的作用域时,会销毁这个已经完成构造的局部智能指针,进而销毁它拥有的Trace并释放存储。
这个过程叫栈展开(stack unwinding)。名字中的“栈”不表示只有栈上资源会被清理;堆对象也会由途经的拥有者释放。对需要销毁的自动对象,顺序与它们完成构造的顺序相反。
#include <iostream>
#include <memory>
#include <stdexcept>
struct Trace {
int value;
explicit Trace(int input) : value{input} {
std::cout << "create " << value << '\n';
}
~Trace() {
std::cout << "destroy " << value << '\n';
}
};
void run() {
auto owner = std::make_unique<Trace>(9);
std::cout << "before throw\n";
throw std::runtime_error{"invalid"};
}
int main() {
try {
run();
} catch (const std::runtime_error& error) {
std::cout << "caught " << error.what() << '\n';
}
}
逐步核对四行:create 9说明Trace已构造;before throw说明即将失败;destroy 9发生在离开run时;caught invalid说明最后才进入main的catch。对象9实际销毁一次,没有由catch再手动delete,也没有等到进程退出才释放它。
正常调用进入异常展开
main进入try后调用run,此时尚未构造Trace,程序仍在普通调用路径。
make_unique完成实际Trace 9的构造,输出create 9;局部owner负责它的释放。
输出before throw后执行throw std::runtime_error{"invalid"}。控制离开普通调用路径,向main的匹配处理器传播。
离开run时局部owner析构,实际销毁并释放Trace 9;destroy 9在处理器的输出之前出现。
展开经过run后,main的const runtime_error&处理器读取本例消息,输出caught invalid。没有再由catch重复释放已销毁对象。
查看所有步骤的文字与数值
- 1 · try调用run
main:try中;run:进入函数;Trace 9:尚未创建
main进入try后调用run,此时尚未构造Trace,程序仍在普通调用路径。
- 2 · 真实对象已被拥有
owner:唯一拥有;Trace 9:存活 / 值9;已输出:create 9
make_unique完成实际Trace 9的构造,输出create 9;局部owner负责它的释放。
- 3 · 抛出异常
已输出:before throw;异常消息:invalid;Trace 9:仍待展开清理
输出before throw后执行throw std::runtime_error{"invalid"}。控制离开普通调用路径,向main的匹配处理器传播。
- 4 · 先展开已构造对象
run局部owner:析构;Trace 9:destroy 9;main catch:尚未开始
离开run时局部owner析构,实际销毁并释放Trace 9;destroy 9在处理器的输出之前出现。
- 5 · 匹配处理器开始
Trace 9:已销毁一次;main catch:开始处理;输出:caught invalid
展开经过run后,main的const runtime_error&处理器读取本例消息,输出caught invalid。没有再由catch重复释放已销毁对象。
这个样例假定观察日志正常输出,异常由明确的throw产生,不注入分配或输出设备故障。日志帮助定位顺序;不因此声称任意日志操作本身永不失败。析构函数该如何处理失败,在16.6继续说明。
构造到一半失败:整个对象的析构未必会运行
类成员按照声明顺序初始化,这一点已经在07章讲过。下面Owner先初始化resource,再初始化part。Part的构造函数抛异常,因此Owner没有完成构造,Owner构造函数的函数体也没有开始执行。
规则不是“没有完整Owner,所以什么都不管”。已经完成构造的成员仍要清理:resource已经是有效的unique_ptr,它被销毁并释放Trace;part没有完成构造,不对它执行一次正常的完整对象析构。Owner本身的析构函数也不会因此被调用。
#include <iostream>
#include <memory>
#include <stdexcept>
struct Trace {
int value;
explicit Trace(int input) : value{input} {
std::cout << "create " << value << '\n';
}
~Trace() {
std::cout << "destroy " << value << '\n';
}
};
struct Part {
Part() {
throw std::runtime_error{"member"};
}
};
struct Owner {
std::unique_ptr<Trace> resource;
Part part;
Owner() : resource{std::make_unique<Trace>(9)}, part{} {
std::cout << "ready\n";
}
~Owner() {
std::cout << "destroy owner\n";
}
};
int main() {
try {
Owner owner;
} catch (const std::runtime_error& error) {
std::cout << "caught " << error.what() << '\n';
}
}
实际次序为create 9、destroy 9、caught member。ready不出现,因为没进入Owner构造函数体;destroy owner不出现,因为Owner没有构造成功。清理是由已经完成的成员承担的,不能把这条记录改写成“先执行了Owner析构”。这也是把资源立刻交给拥有成员的实际意义。
异常保证:操作失败以后,状态还剩什么?
| 保证 | 对失败结果的承诺 | 本章要检查什么 |
|---|---|---|
| 基本保证(basic guarantee) | 不泄漏资源,相关对象仍满足其有效状态约定 | 对象可以继续按合同使用,但值可能已变 |
| 强保证(strong guarantee) | 对所承诺的状态,失败不产生可观察的改变 | 更新失败后旧配置逐项保持 |
| 不抛出保证(no-throw guarantee) | 操作不会通过异常向调用者报告失败 | 实现与调用合同能支持这个保证 |
这些是某项操作的合同,不能因为一个类用了智能指针,就自动宣称它的所有操作都有强保证。资源不泄漏,也不等于旧配置未被部分改写。日志等明确允许的外部观察应单独说明:下面的强保证只针对current配置,不承诺抹掉用于显示失败的输出。
准备、验证、提交:在最后一步之前保留旧配置
下面的replace按引用接收current,按值接收独立candidate。先验证candidate的所有元素都非负,才提交新内容。std::any_of和lambda沿用10章:只要发现一个负数,条件就成立,于是抛出invalid_argument。
current.swap(candidate)是vector的交换接口:交换两者拥有的内容与容量,不是逐个把candidate元素赋给current。本例两者都是使用默认分配器的vector<int>,这次交换不会抛出异常;自定义分配器的合同不能从这个例子外推。交换之后,candidate拥有旧内容,函数结束时由它正常清理。我们依靠这个明确的提交合同取得强保证,不是靠“swap看起来很短”来推断。
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <vector>
void replace(std::vector<int>& current, std::vector<int> candidate) {
if (std::any_of(candidate.begin(), candidate.end(),
[](int value) { return value < 0; })) {
throw std::invalid_argument{"negative"};
}
current.swap(candidate);
}
int main() {
std::vector<int> current{1, 2};
try {
replace(current, std::vector<int>{3, -1});
} catch (const std::invalid_argument& error) {
std::cout << "caught " << error.what() << '\n';
}
std::cout << current[0] << ' ' << current[1] << '\n';
replace(current, std::vector<int>{4, 5});
std::cout << current[0] << ' ' << current[1] << '\n';
}
初始current为1、2。第一次候选为3、−1,验证失败,在交换之前跳到catch,先打印caught negative;再检查current仍为1、2。第二次候选为4、5,验证成功才交换,最终current为4、5。
如果创建候选时就失败,函数尚未提交;若验证失败,也没有触碰current。成功交换以后,本例没有再安排可能改变成功结论的操作。这条准备后提交的顺序,才是保持旧配置的理由。
把同一个顺序用回整数配置
整数更新不需要堆资源,但同样应先得到有效候选再改原值。下面复用16.3的严格解析;if (!candidate)把有值状态取反,检查是否无值,只有candidate有值才把其中int赋给current。对本例普通int赋值不会抛异常,所以可以作为最后一步提交。它不需要为了模仿vector而硬加一次交换。
#include <charconv>
#include <iostream>
#include <optional>
#include <string_view>
#include <system_error>
std::optional<int> parse_integer(std::string_view text) {
if (text.empty()) {
return std::nullopt;
}
int value{};
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), value);
if (error != std::errc{} || end != text.data() + text.size()) {
return std::nullopt;
}
if (value < -1000000 || value > 1000000) {
return std::nullopt;
}
return value;
}
bool update(int& current, std::string_view text) {
const auto candidate = parse_integer(text);
if (!candidate) {
return false;
}
current = *candidate;
return true;
}
void show(std::string_view text) {
int current{7};
const bool accepted = update(current, text);
std::cout << accepted << ' ' << current << '\n';
}
int main() {
show("0");
show("12x");
show("1000001");
}
show每次重新建立current为7。输入0时输出1 0;12x与1000001分别输出0 7。最后两行的7是各自实验的旧值,不是前一次更新后自动回滚出来的7。先区分实验的初始化,再判断失败保留的承诺。
16.6 noexcept:承诺不能逃出异常,然后回访移动
在函数声明的参数列表之后写noexcept,表示它具有不允许异常逃出的异常说明;noexcept(true)表达同样的说明,noexcept(false)则表示可能抛出。它不是自动包一层catch,也不会把异常转换成返回false。
具有noexcept的函数内部可以调用可能抛出的操作,但必须保证不会让异常越过自身边界。如果异常真的逃出,会调用std::terminate;调用者外面的普通catch不能把它当作一次可恢复失败接住。到终止之前可能完全、部分或不展开栈,由实现决定;我们不运行这条路径来指定一套必然的析构日志。
把可能失败的函数随手标成noexcept,不等于给实现证明了“不失败”。反过来,一个没有noexcept的函数,也不表示每次调用一定会抛出:它只是没有以这个说明承诺不抛出。下面正好需要区分这两件事。
移动是否值得选,取决于它能提供什么保证
12章已经看过const引用与右值引用的构造选择。现在使用<utility>中的std::move_if_noexcept(x):对于本例这种可复制的类型,如果移动构造没有不抛出保证,结果按const引用供后续构造使用;有不抛出的移动构造时,结果按右值引用供使用。如果类型不能复制,它仍可能只能提供右值引用,不能把名字理解为“任何情况下都会替你找到安全的复制”。
它只选择用于下一步的引用形式,实际复制或移动由紧接着的对象构造完成。源码用path成员记录选中哪种构造:I是直接建立,C是复制,M是移动;char字面量和成员初始化沿用已有读法。两个移动体只处理int与char,把观察输出留到main,避免在承诺不抛的移动体里添加输出操作。
#include <iostream>
#include <utility>
struct PotentiallyThrowing {
int value;
char path;
explicit PotentiallyThrowing(int input) : value{input}, path{'I'} {}
PotentiallyThrowing(const PotentiallyThrowing& other)
: value{other.value}, path{'C'} {}
PotentiallyThrowing(PotentiallyThrowing&& other)
: value{other.value}, path{'M'} {}
};
struct SafeMove {
int value;
char path;
explicit SafeMove(int input) : value{input}, path{'I'} {}
SafeMove(const SafeMove& other) : value{other.value}, path{'C'} {}
SafeMove(SafeMove&& other) noexcept : value{other.value}, path{'M'} {}
};
int main() {
PotentiallyThrowing first{4};
SafeMove second{4};
PotentiallyThrowing copied{std::move_if_noexcept(first)};
SafeMove moved{std::move_if_noexcept(second)};
std::cout << copied.path << ' ' << copied.value << '\n';
std::cout << moved.path << ' ' << moved.value << '\n';
}
第一种类型的移动体虽然只有简单赋值式初始化,但声明没有noexcept,而且能复制,因此helper给下一步提供const引用,copied.path为C。第二种类型的移动声明有noexcept,下一步选中移动,moved.path为M。两者的整数都为4,输出C 4和M 4。
只删除SafeMove移动构造上的noexcept,保留其它代码,第二行应变成C 4。函数体没有因此增加一次throw;变化的是类型提供给选择机制的承诺。不能用“我跑过一次没抛异常”替代这个承诺。
这也解释了vector扩容为何需要考虑元素的异常保证:若搬动到一半失败且旧元素已经改变,维持原状态就会困难。可用的复制常能支持保留原内容;若只能使用可能抛出的移动,某些操作的保证会变弱。本例通过显式move_if_noexcept验证选择合同,没有测量或保证某个vector实现必定以固定次数调用它。
析构策略:把可报告失败放到显式操作里
析构通常应该不抛出。对没有显式异常说明的普通析构,能否抛出与基类/成员析构合同有关,不能说所有析构无条件都相同。如果另一个异常正在展开时,清理过程中又有异常逃出,程序会终止,外层恢复路径就无法正常完成。
需要报告关闭或提交失败时,应提供正常调用的显式操作,让调用者在对象还活着时处理结果;析构负责符合其合同的兜底清理。这里没有实现文件关闭协议,只建立选择原则。Trace输出只是固定环境的观察手段,不能照搬成“生产析构中任何I/O都不可能失败”的保证。
四步练习阶梯:先判断合同,再交付一个小更新函数
- 预测:不运行,给0、12、12x与空文本分别写“有值/无值”和结果,尤其解释0为何是成功。
- 补全:遮住strict-parse的拒绝条件,补齐错误码、停止位置与±1,000,000范围检查。−1000000接受;1000001、3x与空文本拒绝。
- 找错:在prefix-bug中只补完整消费检查,保留合法12的成功。记录原来为何错误接受12x,以及修复后在哪个条件拒绝。
- 迁移:关掉参考,从空文件写严格整数配置更新。每个独立测试从7开始:0更新为0,12x或1000001失败并保持7。再独立写一个拥有Trace9的函数,让异常传播到外层catch,说明为何到达catch之前已经销毁一次真实对象。
自己的实现完成后,再对照update-integer与unwind-owner。能说出“解析成功”与“完整消费”各检查什么、失败后哪些对象改变、谁负责清理,才是在解释程序;编译成功、打开答案或读完本页都不能自动代替G0。
先留下自己的答案
两道动手练习
先完成正文的练习阶梯,再用下面两题检查自己的解释。先写预测或代码;需要时展开提示,完成后再对照答案。
练习 1
prefix-bug把12x和12都输出为value 12。只扩充现有错误判断,要求完整消费输入,并预测两行修复后的输出。
查看提示
- parsed.ec报告解析状态,parsed.ptr指出首个未被消费字符。
- 输入尾后位置是text.data()+text.size(),不要通过删掉12x测试来隐藏错误。
对照答案与推理
把条件扩充为parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()。输出invalid和value 12:12x的结束位置停在x处,12的结束位置恰好到尾后。
练习 2
noexcept-move中只删除SafeMove移动构造函数上的noexcept,保留两类的复制构造函数及实际函数体。再次由main显式调用move_if_noexcept,预测两行路径和值。
查看提示
- 判断接口上的不抛承诺,不是观察本次函数体似乎不会出错。
- 两个类型仍都有可用的复制构造函数。
对照答案与推理
输出C 4和C 4。删除承诺后SafeMove也不满足不抛移动构造条件,而且可复制,因此move_if_noexcept提供const引用形式,选择复制构造。这里不执行throw或terminate,也不据此断言任意容器实现的内部选择。
关掉参考,再做一次
把理解说出来
先完成正文的独立迁移,再回答下面六题。它们只检验第16.1–16.6节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。
失败合同与合法零
failure-value为什么将bool与输出整数分开?optional<int>{0}的has_value和条件判断分别是什么?
对照推理与英文回答
bool说明这次操作是否接受候选,整数保存成功结果;失败时本例不写输出,所以旧值7保持。optional<int>{0}有值,has_value和条件判断都为真,不能把保存的0当成缺失。
The boolean reports acceptance while the integer carries the value. This function leaves the output unchanged on failure. An optional containing zero is engaged, so both has_value and its condition are true.输入预测
给两个真实输入程序分别提供12x后换行、单独换行后结束输入、直接EOF,numeric-input与line-input会怎样?
对照推理与英文回答
数字提取对12x先成功读出12并留下x;单独换行后遇EOF和直接EOF都没有可提取整数,输出input-failed。getline分别取得长度3的12x、长度0的有效空行,以及没有行;最后一种输出no-line。各程序独立启动,不混用提取后的残留换行。
Integer extraction accepts the numeric prefix of 12x. A blank line followed by EOF and immediate EOF provide no integer. Getline instead reads a three-character line, an empty but valid line, or no line. Each case starts a separate process.严格解析找错
为什么from_chars返回成功还必须检查结束指针?业务上界与int表示范围是同一项检查吗?
对照推理与英文回答
成功可能只表示匹配了数字前缀,12x的结束指针停在x处,完整输入合同应拒绝它。业务范围±1000000比本机int表示范围窄,1000001可解析却不满足业务条件;超int长数字则在解析状态处失败。三个条件不能互相替代。
Successful conversion can stop before a suffix, so full consumption requires checking the returned pointer. The application limit is separate from the representation limit. A value may fit int but violate the application range, while an unrepresentable number fails conversion.传播与部分构造
propagation为何没有outer after?construction-failure为何会destroy 9却没有ready或destroy owner?
对照推理与英文回答
inner抛出后普通流程被中断,外层函数不继续执行outer after,而向匹配处理器传播。Owner的resource先构造完成,后续part构造抛出时会清理已完成的resource,所以Trace 9销毁;整个Owner未完成构造,不进入构造函数体,也不调用整个Owner的析构函数。
Throwing interrupts the ordinary path, so outer after is skipped. During failed Owner construction, the already constructed resource member is destroyed. Owner never finishes construction, so neither its constructor body nor its complete-object destructor runs.准备后提交与异常保证
prepare-commit怎样让负数候选失败后保留[1,2]?update-integer又怎样让12x失败后保持7?这能证明任何更新都有强保证吗?
对照推理与英文回答
vector候选先独立构造并检查,发现负数在swap之前抛出,因此current未变;验证通过才在本例默认分配器vector之间swap,之后candidate清理旧内容。整数更新先得到受限optional,只有有值才赋给current。强保证是针对这一操作的失败后状态承诺,需要逐个潜在失败点检查,不能由例子推广到任意修改。
The vector candidate is prepared and validated before swapping with current. The integer update assigns only after obtaining an accepted optional value. These operations preserve the old state along their shown failure paths. A strong guarantee must be established for the particular operation and its possible failure points.noexcept与明确移动选择
noexcept会捕获或修复异常吗?本例move_if_noexcept为什么分别记录C与M,移动函数里为何没有直接打印日志?
对照推理与英文回答
noexcept承诺异常不会逃出;违反承诺会终止,而不是被这个关键字捕获。两个类型都可复制,第一类移动没有不抛承诺,显式move_if_noexcept提供const引用形式从而复制;第二类有承诺,提供右值引用形式从而移动。路径先记在普通成员里,main再输出,避免把可能抛的I/O塞进不抛移动体。
Noexcept is a promise, not a handler; letting an exception escape violates it and terminates the program. Move_if_noexcept supplies a const reference for the copyable first type and an rvalue reference for the nothrow-movable second type. The constructors record a path member, and main performs the potentially throwing output.和正文是同一份源码
示例文件
先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。
- failure-value.cpp成功状态与输出数值分开
- optional-values.cpp有值的零与没有值
- numeric-input.cpp真实输入流:先检查提取是否成功
- line-input.cpp真实getline:空行与没有行不同
- strict-parse.cpp完整例一:从完整文本得到受限整数
- prefix-bug.cpp安全逻辑反例:只检查错误码
- propagation.cpp普通调用被异常传播中断
- unwind-owner.cpp完整例二:到catch之前清理真实对象
- construction-failure.cpp后续成员构造失败时清理已完成成员
- prepare-commit.cppvector候选通过验证后再交换
- update-integer.cpp独立迁移:失败时旧整数保持7
- noexcept-move.cpp按明确承诺选择可复制或可移动表达式
下载清单来自本章元数据,正文代码与下载同源。需要标准输入的程序,请先读16.3的输入操作,按例子给定的文本、换行和输入结束方式运行;程序等待输入不等于卡死。正常运行、预期拒绝和只作分析的路径按各例明确说明区分,不把无输出自动当作成功。
可选的学习反馈
记下你真正花的时间
每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。
记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。
尚无真实试学用时。
换设备:导入记录,或取回损坏的旧记录
导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。
阅读记录与课程验收分别保存。
本章资料与查证
- WG21 N4861:C++20工作草案 ↗
固定2020-04-01版本。16.2查[optional.observe]、[optional.nullopt];16.3查[charconv.from.chars]、[istream.formatted.arithmetic]、[string.io];16.4/16.5查[except.throw]、[except.handle]、[except.ctor]、[unique.ptr.single.dtor]及[vector.capacity];16.6查[except.spec]、[except.terminate]与[forward]。
- N4861精确位置:optional访问 ↗
16.2:[optional.observe],文内页589、PDF第597页起;has_value、条件转换、解引用、value与value_or的合同。
- N4861精确位置:整数from_chars ↗
16.3:[charconv.from.chars],文内页720、PDF第728页起;停止指针、错误码、字符模式与整数表示范围。
- N4861精确位置:异常展开与部分构造 ↗
16.5:[except.ctor],文内页435、PDF第443页起;已构造对象清理与构造失败。noexcept与move_if_noexcept仍查同一固定草案[except.spec]、[except.terminate]、[forward],vector交换查[vector.capacity]。
本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。