异常、错误与不变量
比较异常、错误码和 optional,建立面向库与应用的失败语义。
学习目标
完成本节后,你能区分异常、错误码、optional 和状态对象的适用边界;会写 try/catch、定义不变量和基本异常安全保证;能区分未定义行为与可恢复错误,并为传感器解析、配置加载和机器人节点启动建立可观察的失败路径。
从 Promise rejection 到 C++ 失败语义
try { await read(); } catch (error) {} try { read(); } catch (const std::exception& error) {} JS/TS 常用 throw、Promise rejection 或 Result 风格对象表达失败。C++ 也能 throw,但异常不是一个隐形的返回值:它会沿调用栈寻找 handler,跳过局部作用域时仍会触发 RAII 析构。库接口是否抛异常应该是稳定契约,不能让调用者猜。
反例是把所有失败都 catch(…) 然后继续运行。配置解析失败、消息校验失败和硬件断开可能有完全不同的恢复策略;吞掉异常会让机器人继续使用未初始化参数。另一个反例是把越界、use-after-free 当成“异常情况”,它们是未定义行为,必须用边界检查和 sanitizer 修复。
示例一:解析输入并捕获异常
#include <iostream>
#include <stdexcept>
#include <string>
double parse_range(const std::string& text) {
const double value{std::stod(text)};
if (value < 0.0) throw std::out_of_range("range must be non-negative");
return value;
}
int main() {
try {
std::cout << parse_range("1.25") << "\n";
std::cout << parse_range("-1") << "\n";
} catch (const std::exception& error) {
std::cerr << "invalid input: " << error.what() << "\n";
}
}
运行先输出 1.25,再输出 invalid input: range must be non-negative,程序仍以捕获后的策略结束。catch 使用 const std::exception&,避免切片和复制。真实命令行程序应根据失败类型返回非零退出码;机器人节点则可能切换到 fault 状态并等待重新连接。
示例二:optional 表达“没有结果”
不是每个没有结果都值得抛异常。空传感器窗口是正常情况时,可以返回 optional。
#include <optional>
#include <vector>
std::optional<double> average_valid(const std::vector<double>& values) {
double sum{0.0};
std::size_t count{0};
for (const double value : values) {
if (value >= 0.2 && value <= 5.0) {
sum += value;
++count;
}
}
if (count == 0) return std::nullopt;
return sum / static_cast<double>(count);
}
调用方必须检查 has_value 或使用 value_or,不能直接假设结果存在。optional 适合“可能没有值且原因简单”的情况;如果需要区分空窗口、全部坏点和设备断开,应返回带 error code 或 error message 的状态对象。接口要让失败原因足够可观察。
示例三:保持不变量的更新
class Battery {
public:
explicit Battery(double percentage)
: percentage_{percentage} {
if (percentage_ < 0.0 || percentage_ > 100.0) {
throw std::out_of_range("battery percentage");
}
}
void consume(double amount) {
if (amount < 0.0 || amount > percentage_) {
throw std::out_of_range("invalid consumption");
}
percentage_ -= amount;
}
double percentage() const { return percentage_; }
private:
double percentage_;
};
对象不变量是 percentage 始终在 0 到 100 之间。构造失败时不产生可用 Battery,更新失败时旧值保持不变,这是强异常安全保证的一个简单例子。验证时测试 -1、101、正常消耗和超过剩余电量四条路径,确认 catch 后对象没有半更新状态。
异常安全的三个层次
基本保证表示失败后没有资源泄漏,对象仍可析构;强保证表示操作失败后状态不变;不抛异常保证表示操作无论如何都不抛。RAII 提供资源清理基础,但业务状态仍要通过先验证、后提交或 copy-and-swap 设计。析构函数、noexcept 移动操作和实时控制热路径通常不应抛异常。
错误码和边界层
设备驱动、系统调用和网络库常使用错误码,因为调用者需要决定重试、降级还是停机。可以把底层 errno 或 SDK code 转换成内部 enum class,再在应用边界记录上下文。不要把返回值为负的读取接口直接当成正常数据;错误码与数据值应有不同类型或结构体承载。
编译、运行与排错
用 c++ -std=c++20 -Wall -Wextra -Wpedantic -g 编译,并为每个失败分支写可复现输入。若异常没有被捕获,检查 throw 到 main 的调用链;若 catch 后程序仍使用坏配置,检查是否在 catch 外继续访问未初始化对象;若 sanitizer 报越界或 use-after-free,不要用 try/catch 包住它,因为未定义行为不是业务异常。日志应包含输入来源、错误类别和恢复动作,但不要打印敏感配置。
机器人连接:启动失败与运行时故障
节点启动阶段读取参数、打开串口和加载模型,任一失败都应有明确的退出或 degraded mode;运行阶段收到坏消息时可以丢弃当前消息并保留上一帧,但设备断开通常需要状态机重连。把异常只留在边界层,内部算法尽量使用已验证的数据对象,可以避免每一层都 catch 同一个错误。
对预期输入错误返回可检查结果
不是所有失败都需要异常。用户输入的数字格式错误是预期分支,可以用 std::from_chars 得到可测试的错误类别;这样不会把异常用于常规控制流,也不会误收 12abc 这类尾随字符。下面按 C++17 的整数接口展示检查顺序:
#include <charconv>
#include <string_view>
#include <system_error>
enum class ParseError { None, Empty, Invalid, OutOfRange, Trailing, Negative };
struct ParseResult { int value{}; ParseError error{ParseError::None}; };
ParseResult parseLimit(std::string_view text) {
if (text.empty()) return {0, ParseError::Empty};
int value{};
const auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), value);
if (ec == std::errc::invalid_argument) return {0, ParseError::Invalid};
if (ec == std::errc::result_out_of_range) return {0, ParseError::OutOfRange};
if (end != text.data() + text.size()) return {0, ParseError::Trailing};
if (value < 0) return {0, ParseError::Negative};
return {value, ParseError::None};
}
调用方可以按 ParseError 提示字段、拒绝配置或回到命令行,而不需要解析错误字符串。若失败来自磁盘、设备 SDK 或内存分配,应结合对应 API 的错误约定处理;不要将所有来源强行套进这个解析枚举。
编译运行与边界验证
为 ""、"abc"、"12abc"、负数、合法零和超出 int 范围的十进制文本分别断言错误类别/结果。对有符号和无符号互转使用显式范围检查;把字符串截断、部分解析和数值越界视为不同错误。编译时启用 warning,并在测试中确认每种错误都被调用方处理,不能只确认 parser 返回了某个状态。
异常传播与资源回收
异常穿过栈帧时,局部 RAII 对象仍会按作用域析构;因此文件、锁和临时缓冲区应优先用作用域管理,而不是手写多个 close() 分支。catch 顺序由具体到一般,只在最接近恢复决策的位置捕获;无法恢复的错误带上下文传到应用边界并终止当前操作。析构函数不应抛出第二个异常,硬件安全停机等动作应由显式状态转换完成。
运行验证:错误类别必须被调用方处理
为 parser 写断言时,同时检查成功值与错误枚举:parseLimit("12") 得到值 12,parseLimit("12x") 是尾随字符,空串与超范围分别得到不同错误。然后在 CLI 层把无效输入映射成参数退出码,在机器人节点层映射成明确 fault 状态。测试目标是验证“每个失败都有负责的下一步”,而不只是确认函数没崩溃。
| 失败场景 | 表达方式候选 | 由谁恢复 |
|---|---|---|
| 查询暂时没有样本 | optional | 调用方等待下一帧或跳过 |
| 用户配置格式错误 | 结果类型或异常 | CLI 提示修复字段 |
| 设备返回可重试状态码 | 错误码 | 设备适配器按策略重试 |
| 内部不变量被破坏 | 异常/终止当前操作 | 应用边界记录并隔离 |
同一函数不应同时把“没有样本”伪装成 0、把坏输入转成空对象、再吞掉设备错误。选择的表达方式应让调用方不能无意忽略;需要跨多个语言进程时,把错误类别序列化成稳定 code,不要传异常堆栈当协议。
动手练习
const value = result ?? fallback; const double value = result.value_or(fallback); throw new Error("bad frame"); throw std::runtime_error("bad frame"); 实现 parse_sensor_line:输入形如 range=1.25,status=good 的文本,返回一个带 value、status 和错误信息的结果。分别处理格式错误、非数字、越界和状态未知,并为 CLI 选择非零退出码,为机器人节点选择 fault 状态。
设计一条可观察的失败路径
为传感器配置加载函数选择 exception、optional 还是 error code,并写出四个失败用例的调用方动作。
给我一点提示
先问失败是否正常、是否需要原因、调用方能否恢复;不要用异常替代边界检查。
查看参考答案
格式错误和设备拒绝可以返回带 error code 的结果;空窗口可返回 optional;启动阶段无法恢复的配置错误可抛到应用边界并以非零码退出;所有访问前先完成范围检查。 小结与下一步
本节结论
打开答案后,请把示例改成自己的传感器数据,再用编译器、运行输出和边界输入验证,而不是只对照文字。
异常、optional 和错误码的区别在于失败的频率、原因和恢复责任。下一节把这些语义放进 class 和 interface,学习如何让传感器驱动可以替换、测试和组合。
延伸阅读
先完成本节练习,再用这些资料查阅完整 API 和真实项目组织方式。
阶段共 16 节课,按顺序完成更容易建立完整的迁移模型。