C++ / a working model

95 / 103   ·   C++17   ·   约 12 分钟

错误边界:报告失败不等于处理失败

先记住这句话

解析层报告它能证明的错误,业务层才决定如何响应。用一个金额解析接口区分发现、传播和翻译,保留异常的动态类型,并让失败结果不携带伪造金额。

本篇内容
  1. 先决定谁有足够信息
  2. 传播时保留事实,翻译时添加语义
  3. 现代接口仍然需要边界证明
  4. 运行示例
  5. 动手练习
READING EVIDENCE / 已读完整正文

C++ Coding Standards: 101 Rules, Guidelines, and Best Practices

已取得第一版完整 PDF,顺序读完 0–100 全部 101 条的正文(Summary、Discussion、Examples、Exceptions 与 References),并逐页直接查看嵌入代码图像所在的全部 80 个实质页,补齐文本提取留下的代码空白。另交叉阅读官方 Items 1、25、73、74、83。汇总与索引不计正文。auto_ptr、动态异常规范、旧式适配器等仅作历史语境;原创示例采用 C++20,不照搬旧代码。

查看版本、实际阅读范围与原文入口 →

先决定谁有足够信息

读到 Item 74 时,值得追问的不是“哪里可以写 catch”,而是“这一层能做什么决定”。底层解析器只能判断文本是不是非负整数,不能替调用者决定坏输入是否等价于零。把失败当成零会把损坏数据变成一笔真实业务记录,程序表面上继续运行,契约却已失效。

例子的 parse_cents 发现格式或范围错误便抛出 ParseError。它不输出日志、不重试,也不读取外部状态;调用者可以独立决定如何展示失败。返回 int 表示成功,异常表示没有金额产生,两条路径不能混为一谈。

传播时保留事实,翻译时添加语义

中间层如果没有恢复动作,通常不需要 catch。这里刻意保留一次按 const 引用捕获再裸 throw 的转发,以便断言演示动态类型仍是 ParseError;实际项目应删除没有作用的转发层。写成 throw e 会创建一个由表达式静态类型决定的新异常,可能丢掉派生类信息。

业务边界 submit 把已知解析失败翻译成 Status::bad_amount,并让 optional 金额为空。它只捕获自己能够解释的 ParseError,而不是用 catch(...) 吞掉内存不足等无关故障。错误翻译不是抹平差异,而是把调用者真正需要的语义放进接口。

现代接口仍然需要边界证明

Item 73 的按值抛出、按引用捕获原则在现代 C++ 仍然适用;C++17 的 from_chars 和 optional 则是本课新增工具,不是 2005 年书中已有设施。from_chars 不保证消费整个输入,所以必须同时检查错误码和结束指针;否则 12x 可能被错当成 12。

示例同时检查合法输入、尾随垃圾和整数溢出。assert 用于证明本例约定,不是线上输入校验的替代物:即使禁用断言,parse_cents 内的分支仍会拒绝错误。若改用 C++23 expected,仍应保持同样的成功与失败边界,而不是因为换了容器就认为错误策略已经完成。

容易答错的地方

  • 只检查 from_chars 的 ec 而不检查 ptr,会接受带非法后缀的部分输入。
  • catch(const std::exception& e) 后使用 throw e; 可能切掉派生异常;重新抛出当前异常使用 throw;。

运行一个例子

最低标准 C++17 · 完整程序 · 下载 .cpp

#include <cassert>
#include <charconv>
#include <optional>
#include <stdexcept>
#include <string_view>
#include <system_error>

struct ParseError : std::runtime_error {
    using std::runtime_error::runtime_error;
};

int parse_cents(std::string_view text) {
    if (text.empty()) throw ParseError("empty amount");
    int cents = 0;
    const char* end = text.data() + text.size();
    const auto result = std::from_chars(text.data(), end, cents);
    if (result.ec != std::errc{} || result.ptr != end || cents < 0)
        throw ParseError("invalid amount");
    return cents;
}

int preserve_error(std::string_view text) {
    try { return parse_cents(text); }
    catch (const std::exception&) { throw; }
}

enum class Status { accepted, bad_amount };
struct Receipt { Status status; std::optional<int> cents; };

Receipt submit(std::string_view text) {
    try { return {Status::accepted, parse_cents(text)}; }
    catch (const ParseError&) { return {Status::bad_amount, std::nullopt}; }
}

int main() {
    const auto good = submit("1250");
    assert(good.status == Status::accepted && good.cents == 1250);
    const auto bad = submit("12x");
    assert(bad.status == Status::bad_amount && !bad.cents);
    assert(!submit("9999999999999999999999999999999999999999").cents);
    bool preserved = false;
    try { (void)preserve_error(""); }
    catch (const ParseError&) { preserved = true; }
    assert(preserved);
}

在本地编译

g++ -std=c++17 -Wall -Wextra -Wpedantic -pthread books-cpp-coding-standards.cpp -o example && ./example

预期结果

预期:正常退出、无输出;所有 assert 通过。

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

让金额 0 也成为业务错误,但不改变底层解析器对合法整数的定义。修改哪里?

查看参考答案

在 submit 调用 parse_cents 后保存结果;若 cents == 0,返回 {Status::bad_amount, std::nullopt},否则返回成功。parse_cents("0") 仍得到 0,因为零是合法整数;业务是否接受零由边界决定。增加 submit("0") 失败且 parse_cents("0") 成功的断言。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录