拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++异常处理最佳实践:从线上事故到工程化落地

C++异常处理最佳实践:从线上事故到工程化落地

聊C++异常处理最佳实践之前,我先说说自己踩过的一个大坑:我曾维护过一个任务调度服务,代码里到处是try/catch,自我感觉“异常处理挺完善”,结果一次线上数据错乱事故的根子,恰恰就藏在一个被catch(...)吞掉的std::bad_alloc里。从那天起我才真正意识到,C++异常处理这件事,很多人(包括当时的我)的认知都停在“别让程序崩掉”这个层面,完全没有进入工程化的范畴。异常处理是接口契约、是资源安全、是可观测性,甚至在性能敏感场景下还得算清楚成本。这篇内容我会结合自己实际维护过的服务端项目,把异常处理从原则到实操完整过一遍,尽量让不同基础的读者都能直接拿去用。

1. 从一次事故说起:捕获异常不等于处理异常

1.1 那次事故的完整排查链

先说背景。那个服务是多线程消费任务队列,每个任务会调用一个第三方二进制库做数据处理。服务稳定跑了大半年,某天突然有用户反馈一部分任务“显示成功但结果明显不对”。更奇怪的是,这批任务重跑一遍结果又正常了。

我最初的怀疑方向是数据竞态、共享状态污染这类问题。查了两天,最后才在日志里看到一条线索:处理失败的任务入口处打印了unknown exception。再去翻代码,发现任务入口长这样:

