Exception
Error 是问题 (废话), 那么一般来说可以分为编译期发现的和运行期间发现的. 此处简单说下 C++ 提供的一套在 Runtime 的时候对于 Error 的捕获,传播,处理等的机制 —— 也就是 Exception Handling
简单来说,这是一套可以主动报告 Failure,并把 Failure 沿调用栈传递给某个 Handler.
(关于 runtime error 的其他处理机制, 比如 std::option, std::expected 等,等开其他笔记再说, 此处聚焦 try-catch, throw 以及 exception 这套机制)
简单例子
void Load()
{
throw std::runtime_error("load failed");
std::cout << "Load End" << '\n';
}
int main()
{
try
{
Load();
}
catch (const std::exception& e)
{
std::cerr << e.what() << '\n';
}
std::cout << "continue\n";
}按照顺序执行, 先进入 Try 语句, 尝试执行 Load, 如果 Load 中出现了 throw 的 exception, 则会被 catch 匹配, 然后执行 catch 中的代码块, 执行之后继续按照顺序执行(直接执行 try-catch 后的语句). 所以上述代码的输出结果为 ——
load failed
continue解决问题
那么这解决了一个什么问题呢 —— 当前层级的 Err, 自己可能不能处理, 要交付给上层进行处理
实际例子 ——
Application::LoadScene
↓
SceneLoader::Load
↓
TextureLoader::Load
↓
FileSystem::Read然后 FileSystem::Read 挂了, 读取文件失败 —— 但是自己可能不能解决这个 Err, 因为文件损坏 / 没有读取权限等之类的有可能有多个问题导致文件失败,这不是 Read 方法可以自己解决的事情, 无奈, 只能把 Err 报告给上层,看看上面怎么处理.
此处有两种处理方式 ——
一是使用 Error Value 进行传播
Result ReadFile()
{
if (failed)
return Error;
}
Result LoadTexture()
{
auto result = ReadFile();
if (!result)
return result.error();
...
}
... // 再上层也同理std::expected<T, E>、LLVM Expected<T>、Abseil StatusOr<T> 都属于这种实现, 非常的不赖, 也挺优雅的.
那么另一种就是使用 Exception 来进行捕获
File ReadFile()
{
if (failed)
throw FileError(...);
}
Texture LoadTexture()
{
File file = ReadFile();
...
// 就是正常编写逻辑
}然后只需要在有能力处理这个 Error 的调用层写 ——
try
{
LoadScene();
}
catch (const FileError& error)
{
...
}也就是在 “调用者” 处进行错误的捕获和处理.
那么这样实现的好处是可以让 Err 越过若干层级, 直接跳到可以处理它的调用层进行处理
Exception Object
当程序执行到
throw std::runtime_error("load failed");的时候, 是实例化一个 Exception Object 对象, 并且记录信息, 让后面捕获这个 Exception 的地方可以进行一些处理
首先 std::exception 这个基类 ——
class exception
{
public:
virtual ~exception() noexcept;
virtual const char* what() const noexcept;
};(被删的差不多的 exception)
也就是说, 所有实现了这个接口的 exception 都有一个 what 接口,可以用于自己 override 记录错误的基础信息等
那么上面的 std::runtime_error 就是一个实现了 std::exception 的 exception, 其核心依旧是这个 what, 主要保存一段描述问题的信息
不过值得注意的是, throw 不是被限制要 throw 一个 exception 的接口, 而是可以 throw 其他什么, 比如 ——
throw 114514;
throw SomeClass{};Throw 之后
(以下不是 C++ Standard 规定的具体实现方式,而是 Itanium C++ ABI 下 GCC/Clang 等常见实现使用的一套典型机制)
最需要关注的点在于 —— 在遇到 Throw 语句之后, 如何向上传播.
可以简单查阅一下 Itanium ABI 文档 ——
temp = __cxa_allocate_exception(sizeof(X));
// construct X into temp
__cxa_throw(
temp,
type_info<X>,
destructor<X>
);这里就定义了几件事情要做 ——
- 准备一块能够保存 Exception Object 的空间
- 在这块空间中真正构造
X - 把这个 Exception Object 交给 C++ Exception Runtime
- Runtime 同时记录
- 它是什么类型
- 最后应该怎么销毁它
- 开始沿调用栈寻找能够处理它的 Handler
首先建立独立 Exception Object 的原因是 —— 如果 throw 来一个局部变量, 则会的当前的 Stack Frame 结束之后被销毁, 其声明周期无法保证它可以被传播到可以处理它的地方
接着记录 type_info 是为了在 catch 中可以进行类型匹配 ——
catch (const std::runtime_error&)
catch (const FileError&)
...这样的代码可以让 throw 出来的对象正确进入分支
同时 destructor 是为了保证其声明周期 —— 在 catch 之后可以被正常释放掉
我们准备好了所有东西, 接下来就是如何传播的事情了 ——
假设还是“解决问题”那个部分中的读取文件的例子, 我们会有一个调用链 ——
Caller
↓
LoadScene
↓
LoadTexture
↓
ReadFile此时必须让 exception 随着 ReadFile 沿着这个路径一直找到 Caller 身上.
显然, 此时涉及到栈帧的切换,所以需要涉及Stack Unwinder的事情, 简单来说, 就是让 Exception 向上传播的时候, 因为会离开一个栈帧, 所以需要对其作用域内的已经构造的局部对象进行释放, 然后继续向上传播.最后找到 Catch 为止. 看 catch 如何处理, 处理之后从 try-catch 块之后继续执行
不过实际上在 Itanium C++ ABI 的典型实现模型中其实有两个阶段 ——
- 先做查找, 仅查找 catch 在的栈帧, 而不做销毁,
_UA_SEARCH_PHASE - 进行 Stack Unwinding, 并执行沿途需要的 Cleanup / Destructor, 一路走到 catch 的位置,
_UA_CLEANUP_PHASE
(其中实际上比较复杂的编译原理, 未来可期一下, 此处重点还是 C++ 的理解, 所以先极度化简下)
此处为了保证资源可以被正常的销毁, 也是依赖 RAII 来进行管理, 保证没有资源泄露.
不过哪怕利用 RAII 保证资源的释放正常, 也无法保证资源的逻辑正确. 比如 ——
void Config::Update(const NewConfig& newConfig)
{
name_ = newConfig.name;
Validate(newConfig); // throw
path_ = newConfig.path;
}那么此时 config 的状态就是名字是新的,但是路径是旧的, 所以工程上还会提出 Exception Safety Guarantee 这样的概念.
感觉继续写会导致文章职责膨胀, 下次一定. 简单来说就是保证 Throw 之后我们最好依旧可以保证变量逻辑正确 (比如先做所有有可能 throw 的操作, 最后再统一 commit)
void Config::Update(const NewConfig& newConfig)
{
Config temp = *this;
temp.name_ = newConfig.name;
temp.path_ = newConfig.path;
Validate(temp);
std::swap(*this, temp); // commit
}noexcept
noexcept 用于声明一个函数不允许 Exception 从该函数边界逃逸. 如果 Exception 实际试图逃出一个 noexcept 函数, 程序会调用 std::terminate(), 尤其是 destructor move swap 之类的方法非常的有意义 ——
简单来个例子, 假设析构函数可以参与exception的传播链条 ——
class File
{
public:
~File() noexcept(false) { throw CloseError{}; }
}
void Foo()
{
File file;
MaybeThrow();
}系统在执行 MaybeThrow 的时候开始处理异常, Stack Unwinding 自动调用了 ~File(), 而 Destructor 在执行过程中又抛出了第二个 Exception, 并让它逃出了 Destructor. 那么就会直接调用 std::terminate() 终止程序
原因是一个异常正在被尝试处理, 但是同时另一个异常又被系统自动掉其被抛出, 那么不管是继续处理第一个异常, 还是放弃第一个异常去处理第二个异常, 都会有很多问题(资源管理就是一大难题), 所以会直接调用 std::terminate()
因此, 对于可能在 Stack Unwinding 中承担 Cleanup 的操作,尤其是 Destructor,应当保证 Exception 不会逃逸
与此同时, 对于承担 commit / resource transfer 职责, 并且实现本身确实能够保证不抛异常的 Move / Swap, 应该尽可能提供 noexcept. 这样 generic code 才能够利用这一性质建立或维护 Strong Exception Guarantee —— 还是举例子 —— 标准库里的 vector 在 realloc 的时候
old storage
[A][B][C]
↓
new storage假设在 move A, B 的时候正常, 但是在 move C 的时候抛出异常, 那么此时很难保证 Strong Guarantee, 于是 std::move_if_noexcept 的策略是 —— 如果可以 move 是 noexcept 就 move, 如果不是, 则使用 copy
好的写法
在明确可以处理 exception 的时候再写, 而不是 catch 语句到处写. 同时设计类型的时候最好考虑针对不同的策略 —— 每种策略对应一种类型, 而不是写一堆类型.
Exception Hierarchy
假设我们全部使用 std::runtime_error(message) 那么在 catch 之后做字符串匹配, 就显得非常愚蠢. 而是可以通过实现 exception 的接口, 然后做类型匹配
class AssetError : public std::runtime_error
{
public:
using std::runtime_error::runtime_error;
};
class AssetNotFoundError : public AssetError
{
public:
AssetNotFoundError(Path path)
: AssetError("asset not found"),
path_(std::move(path))
{
}
const Path& PathValue() const noexcept
{
return path_;
}
private:
Path path_;
};
class AssetDecodeError : public AssetError
{
public:
using AssetError::AssetError;
};这种利用继承树的实现也可以让调用处可以自己选择处理粒度 ——
try
{
LoadAsset(path);
}
catch (const AssetNotFoundError& e)
{
UseFallbackAsset();
}
catch (const AssetDecodeError& e)
{
ReportCorruptedAsset(e);
}
catch (const AssetError& e)
{
ReportAssetFailure(e);
}借助同一个 try 对应的 handlers 会按照源码出现顺序依次进行 matching 的特性, 所以写的顺序也要从细粒度到粗粒度,最后“兜底”这样来写
Catch-All Boundary
最好还是先匹配类型, 然后通过 ... 来进行兜底 ——
try
{
RunApplication();
}
catch (const std::exception& e)
{
LogFatal(e.what());
return EXIT_FAILURE;
}
catch (...)
{
LogFatal("unknown exception");
return EXIT_FAILURE;
}Rethrow
当前层有时不能真正处理 Exception,但可能需要完成某些必要的附加工作,之后继续传播原始 Exception,此时可以使用 throw;
try
{
Load();
}
catch (const AssetError& e)
{
AddTrace(e);
throw;
}这样的语义是 —— 当前层不能处理,只进行必要的附加工作,然后继续传播原异常
不过值得注意的是, 此处如果写成throw e;的话, 语义又完全不同,而是类似下一个文段说的内容.
于此同时, 可能会出现 object slicing 的问题, 即根据当前 e 的静态类型进行创建 throw 对象之后再进行传播, 可能会只剩下基类那部分数据
Exception Translation
此处的语义是把当前匹配到的 throw 做一次重新的打包, 成为自己系统中的 Error 传播对象
try
{
ReadFile(path);
}
catch (const std::filesystem::filesystem_error& e)
{
throw AssetLoadError(
AssetErrorCode::IOError,
path,
e.what()
);
}这样会重新开启一个新的 throw exception 的传播链条
总结
Don’t try to catch every exception in every function.
不要在各个函数中都写 catch, 而是在明确可以处理某种 exception 的时候再写;