异常这东西。
在 C 的世界里。
它一开始其实很朴素。
函数失败了。
就返回一个值。
-1。
或者塞个 errno。
你再写一层。
就再把这个值往上抛。
一路抛到 main。
人也跟着老了几岁。
后来 C++ 来了。
带着类。
也带着一个更“戏剧化”的东西:异常。
它不走返回值这条路。
它直接跳出当前函数。
去找能处理它的地方。
听起来很爽。
也很吓人。
爽的是。
错误不用层层传了。
吓人的是。
它真的会“跳”。
这玩意儿也不是 C++ 独创。
早些年的 Ada、CLU 这些语言,就已经把“异常”当成一等公民了。
C++ 只是把它带进了一个更接近 C 的世界。
跳错了地方。
线上就啪一下。
先把“异常会跳”翻译成人话
你如果刚从 C 过来,会很容易卡在一句话上:
异常到底是怎么“跳”的?
最接近的类比是 goto。
但它不是你写的 goto。
它是运行时替你做的。
先看一个最小的。
int parse_int(const char* s) {
if (!s) throw 42;
return 0;
}
int main() {
try {
parse_int(nullptr);
} catch (...) {
puts("caught");
}
}
try 的意思是:
我在这里守着。
你要是跳出来,我接住。
catch (...) 的意思是:
不管你跳出来的是啥,我都先抓住再说。
上面 throw 42 很土。
但它足够直观。
它只是想告诉你:你甚至可以抛一个 int。
catch (...) 也真的能抓住。
但“跳”不是瞬移。
它会一路往外退。
退的时候还会做一件很关键的事:把沿途的局收拾干净。
struct Guard {
~Guard() { puts("cleanup"); }
};
void f() {
Guard g;
throw 1;
}
这段代码的直觉是:
哪怕你 throw 了。
局部对象的析构也会跑。
这就是很多 C++ 老代码敢用异常的底气。
当年大家给它起了个名字,叫“栈展开”。
你不用背这个词。
你先记住一句人话就行:
异常往外跳的时候,会顺手把沿路的本地资源清理掉。
这件事跟 C 的差别特别大。
你在 C 里经常会写这种“手动收拾残局”。
FILE* f = fopen(path, "r");
if (!f) return -1;
/* 这里中间只要 return 了,就得记得 fclose */
fclose(f);
在 C++ 里,大家更喜欢把“关闭资源”放进析构里。
这套风格后来有个很出名的名字:RAII。
你也不用背缩写。
你先把它当成一句话:资源的生命周期,交给对象的生命周期。
比如一个最小的“自动关文件”。
struct File {
FILE* f;
explicit File(const char* path) : f(fopen(path, "r")) {}
~File() { if (f) fclose(f); }
};
你用的时候就轻松很多。
你只要保证对象会离开作用域。
文件就一定会被关掉。
这就是异常机制能跟 C++ 的资源管理绑在一起的原因。
但注意。
这也带来一个“更硬的规定”。
坑 0:析构里别抛异常
你刚学异常时,会忍不住想:
那我析构里也 throw 一下行不行?
行。
但你很容易把自己送进 std::terminate()。
struct Guard {
~Guard() { throw 1; }
};
void f() {
Guard g;
throw 2;
}
这段代码的直觉是:
f() 已经在处理“一个异常往外跳”这件事了。
这时候析构又抛了第二个。
两个异常同时在天上飞。
标准库就不陪你玩了。
直接 terminate。
所以你会看到很多老程序员的口头禅:
析构函数里别抛异常。
这不是洁癖。
这是保命。
当年大家的第一反应:给异常加个“条款”
C++98 那会儿,大家想了个很工程化的办法。
既然异常会跳,那我能不能提前写清楚:我最多跳出哪些异常?
于是就有了“异常规格说明”。
也有人叫它动态异常规格说明。
语法写在函数后面,像合同条款。
void f() throw(std::bad_alloc);
意思是:
我最多就抛 std::bad_alloc。
别的别指望我负责。
std::bad_alloc 你可以先当成“内存不够了”。
比如 new 申请内存失败。
标准库就爱用它。
这个想法很朴素。
也很像老派工程师的直觉:把边界写死,大家都安心。
这套设计也不是凭空来的。
它很像 Java 的 throws 列表。
只不过 Java 是“写清楚我可能抛什么”。
C++98 这边更像“我保证只抛这些”。
void f() throws IOException {
}
你一眼就能看出来。
当年大家真的是在试图把异常当接口的一部分。
但这里也埋了一个“语言气质”的差别。
Java 走的是 checked exceptions。
它会逼着你在签名里写 throws。
你不处理,编译器就拦你。
C++ 这边本来也想试。
但很快发现:这条路在 C++ 里走不顺。
你想象一下。
你给一个函数加了 throw(A, B)。
明天你发现还得抛 C。
你改签名。
然后所有调用它的地方,理论上都得跟着重新编译、重新验证。
在“头文件到处 include、模板到处实例化”的 C++ 生态里。
这种设计会让接口变得很脆。
你越想写清楚。
它越像一个会传染的东西。
而且这不是 C++ 一家在痛。
连 Java 圈子里也长期吵。
一个老生常谈的问题就是:版本演进。
今天你加一个新异常类型。
明天一堆调用方就全得改。
还有另一个更难受的点:可扩展性。
系统一层套一层。
每层都要把下面的异常“写进签名”。
最后签名像一张购物清单。
人写不动,也看不动。
坑 1:你以为是约束,其实更像“誓言”
问题是,编译器很难靠它变聪明。
你写了 throw(A, B),并不会自动得到“更强的静态检查”。
更麻烦的是它的违约成本。
如果你说“我只抛这些”,结果你抛了别的,标准库会走一条很古早的路:std::unexpected。
默认情况下,std::unexpected 往往会把你送去 std::terminate()。
也就是:进程结束。
这就是动态异常规格说明最让人不安的地方。
你以为它在“约束异常”。
实际上它更像在说:你敢违约,我就直接把进程掐了。
你可以拿一段更直白的代码感受一下这个“违约”。
void f() throw(std::bad_alloc) {
throw 42;
}
你以为外面能 catch。
实际上你可能先看到的是:程序直接结束。
就这么硬。
你看。
它不是“我帮你兜底”。
更像“你发了誓,别反悔”。
线上啪一下:throw() 一写,catch 都来不及
我见过最离谱的事故。
不是异常没抓到。
是你以为“应该能抓到”,结果程序直接没了。
当时是个小工具。
读一份配置。
生成一份报表。
跑在机器上,大家都当它是“稳稳的脚本”。
代码里有这么一段。
void report() throw() {
throw 42;
}
你如果刚学完 C with classes,很容易误会。
你会以为 throw() 是“我可能会 throw”。
其实它的意思正好相反。
它是“我保证什么都不抛”。
你又真的抛了。
那标准库就直接 std::terminate()。
terminate 是什么?
你先把它当成人话:不让你再跑了。
它不会去找外层 catch。
它也不会给你时间“回滚”“降级”“兜底”。
你不信可以加一层 try/catch。
int main() {
try {
report();
} catch (...) {
puts("never reached");
}
}
这一段的“杀伤力”在于:
你以为你已经抓住了异常。
其实你压根等不到它冒泡。
你站在茶水间,端着杯子,脑子里就一个字:
“啊?”
C++11 的做法:noexcept,别绕弯子
C++11 没有继续修补那套动态异常规格说明(throw(A, B, C) 这种)。
它把“我不会抛”这句话,换成了一个更直白的关键字:noexcept。
void g() noexcept {
// ...
}
规则也很硬。
你说你不抛,你就必须不抛。
一旦真的抛了,还是 std::terminate()。
你可能会问:那这不还是“啪一下”?
是。
但区别在于:
这次大家不再鼓励你去写“我可能抛 A/B/C”。
而是只把一个最有用的承诺保留下来:我保证不抛。
顺手补一个容易困惑的小点。
在 C++11 之后,老写法 throw() 基本等价于 noexcept(true)。
只是大家不推荐你再用它。
因为它长得太像“我会 throw”。
新手一眼就误会。
再补一嘴历史。
动态异常规格说明(throw(A, B, C) 这种)在 C++11 就被标成“别用了”。
后来标准干脆把它淘汰掉。
从 C++17 开始,新代码里你基本不该再写这套语法。
还有一个更关键的点。
你不写 noexcept,默认就是 noexcept(false)。
也就是:我可能会抛。
不同点在于,这次标准不再装作“还能帮你处理 unexpected”。
它把规则写死了。
编译器也就敢信。
标准库也就敢押注。
关键结论先放这。
noexcept 不是给人看的。
它是你跟编译器、跟标准库打的一个赌。
顺手说个洞见。
C++ 的很多历史都这样:先给你一把刀,再发现你总拿它切自己,最后换成一把更安全、也更实用的刀。
坑 2:move 写了,vector 还是不敢用
再讲一个更像“线上”的坑。
我写过一个小项目,读日志,解析成对象,丢进 std::vector。
压测一跑,CPU 飙了。
这里先插一句新手最容易懵的:move 是啥?
你可以先把它当成“把资源的所有权挪过去”。
不再复制一份。
最常见的对比就是拷贝构造 vs 移动构造。
struct S {
S(const S&) { puts("copy"); }
S(S&&) { puts("move"); }
};
这里的 S&& 叫右值引用。
词你先不用背。
你把它理解成“我接的是临时对象/快要死掉的对象”。
这种对象拿来“偷资源”,通常更安全。
我第一反应是 move 写错了。
结果 move 没错。
是标准库根本不敢用。
struct S {
S(const S&) { puts("copy"); }
S(S&&) { puts("move"); } // 没写 noexcept
};
再来两行。
std::vector<S> v;
v.push_back(S{});
v.push_back(S{}); // 这里可能扩容
你以为会看到 move。
很可能你看到的是一堆 copy。
原因也不玄学。
vector 扩容要“搬家”。
搬到一半如果抛异常,它就很难保证“要么全成功,要么完全没发生”。
这句话有点学术,拆成人话就是:
我不能把你的数据搬一半就跑。
这就是你经常听到的那个词:异常安全。
它也不需要你背定义。
你先抓住“容器的底线直觉”就行。
容器做一件大动作(比如扩容)时。
要么成功。
要么失败了也不能把自己搞成半残。
所以标准库的策略很保命。
如果 move 可能抛,那我宁愿 copy。
因为 copy 这条路更容易兜住局面。
这里还有个幕后工具,高手很爱提。
叫 std::move_if_noexcept。
S a;
S b = std::move_if_noexcept(a);
它的意思你可以当成一句话:
如果 move 够安全,我就 move。
不够安全,我就退回去 copy。
你可以把它想成标准库的自保手册。
标准库不是不想快。
它是不敢为了快,把你数据搞坏。
解决也很朴素。
你真能保证不抛,就把赌注写上去。
S(S&&) noexcept { puts("move"); }
很多项目里,这一句的意思几乎等价于:
让 vector 扩容时真的走 move。
少这一句。
你以为你在用移动语义。
其实在用拷贝。
关键结论再强调一下。
noexcept 不只是“好看”。
它会改变标准库怎么搬你的对象。
noexcept 还能写条件:我不拍胸脯,但我会写条款
当然。
不是每个 noexcept 都能拍胸脯。
模板一来,你就得看 T 的脸色。
C++11 允许你写“条件承诺”。
template <class T>
void swap(T& a, T& b) noexcept(noexcept(T(std::move(a)))) {
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
这里有个新东西:noexcept(expr)。
它会给你一个 bool,而且编译器能算出来。
你可以把它理解成“编译期问一句”。
我这句代码会不会抛?
如果不会,那我就敢把整个函数也标成不会抛。
void h() noexcept {}
static_assert(noexcept(h()), "h must not throw");
static_assert 你可以理解成:
编译期就把你拦下。
别等上线再吵。
所以这段话翻译成人话就是:
如果 T 的移动构造保证不抛,那我这个 swap 也保证不抛。
否则我就不乱承诺。
你写得保守一点。
标准库也能更大胆一点。
横着看一眼:别的语言怎么处理“可能失败”
你学 C 的时候。
你见过的失败,通常长这样。
int read_config(const char* path) {
FILE* f = fopen(path, "r");
if (!f) return -1;
fclose(f);
return 0;
}
失败写在返回值里。
你每一步都要记得检查。
漏一次就埋雷。
后来一些语言走了“声明式”的路线。
比如 Java。
void readConfig(String path) throws IOException {
}
它把“可能失败”写进函数签名。
你不处理,编译器就不让你过。
也有语言不强制你写 throws。
比如 C#。
它更像是在说:我给你异常,但我不管你怎么组织它。
还有一类语言更狠。
它们干脆不让你“随便 throw”。
比如 Rust。
fn read_config(path: &str) -> Result<(), std::io::Error> {
Ok(())
}
失败被塞进类型里。
你写着写着就会养成习惯:
失败不是“跳出去”。
失败是“一个值”。
你把这些放在一起看,就会发现一个很现实的事实。
C++ 想两边都要。
它既保留异常这套“跳”的机制。
又需要一个办法告诉标准库:这里真的不会跳。
所以 noexcept 这种承诺就很关键。
横着看完,你会发现 C++ 的取舍
如果你拿 C、Java、Rust 放一起对照。 你会发现每家都在解决同一个问题:失败怎么表达。
C 的直觉是:失败是返回值。 你每一步都要检查。
Java 的直觉是:失败是接口的一部分。 你不处理我就不让你编译。
Rust 的直觉是:失败也是一个值。 你必须把它走完。
C++ 的现实是: 我想要异常这套“跳”的力量。 我也想要 RAII 这套“自动收拾残局”的舒服。
但我还得给标准库一个信号: 这一步真的不会跳。 不然容器就只能保守。
所以 noexcept 才会这么重要。
它不是装饰。
它是标准库敢不敢跑快的依据。
我写 noexcept 的小规矩
我不喜欢滥用 noexcept。
因为它不是注释,它是合同。
你违约的代价,是直接结束进程。
但我也不喜欢回避。 因为它会改变标准库的行为,尤其是容器,尤其是移动。
所以我的规矩很简单。 能保证就写。 保证不了就别硬写。 模板里就写条件。
最后再给你一句老程序员的“茶水间总结”。
noexcept的价值,不是让你少抛异常。是让别人敢相信你。