void processTask(Task& task) { try { auto result = callThirdParty(task.data); task.status = TaskStatus::Success; task.result = result; } catch (...) { task.status = TaskStatus::Success; // 当年写这行代码的人想表达“兜底” logError("unknown exception"); } }

问题一下就清楚了。第三方库在高峰期内存分配失败,抛了std::bad_alloc,被catch(...)接住。但代码里callThirdParty在返回之前实际上已经把一部分数据写进了中间文件,这行“兜底成功”的代码直接把任务标成了成功。下游拿到的结果自然是不完整的。

这个case最扎心的地方在于:写catch(...)的初衷是想“确保任务不崩”,结果反而制造了最隐蔽的脏数据源。异常处理从来不是“不崩就行”,而是“状态必须一致”。

1.2 从事故里提炼的第一性原则

那次事故之后,我给了自己三条规矩,这几条后来几乎覆盖了所有C++异常处理场景的决策:

  • 捕获异常时,确认自己能恢复状态再捕获,否则就该让它继续往上抛。恢复状态的意思是:所有的局部资源都释放了,所以前的对象处于合法状态,错误信息已经记录。
  • catch(...)只允许在明确知道自己必须兜底所有异常的地方出现,比如线程入口、main函数入口,并且兜底动作必须是“不可恢复则记录现场并终止该任务”,而不是标记成功继续跑。
  • 每个catch块的第一行应该是“记录这条异常本身”,而不是直接敲业务逻辑。我们后来统一用exception_ptr把原始异常转存到日志队列里,避免在catch里调用可能再抛异常的日志函数。

事故处理完后我们把服务里所有catch(...)和catch(std::exception&)的语义全部过了一遍,按“能否恢复状态”重新分类,改完线上异常日志的排查效率明显提升。这也是我写这篇文章的原始动力——想把这些年在异常处理上踩过的坑,系统性地整理成可用清单。

2. 异常安全的三个级别:基本保证、强保证与不抛保证

2.1 三个级别分别保证什么

C++社区对异常安全做过经典分类,任何一个函数或者操作都可以按这三个级别去要求自己:

  • 基本保证(basic guarantee):如果抛出异常,对象处于合法但不确定的状态。所有内部资源没有泄漏,对象可以被继续赋值、析构,但具体内容可能已经是一半新一半旧。
  • 强保证(strong guarantee):如果抛出异常,程序状态和调用前完全一致。要么完全成功,要么等于没发生过。也叫“事务语义”或“提交或回滚”。
  • 不抛保证(nothrow guarantee):操作不可能抛出异常。比如int的拷贝、std::swap这些。

很多团队对基本保证的理解不够准确。他们觉得“不泄漏就不错”,但基本保证其实是很弱的承诺。举例来说,一个容器类对象在insert失败后,迭代器是否失效都可能不再被保证。如果调用方还指望继续使用这个对象做后续操作,你至少要保证它能被正常析构和重新赋值。这也意味着资源所有权必须明确。

实践中我的经验是:对外提供的关键API,至少要承诺基本保证;如果这个操作“要么全做,要么全不做”的语义对业务很关键,就得上强保证。标准库自己的容器也遵循这个逻辑,比如std::vector::push_back做强保证,std::map::operator[]在插入时只做基本保证。

2.2 用copy-and-swap写出强保证的代码

强保证最容易落地的实现方式就是 copy-and-swap。思路非常朴素:所有有风险的操作都在一个临时副本上完成,等副本完全构造好以后,再用一个不会抛异常的操作把它替换进去。替换操作通常就是std::swap。

class Config { public: Config() = default; void update(const std::string& path) { Config tmp; // 1. 先在副本上执行所有可能失败的步骤 tmp.loadFromFile(path); // 文件不存在、解析失败都只影响 tmp swap(tmp); // 2. swap 承诺不抛异常,安全提交 } void swap(Config& other) noexcept { data_.swap(other.data_); } private: std::map<std::string, std::string> data_; }; // vector<Config> 里自定义类型做强保证也常用这套 void updateAll(std::vector<Config>& cfgs, const std::string& path) { auto copy = cfgs; // 先整体拷贝一份 for (auto& c : copy) { c.update(path); // 即使中途抛异常,cfgs 依然是原状 } cfgs.swap(copy); // 全部成功后再一次性提交 }

写这段代码的人需要注意一个关键点:loadFromFile里的任何失败都不会污染this。这就是强保证。很多新手会在update里直接改成员,改到一半抛异常,然后想用try/catch把成员改回去——那种回滚代码往往比原逻辑还容易出错。copy-and-swap避免了所有回滚逻辑,因为根本没有发生过修改。

2.3 RAII与不抛保证:让清理由类型自己完成

不抛保证很多时候依赖析构函数,而析构函数在异常处理里有个特殊地位:它在栈展开(stack unwinding)阶段被调用,如果它自己再抛异常,程序只能调std::terminate。所以一个工程纪律是——析构函数默认不应该抛异常,或者至少要在内部消化所有异常。

这套思想具体落到资源管理上就是RAII:资源的获取放进构造函数,资源的释放放进析构函数。对象生命周期结束,资源必然释放,不需要业务代码处处写try/catch做清理。

class RawFileWrapper { public: RawFileWrapper(const char* path, const char* mode) : fp_(std::fopen(path, mode)) { if (!fp_) { throw std::runtime_error(std::string("cannot open file: ") + path); } } ~RawFileWrapper() { if (fp_) { std::fclose(fp_); // fclose 失败也没办法在析构里大动干戈 } } RawFileWrapper(const RawFileWrapper&) = delete; RawFileWrapper& operator=(const RawFileWrapper&) = delete; private: std::FILE* fp_ = nullptr; };

凡是封装过资源的人都知道,这种写法最大的好处是:就算中间某步抛异常,RawFileWrapper的析构函数还是会被自动调用。栈展开是异常处理最强大的能力之一,它保证“每一层已经构造好的本地对象都会被正常析构”。也就是说,你只要遵守“资源由对象管理,析构不做有风险动作”,异常安全的一半问题就自动解决了。

这里顺带说一个我对“最佳实践”的理解:最佳实践不是让你写更多try/catch,而是让你写出根本不需要try/catch的代码。能用RAII解决的问题,就不该靠手工catch来恢复。

3. 构造函数与析构函数:异常最容易击穿的两个位置

3.1 构造失败时的资源清理问题

构造函数抛异常有个非常反直觉的地方:如果对象构造失败,析构函数是不会被调用的。因为对象本身“从未完整存在”。但是,已经构造出来的成员变量变量和已经初始化的基类子对象会正常析构。这导致了经典的坑:如果你在构造函数里手工new了多个资源,第一个new成功、第二个new失败,前面那个资源就会泄漏。

// 反面教材:第二行 new 抛异常时,ptr1_ 不会自己释放 class BadExample { public: BadExample() : ptr1_(new int(1)), // ok ptr2_(new int(2)) {} // 这里抛异常,ptr1_ 无人清理 private: int* ptr1_; int* ptr2_; };

C++标准规定,构造函数的函数体(body)抛出异常时,已经构造完成的成员会被析构。但上面两个成员变量都是裸指针,裸指针没有自定义析构,所以ptr1_指向的内存就永久泄漏了。正确做法是成员变量直接用std::unique_ptr、std::shared_ptr、std::string、std::vector这些东西。

class GoodExample { public: GoodExample() : data_(std::make_unique<int[]>(1024)) {} private: std::unique_ptr<int[]> data_; };

这个规则的底层逻辑是:构造函数本身没有返回值,它没法像普通函数那样返回错误码。所以异常几乎是对外报告构造失败的唯一通道。既然用了异常这条路,你就必须保证它在失败时不留任何后遗症。

3.2 析构函数隐式noexcept带来的terminate风险

很多人不知道一件事:C++11之后,析构函数默认是noexcept的。也就是说,如果你没显式写,编译器会认为它不抛异常。一旦它真的抛了,程序直接终止。这和“析构函数在栈展开期间再抛异常会调terminate”的规则叠加,形成两条不可触碰的红线:

  • 析构函数主动抛出异常,绝大多数情况下都会导致std::terminate。
  • 栈展开过程中,多个对象依次析构,任何一个析构抛出异常,同样直接终止。

所以工程上的做法非常明确:析构函数里不要做可能抛异常的操作。如果一定要做(比如刷盘、关闭数据库连接、释放锁),就把异常吞了或者在局部try/catch中消化。

class LockGuard { public: explicit LockGuard(std::mutex& mtx) : mtx_(mtx) { mtx_.lock(); } ~LockGuard() { try { mtx_.unlock(); } catch (...) { // 析构期间绝不允许异常逃逸,确实发生了也记录后吞掉 } } private: std::mutex& mtx_; };

我见过一个很现实的问题:有人觉得吞掉异常“不道德”,于是让析构函数里的unlock()或flush()失败后抛出去。结果线上服务频繁terminate,core dump里全是析构调用链。记住,析构函数里的失败通常意味着你已经无法安全恢复现场,最好的结果就是记录一个错误,然后让对象按正常方式销毁。

3.3 函数try块与双阶段构造的实际意义

C++里还有一类特殊语法叫函数try块(function-try-block),可以在构造函数的初始化列表阶段捕获异常:

class DatabaseConnection { public: DatabaseConnection(const std::string& connStr) try : conn_(connect(connStr)) { // 构造函数函数体 } catch (...) { // 初始化列表抛出的异常在这里可被记录 // 注意:异常必须重新抛出,否则会被视为构造函数成功 logError("DatabaseConnection init failed"); throw; } private: Connection conn_; };

这个语法在实践中有用,但它的限制很多:catch块访问不了这个对象的成员,因为对象还没完全构造;你也必须在catch块末尾重新抛出异常,否则编译器会认为构造函数成功,随后析构一个不完整的对象。我个人把函数try块当作“最后一层诊断日志”,而不是修复逻辑的地方。

双阶段构造(先构造无异常的基础阶段,再通过init之类的方法做可能失败的初始化)在特殊场景里仍有价值,比如某些嵌入式环境禁用异常。但在常规服务端开发里,我建议优先让依赖在构造函数里就绪,而不是把对象生出来再慢慢初始化。因为后者会让调用方每次都必须处理“对象存在但不可用”的中间状态,反而埋下一致性隐患。

4. noexcept不是性能关键词,是接口契约

4.1 noexcept的编译期与运行时行为

noexcept从C++11开始成为异常说明的主要手段,C++17之后旧的动态异常说明throw(...)被彻底移除。一个函数标记noexcept,意味着你向编译器和调用方做出了“不会抛出异常”的承诺。

这个承诺的代价也极其严重:如果标记了noexcept的函数真的把异常抛出去了,程序不会像普通未捕获异常那样沿调用栈找catch块,而是直接调用std::terminate。换句话说,noexcept不是“优化建议”,是“如果违约程序就死给你看”的硬约束。

理解这一点以后,你就不应该把noexcept当成一个可有可无的性能开关。它是接口契约的一部分。调用方看到noexcept,就可以放心在析构、swap、移动操作等对异常敏感的场景里调用。而编译器也能基于这个信息做优化,比如不再为这个函数生成异常展开表对应的额外路径。

4.2 哪些函数值得标记noexcept

实践中我有一套自己的判断标准,按值得程度从高到低排列:

  • 析构函数:即使你不写,它也是隐式noexcept。明确写出来让人更容易审查。
  • swap函数:强保证实现的基石,必须是noexcept,否则copy-and-swap的“最后一步”就不再安全。
  • 移动构造函数和移动赋值运算符:如果成员本身移动不抛,就标记noexcept。这一步做不好,标准库容器会非常难受。
  • 纯查询类函数:比如empty()、size()、简单的getter。
  • 小计算函数:没有内存分配、没有外部调用,逻辑上不可能失败。

这里深挖一下移动构造。标准库的std::vector在扩容时有一个细节:如果元素类型的移动构造函数是noexcept,扩容时就会用移动而不是拷贝。如果不是noexcept,为了保持强保证,vector宁可拷贝元素也不移动,因为拷贝失败了可以保证原容器不被破坏,而移动一旦中途失败无法“复原”。这就是std::move_if_noexcept存在的原因。写成代码就是:

class Token { public: Token(Token&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; } private: void* handle_ = nullptr; };

如果你实现了移动构造,又忘了标noexcept,你的自定义类型在std::vector里的表现可能比预想中慢得多,因为容器会退回拷贝路径。这个问题不会报错,只能靠性能分析和代码审查去发现。

4.3 条件noexcept与模板场景

模板场景下,你往往不能凭空承诺noexcept。比如写一个通用的包装器,能不能保证移动不抛,取决于模板参数的移动是否不抛。C++17之后可以直接表达这种“条件承诺”:

template<typename T> class Box { public: Box(Box&& other) noexcept(std::is_nothrow_move_constructible_v<T>) : value_(std::move(other.value_)) {} private: T value_; };

条件noexcept让模板作者可以把“依赖参数的性质”翻译成对调用方的契约。这样vector对Box<T>做扩容时,编译器就能正确判断应该拷贝还是移动。这也是现代C++里泛型库作者必须掌握的细节。

工程上还有一个很常见的误解:有人觉得把noexcept加满整个项目代码库,性能就好。不是的。noexcept真正的价值是让调用方敢于在关键路径上做决策。如果你把一个可能分配内存、可能打开文件、可能抛用户异常的函数标成noexcept,那只是在埋terminate的地雷。

5. 设计一套能自助诊断的异常类型体系

5.1 异常类型怎么设计:继承、字段与what()

工程里很少有人直接抛std::runtime_error的裸字符串,因为打印日志的时候只能看到一个孤零零的message,完全不知道是哪个模块、哪个操作、哪个输入参数触发的。一套好的自定义异常类型,应该让运维和下游开发在拿到异常对象的第一时间就能定位问题。

我习惯的做法是统一继承std::runtime_error,然后增加业务相关的结构化字段:

class ConfigLoadError : public std::runtime_error { public: ConfigLoadError(std::string path, int line, std::string msg) : std::runtime_error(buildMessage(path, line, msg)), path_(std::move(path)), line_(line) {} const char* what() const noexcept override { return std::runtime_error::what(); } const std::string& path() const noexcept { return path_; } int line() const noexcept { return line_; } private: std::string path_; int line_; static std::string buildMessage(const std::string& path, int line, const std::string& msg) { return "config load error, path=" + path + ", line=" + std::to_string(line) + ", msg=" + msg; } };

这里有个技术细节:what()在标准里被声明为noexcept,它不能返回一个临时构造的字符串,所以我们通常只在构造函数里拼好完整字符串,what()直接转发已有的内部存储。这样既满足契约,又避免在异常处理路径上再分配内存。

异常对象应该携带多少信息?我的经验是“够现场排查,但别把整个数据结构都塞进去”。路径名、行号、错误码、关键参数值都属于高价值信息;大容器的完整内容、堆栈快照抓到日志系统里更合适。异常对象在抛出和捕获过程中会经历拷贝甚至多次拷贝,塞太多东西会直接拖慢异常路径。

5.2 嵌套异常与异常链:把上下文一路带上去

异常传播的最大痛点是上下文丢失。底层函数抛了一个std::runtime_error("file not found"),中间层也不知道是哪个配置文件触发的,顶层就更无从下手。C++标准库提供了嵌套异常工具,可以把“当前栈帧的上下文”和“底层的具体异常”打包在一起。

try { loadConfigFromPath(confPath); } catch (...) { std::throw_with_nested( std::runtime_error("failed during system config load, conf=" + confPath) ); }

顶层诊断时,可以递归地把整个异常链展开打印:

void printRecursive(const std::exception& e) { std::cerr << e.what() << '\n'; try { std::rethrow_if_nested(e); } catch (const std::exception& nested) { printRecursive(nested); } catch (...) { std::cerr << " [unknown nested exception]\n"; } }

嵌套异常这套机制非常适合“长调用链 + 多层适配”的架构。我参与过的网关项目里,最外层catch统一走这个递归打印,日志从顶层到底层一层层展示,定位问题的时间从小时级降到了分钟级。

5.3 用exception_ptr跨线程传递异常现场

多线程程序里有个更麻烦的场景:工作线程出了异常,你希望它不影响整个进程,还要把异常原封不动交回主线程处理。C++11的std::exception_ptr就是干这个的,它像一个异常对象的shared_ptr,可以在线程之间传递。

std::exception_ptr captured; void worker() { try { doRiskyWork(); } catch (...) { captured = std::current_exception(); // 捕获当前异常现场 } } // 主线程 std::thread t(worker); t.join(); if (captured) { try { std::rethrow_exception(captured); } catch (const std::exception& e) { // 在这里统一处理,完整的异常类型和堆栈信息都还在 logError(e.what()); } }

注意,std::exception_ptr可以跨线程、跨异步链传递,并且会保存异常类型和嵌套异常的全部数据。比在线程里直接打印日志然后吞掉要健壮得多。任务线程池场景下,我通常会配合std::future使用——std::future的get()天然会把异常重新抛出,拿到task对应的异常时已经带上了原始现场信息。基于后者做异步任务封装,可以少写很多手工exception_ptr搬运代码。

6. 错误码、optional与异常:按接口边界取舍

6.1 两大类场景的判断标准

很多新人容易走入两个极端:一个是“整个项目统一用异常,业务逻辑也靠throw控制流程”,另一个是“看到网上说异常慢,所以全面禁止,所有函数都返回错误码”。这两种做法在工程上都很拧巴。

我的取舍标准非常直接,按下面这个逻辑看:

  • 调用方是否能从当前上下文中将失败情况恢复,并且恢复逻辑是业务的主要分支?比如校验用户输入格式不对、请求的url不合法,这类“正常业务分支里能预见到的失败”,用bool、错误码、std::optional或std::expected都更自然。
  • 失败是否表示“当前接口无法履行约定”但调用设身处地地需要向上层抛出?比如构造函数初始化失败、数据文件格式错误、资源耗尽,这类情况用异常。

举个具体例子。解析配置文件的场景里,文件不存在但系统有一套默认配置可用,那文件打开失败就是你业务可以预期的分支,返回错误码或者optional非常合适。但如果配置文件中某个关键字段格式完全错误,程序根本没有合理的默认行为,此时抛异常向上传递是最清晰的表达。

6.2 库接口设计中容易糊涂的地方

如果你在设计一个被其他人调用的库,异常处理的选择会直接影响调用方的代码风格。这里有几个我在实际项目中踩过的坑:

  • 不要把异常作为“模块内部控制流”跨公开API边界抛出。特别是当你的库允许用户关闭异常编译选项时(例如在嵌入式环境里通过-fno-exceptions编译),你必须提供非异常的错误报告路径。
  • 对于“查询型接口”,比如find、lookup,不要因为“没找到”就抛异常。没找到是正常的查询结果,不是系统错误。
  • 对于“命令型接口”,比如insert、save、execute,如果失败意味着对象状态发生了不可预知的变化,那么必须通过异常或返回值向调用方说明,绝不能默默吞掉。

工程上很多团队会规定“跨模块边界尽量用错误码,模块内部可以用异常”。这样做的逻辑是:模块和模块之间经常有DLL/SO边界,跨动态库抛C++异常在不同编译器、不同标准库实现下存在ABI兼容风险。这个问题在Windows上尤其需要小心,MSVC工程的异常处理模型和MinGW就可能对不上。如果你无法控制所有编译单元使用同一套工具链,跨模块边界传错误码或结构化的错误结构体,比赌异常能正常工作安全得多。

6.3 C++23之后的新选择:std::expected

C++23引入的std::expected是一个很好的中间态:它既能携带一个期望值,也能携带一个错误对象,语义上比错误码丰富,又不需要经过异常传播路径。

std::expected<Connection, std::string> createConnection(const std::string& connStr) { auto conn = rawConnect(connStr); if (!conn.isValid()) { return std::unexpected(std::string("invalid connection string")); } return conn; } auto r = createConnection("host=..."); if (!r) { // r.error() 是错误详情 }

我们内部已经在开始试用std::expected做纯业务解析层的接口。它把“可能失败的返回值”写进了类型系统,调用方不容易漏检查。异常仍然保留在那些“失败代表系统级错误”的路径上。这种混合模式我认为会越来越成为服务端C++工程的主流。

7. 性能观察:异常确实是慢的,但慢在哪里要搞清楚

7.1 零成本异常模型

一提到“异常影响性能”,网上最多的争论就是“C++异常到底慢不慢”。先说结论:现代编译器的零成本异常模型(Zero-Cost Exception Model)保证的是“不抛异常时,成功路径几乎没有额外开销”,而不是“抛异常本身很快”。

这套模型的核心原理是:正常执行路径的代码里不插入任何异常检查指令,编译器只额外生成一张异常展开表(exception table),记录每段代码对应“哪些栈对象需要在异常时被析构、跳转到哪个catch块”。程序不抛异常时,这张表只是躺在只读数据段的死数据,CPU流水线完全不受影响。一旦抛出异常,运行时才在异常表里做查找,然后启动栈展开流程。所以异常的慢,慢在“查找表 + 栈展开 + 逐级析构对象 + 匹配catch块”这条路径上。

7.2 实测对比:异常路径不是用来做流程控制的

我在自己的项目里试过一组简单benchmark:一个函数内部做一次可能失败的判断,分别用if返回错误码和throw catch实现,循环1亿次比较成功路径和失败路径的耗时。数值上大致是这样:

路径错误码异常try/catch
成功路径(不触发失败)几乎为零开销几乎为零开销,二者差异多在噪声范围内
每次迭代都触发失败纳秒级微秒级,差距可能拉大到几十倍甚至上百倍

这个结果印证了最关键的结论:异常抛出路径非常昂贵,因为它要遍历栈、调用析构、匹配catch块;但成功路径几乎看不出差异。所以异常完全不适合做“高频业务分支”的判断工具。如果你在一个循环的每个迭代里都靠try/catch判断某个数据是否存在,那性能大概率不可接受。此类场景用if或std::optional是正确的。

7.3 真遇到性能热点怎么办

真需要压榨异常路径性能时,我能给出的经验是:

  • 保持异常对象小而简。异常对象本身会在各个栈帧间“旅行”,越小越好。不要在异常类型里塞矢量、塞字符串大对象。
  • 构造函数里预先拼好what字符串。异常处理路径上再进行字符串拼接、分配堆内存,会让本已昂贵的路径雪上加霜。
  • 把“检查环境合法性”放在调用异常路径之前。比如一个可能因为资源不足而失败的接口,在入口处先做可做的检查,让真正的抛异常只发生在“不可预料的时刻”。
  • 降低热点函数的异常粒度。如果外层循环不能避免调用可能抛异常的接口,可以尝试把每个子任务包一层,让异常不会反复跑整个大循环的栈展开。

工程上我还遇到过一种情况:有人为了“性能优化”在编译选项里加了-fno-exceptions,结果整个依赖某个会把异常作为核心错误通道的第三方库直接编译失败,最后不得不回滚。异常不只是语言层面的语法糖,它是ABI和库契约的一部分。除非你是从零开始搭建一套明确禁用异常的系统,否则用编译选项全局关闭异常不是一个负责任的优化手段。

把异常处理的这些维度写下来之后,回头再看那次任务调度服务的事故,其实当时的代码缺的不是更多try/catch,而是一个明确的契约:什么算可恢复的失败、什么算需要向外传播的系统异常、跨线程怎么保留现场。后来我把前面列的那些RAII、noexcept、自定义异常类型、嵌套异常机制放进组里的编码规范里,新的服务再没出现过“被吞掉的异常变成脏数据”这类问题。做C++项目,异常处理从来不是语法题,而是设计题。

返回列表