C++ 异常处理模型与 Rust 的等效实现:语言层面一对一写法
认识异常捕获模型
异常捕获模型是各种编程语言都具备的一种「错误处理机制」或者「异常状态处理机制」,用于 帮助程序员 更少地使用 if
或者宏来处理不符合预期的运行状态。
对于没有异常处理模型的 C 语言而言,每一次执行都需要判断上一次执行成功了没,这会给开发者带来较大的心智负担,如下面代码所示:
//=============================================================================
//函数:CreateNode
//描述:建立节点,函数会根据级数来判断它是否为叶子节点,如果不是,则“可删减”
//-----------------------------------------------------------------------------
static OctreeNode *CreateNode
(
size_t Level,
PaletteGenerator *pGen
)
{
OctreeNode *pNode;
pNode = malloc(sizeof *pNode);
if(!pNode)//错误汇报机制由调用者完成。
return NULL;
memset(pNode, 0, sizeof *pNode);
if(Level == ColorBits)
{
//判断是否最深一级
pNode->IsLeaf = 1;
pGen->NbLeaf ++;
}
else
{
//横向连结所有的节点
pNode->pNext = pGen->pReducibleNodes[Level];
pGen->pReducibleNodes[Level] = pNode;
}
return pNode;
}
//=============================================================================
//函数:CreatePaletteGenerator
//描述:分配内存,创建一个PaletteGenerator
//-----------------------------------------------------------------------------
PaletteGenerator* CreatePaletteGenerator(FnOnError pfnOnError)
{
PaletteGenerator* pGen;
pGen = malloc(sizeof *pGen);
if(!pGen) return NULL;
memset(pGen, 0, sizeof *pGen);
if(pfnOnError)
pGen->pfnOnError = pfnOnError;
else
pGen->pfnOnError = DefOnError;
//建立树根节点
pGen->pRoot = CreateNode(0,pGen);
if(!pGen->pRoot)
{
free(pGen);
if(pfnOnError)
pfnOnError(OPErr_NoMemory);//汇报错误
return NULL;
}
return pGen;
}
其中,C 语言每次调用 malloc()
分配内存后,必须要判断其返回的内存地址是否为 NULL
才能继续运行:如果是 NULL
说明内存分配失败,此时函数要返回一个错误状态让上一层的调用者可以继续处理。这导致上一层的调用者也不得不使用 if
或其它条件判断语句去判断函数调用是否成功,如果没有成功,则需要再返回到上一层继续去处理,一直到最顶层调用者决定打印错误信息并结束程序为止。 每经过一层调用,都要使用专门的错误判断语句去判断错误 ,造成了代码的繁琐与噪声。
早在古代时期,只有 C 语言,却有 GPU 的年代,OpenGL 的 API 设计者,为了让代码清晰简洁,将几乎所有的 OpenGL 函数设置为无返回值。但是 函数本身并不是一定就能成功运行的 ,函数调用者提供了错误的参数,或者 GPU 不提供特定的功能的时候,函数就会出错。此时,OpenGL 提供了 glGetError()
专门用来取回函数执行的错误状态。你如果想要处理错误,你需要针对几乎每一条 OpenGL API 的调用,写针对它的错误检测的 glGetError()
调用;要么你干脆无视 OpenGL 的错误状态,完全不检测错误,如果遇到错误,让用户自行处理;于是用户在面对黑屏的时候,通过物理击打显示器、重启游戏、重装显卡驱动、重装系统、重买电脑、重新来过人生等方式解决问题。
同样在古早时期,libpng
这个库,为了让程序代码简洁,使用了 setjmp()
和 longjmp()
这样的函数,强行从出错的位置跳转回集中处理错误的位置,判断 longjmp()
带来的 int
值,用这个值来判断运行是否出错,出的是什么错等。在跨了编程语言边界的地方,它来个冷不丁的 longjmp()
,直接就破坏了其它编程语言所需的 RAII 规则检测,造成潜在的内存泄漏问题。
上述代码如果改成 C++,会极大地改善代码的清晰度,如下:
class PaletteGenerator
{
protected:
...//前面代码(类成员变量定义等)省略
OctreeNode *PaletteGenerator::create_node(int level)
{
auto *node = new OctreeNode;
if (level == 8)
{
//判断是否最深一级
node->is_leaf = true;
num_leaves++;
}
else
{
//横向连结所有的节点
node->next_sibling = reducible_nodes[level];
reducible_nodes[level] = node;
}
return node;
}
public:
PaletteGenerator::PaletteGenerator() : root(create_node(0))
{
}
}
上述代码因为要使用一个自己搓的数据结构「八叉树结构」用不到智能指针,用 new
和 delete
自己管理内存;但是即便需要显式调用 delete
清理不使用的结构体, 也依然比 C 简洁 :new
如果分配内存失败,它内部会检测这个问题,然后抛出 std::bad_alloc
这个异常类型;你要是想处理这个异常你就使用 catch(const std::bad_alloc&)
捕获它,然后进行内存分配失败后的处理(一般就是打印程序的出错原因:我内存分配失败了,我退出);你要是不想处理,放任之也行,因为最终 C++ 程序会因为「未处理的异常」而中止运行,并打印调用栈和异常类型名字:std::bad_alloc
,一看就知道是内存分配故障。在它抛出异常时,C++ 会自动将所有当前域需要释放、销毁的局部变量或者结构体、类资源进行析构。C 语言则需要在遇到错误并退出时,程序员手动将当前域分配的所有资源都清理了以后,才能向上返回错误状态。
异常捕获模型的正确使用机制
假设你是库作者:
- 显式声明你有什么样的错误类型。
- C++:在头文件
.hpp
文件里,开头的代码部分,使用 #include<stdexcept>
标准库,然后定义自己的异常类型,如下:
// 异常类型名:MyLibraryIsUncomfortableError
// 异常描述:当我的库不舒服的时候,抛出这个异常。
class MyLibraryIsUncomfortableError : public std::runtime_error // 或者其它类型的 error
{
public:
MyLibraryIsUncomfortableError (const char* what) noexcept;
}
再在编译单元 .cpp
文件里,实现 MyLibraryIsUncomfortableError
的构造方法:
MyLibraryIsUncomfortableError::MyLibraryIsUncomfortableError (const char* what) noexcept :
std::runtime_error(what)
{}
因为公开继承了 std::runtime_error
类,该类有个 what()
方法可以获取异常原因字符串信息,所以你可以在 throw
你的 MyLibraryIsUncomfortableError
异常类型的时候,提供这个字符串信息。
- 针对不同的错误理由,定义不同的错误类型。上述的「我的库不舒服异常」
MyLibraryIsUncomfortableError
的错误理由是「不舒服」,除了它以外,每一种可以向用户说明错误理由的地方都定义一个单独的类。这样的话,用户可以根据你的异常类型,选择捕获特定类型的异常进行处理。
- 编写你的库的方法实现的时候,你需要做这几件事:
- 对于返回错误码的方法函数的调用,用
if
等条件判断语句判断它出错了没,如果出错,抛出你自己的异常类。尽量忠实于被你调用的函数能提供的错误信息,也可以适当简化内容重复的错误信息。
- 对于会抛出异常的方法函数的调用,通常可以任其抛异常,让用户处理;但是需要在你的函数文档里 显式说明你的方法函数会抛出这个异常,并且一定要在头文件里提供对应异常的类型声明。或者 将它抛出的异常类型转换为你的库定义的异常类型 ,转换的方法就是自己过一遍
catch
捕获这些异常,再用 throw
抛出对应的你的库定义的异常类型。
- 在编写注释和文档的时候,你需要 显式说明你的方法函数会抛出具体哪些异常类型 。
假如你是库的调用者:
- 编程的时候仔细阅读文档,知晓库的每个方法函数具体会抛出什么类型的异常。
- 针对你想处理的异常,你可以用
catch
来捕获;对于不想处理的异常可以不捕获。
- 尽量不要编写「能捕获一切异常类型的
catch
语句」,除非你写的是长期运行的服务器程序,不能因为一些忘记捕获的异常而崩溃退出。
性能
当你或者你使用的第三方库在最底层使用 if
判断最底层返回错误码的那个函数是否出现错误的情况时,其成本就是一个 if
的判断成本。 如果没有发生异常,那就没有额外的性能成本 。
如果发生了异常,就会有挺大的性能成本,具体体现在以下情况:
- 判定具体的异常类型的成本,这个成本接近于你把一个具有虚方法的类
dynamic_cast
为一个具体的类。
- 判定了具体异常类型后,跳转到
catch
块的跳转成本。跳转前会发生 RAII 资源回收。
- 跳转后,假设你的
catch
块需要得到抛出的异常的实例,那么取回这个实例也需要成本。
- 这就导致你写
catch (const MyLibraryIsUncomfortableError& e)
却并没有使用这个 e
的时候,编译器会提示你去掉这个 e
,这样可以节省取回这个具体的 e
的成本,只需要判断异常类型即可。
不同编程语言的异常抛出和捕获的行为
Python
Python 使用 raise
关键字抛出特定类型的异常(使用 class
定义异常的类型),然后使用 try
和 except
来捕获异常。Python 官方推荐 使用这种方式进行常规的错误处理模型,因为异常一定会被捕获到。
Python 的异常的性能和 C++ 类似,如果不需要抛出异常,那就是一个 if
的成本;如果需要抛出异常,那也像 C++ 抛异常一样;如果要捕获异常,也是像 C++ 那样慢。
C++
C++ 官方 并不推荐 用抛异常方式进行常规的错误处理,因为并不是所有的平台都支持 C++ 异常的捕获。在不支持 C++ 异常捕获的平台,比如 Buildroot,抛出异常等于中止程序并打印调用栈,即使你有 catch
块也无法捕获。
Rust 的错误处理模型
同样是为了方便开发者不要像 C 语言那样繁琐地处理程序错误问题,Rust 另辟蹊径,给开发者提供了 Result<T, E>
这样的枚举类型,当它是 Ok
的时候,调用者可以取出函数的返回结果(泛型类型 T
);当它是 Err
的时候,调用者可以取出错误类型 E
。虽然它还是基于条件判断的,但是开发者可以对 Result<T, E>
类型使用问号 ?
来省略自己写 if
或者 match
的语句,而是让编译器生成对函数返回类型的判断,如果函数返回了 Result::Err
,则向上层调用者返回这个 Result::Err
。此时,如果开发者调用的函数返回的错误类型和开发者自己写的函数返回的错误类型不一致,只需要开发者针对自己的错误类型实现其 From<被调用者的错误类型>
trait 即可实现错误类型经过转换后再向上传播。 虽然生成的二进制指令是用条件判断来处理错误情况的,但是开发者不用亲自编写这些条件判断的语句 ,从而实现了优雅的错误处理。
Rust 的 panic 机制
Rust 的 panic
和 C++ 的 throw
是用相同的方式实现的。不同的地方是:Rust 官方想让你优先使用 Result<T, E>
的方式处理错误,因为这样做最妥当、稳定。 Rust 的 panic 可以像 C++ 的异常一样被捕获。 用 std::panic::unwind_catch()
等方式即可捕获。 Rust 官方不推荐使用这种方式作为常规的错误处理方案 ,因为它的缺点和 C++ 的异常一样:并非所有的平台都允许你捕获异常。在捕获不到异常的情况下,依赖异常捕获的错误处理机制会失效——会直接导致程序报告异常类型并退出。
让 Rust 像 C++ 一样抛出异常并捕获异常
Rust 是可以像 C++ 一样,抛出异常并捕获异常的。异常处理模型的一个最基本的原则是:捕获异常的时候,要按异常的类型来捕获。Rust 如何体现异常的类型呢?请看下文:
-
如果你用 panic!()
语句触发 panic,其「异常类型」是 String
。
-
想要抛出自己定义的异常类型?使用 std::panic::panic_any()
函数。
-
捕获 panic:使用 std::panic::catch_unwind()
可以捕获异常,得到一个 Result<T, E>
,其中 E
是 &dyn std::any::Any + Send
类型。
用法:
match std::panic::catch_unwind(||{
//此处的任何语句只要触发 panic 就会导致 catch_unwind 返回 Err,如下面的语句:
std::panic::panic_any(MyLibraryIsUncomfortableError("我的库不舒服"));
}) {
Ok(_) => println!("没有触发 panic"),
Err(e) => println!("有 panic:{e:?}"),
}
-
手动匹配「异常类型」,只处理自己要处理的「异常类型」,如果遇到意料之外的「异常类型」,则需要将其重新抛出。
match std::panic::catch_unwind(||{
//此处的任何语句只要触发 panic 就会导致 catch_unwind 返回 Err,如下面的语句:
std::panic::panic_any(MyLibraryIsUncomfortableError::new("我的库不舒服"));
}) {
Ok(_) => println!("没有触发 panic"),
Err(e) => {
if let Some(e) = e.downcast_ref::<MyLibraryIsUncomfortableError>() {
println!("触发了 `MyLibraryIsUncomfortableError`: {e:?}");
} else {
// 不是自己认识的「异常类型」,继续向上层抛出
resume_unwind(e);
}
}
}
性能
使用 if
判断要不要抛出自己的异常,其成本也就是一个 if
的成本,和 Result<T, E>
使用 ?
符号来返回的成本相同。
但是如果要抛出「异常」,也就是使用 panic!()
或者 std::panic::panic_any()
的情况,其成本和 C++ 的 throw
相同。
抛出异常后你要捕获,捕获的成本也和 C++ 相同,不同的是:
- C++ 根据你的
catch
语句,自动判断异常的类型,做 dynamic_cast
类型转换;对于你没有 catch
的类型,C++ 自动忽视之,也就是任其向上层抛出。
- Rust 则是捕获全部类型的「异常」,然后交给开发者自己去用
downcast_ref
去手动判断「异常类型」,并进行处理。
- 对于不想捕获的「异常类型」开发者需要手动重新抛出, 但是开发者如果不想抛出,就会原地吃掉这个「异常」,造成问题。
应用
不到万不得已 ,绝不使用此方式进行错误处理。
我这边遇到的情况是:我有一个 ffi 接口需要调用类似 get_proc_address()
的 API 去获取函数指针地址,然后再封装成 Rust 函数供上层调用者调用。
在不同的系统、软件、硬件情况下,我的 ffi 接口并不是所有的函数都可以获取到地址。它具有一个插件系统,当 OS 或者驱动实现了某块插件,那么对应插件的 API 函数地址即可获取到;否则会获取到 NULL
。
针对获取到 NULL
的情况,如果不加处理直接提供给用户,结果就是用户一旦调用这个函数,函数调用对应的函数指针, 会直接造成未定义行为 ——通常是崩溃报内存访问错误,但是有些系统比如嵌入式系统是 允许零地址上存有数据或者指令 的,比如 STM32 的 ITCMRAM 的地址就是从 0x00000000
开始的,其中用于存储时间紧凑型指令,这块 RAM 通常能达到与 CPU 核心同频的速度,CPU 执行这里面的指令可以不需要占用 I-Cache。
此时,我需要对每个函数指针设计一个「假函数」,一旦 get_proc_address()
返回 NULL
,我就使函数指针指向对应的假函数,在假函数里抛出我的「函数指针为空的异常」。因为函数指针的参数和返回值类型必须按照 ffi 接口定义的去定义,所以不能让函数返回 Result<T, E>
,那么此时我的假函数也必须和函数指针一样,不能返回 Result<T, E>
;但是我可以让我的封装的函数返回 Result<T, E>
。在我封装的函数里,使用 std::panic::catch_unwind()
的闭包函数里调用我的函数指针,再根据其 Result
判断是否有 panic
,有的话我再用 downcast_ref
去将对应的 Err
值里面的错误值转换为我抛出的「函数指针为空的异常」, 如果能成功转换则使函数返回对应的 Err ,如果不能转换则继续抛出。
可能存在的问题
如果调用 ffi 接口的其它语言的库的时候, 如果其它语言是 C++ 并且抛出了异常 ,Rust 的 std::panic::catch_unwind()
有可能捕获这个异常,从而导致混乱(除非你又继续将其抛出)。因此除非能明确被调用函数是 C 接口,否则不要使用这种方式处理异常。