Marvin's Blog【程式人生】

Ability will never catch up with the demand for it

22 Jun 2020

C++/WinRT学习笔记(十一):异常处理

Error handling with C++/WinRT

Error handling with C++/WinRT

异常最好只用来处理异常。而不是用来处理可预期错误。用throw/catch的方式处理可预期错误,不仅影响性能,而且影响代码可读性。异常用于将那些不可预期,无法在本地处理的错误沿调用栈回溯,知道遇到一个能够处理此异常的调用者为止。实在没有处理者,让程序终止。

举了例子,假设要用StorageFile.GetThumbnailAsync获取缩略图,并传给BitmapSource.SetSourceAsync。前者可能会返回nullptr,这是一个可预期的错误,程序员应该自己想办法处理好这个nullptr,而不是抛出异常。

WinRT ABI是通过返回HRESULT(32位整型)的方式返回错误代码的。在WinRT映射类中,异常跨越边界的时候被转化为HResult,然后被转为winrt::hresult_error 异常。你也可以使用winrt::hresult处理ABI传入的HRESULT。

如果你要抛出异常,C++/WinRT也提供了一些支持,下面是个例子:

winrt::handle h{ ::CreateEvent(nullptr, false, false, nullptr) };
winrt::check_bool(bool{ h });
winrt::check_bool(::SetEvent(h.get()));

如果winrt::check_bool失败,那么会调用winrt::throw_last_error,然后调用GetLastError 获取当前线程的错误值,然后调用winrt::throw_hresult 将错误转化成winrt::hresult_error抛出。

其他check函数:

  • winrt::check_hresult. Checks whether the HRESULT code represents an error and, if so, calls winrt::throw_hresult.
  • winrt::check_nt. Checks whether a code represents an error and, if so, calls winrt::throw_hresult.
  • winrt::check_pointer. Checks whether a pointer is null and, if so, calls winrt::throw_last_error.
  • winrt::check_win32. Checks whether a code represents an error and, if so, calls winrt::throw_hresult.

尽量多写异常安全地代码,避免捕获和抛出异常。如果程序抛出地异常没有处理,那么Windows自动生成一个错误报告(包含一个minidump),帮你分析这个异常。

所谓异常,应该都是预料之外地,如果你用catch去捕获一个异常地时候,想想这么做地合理性。这样当异常产生地时候,一定是你地代码逻辑有问题,或者系统运行出错。

也就是说异常最好只用来处理异常。而不是用来处理可预期错误。用throw/catch的方式处理可预期错误,不仅影响性能,而且影响代码可读性。异常用于将那些不可预期,无法在本地处理的错误沿调用栈回溯,知道遇到一个能够处理此异常的调用者为止。实在没有处理者,让程序终止。

抛出异常可比返回错误码要更影响性能

举了例子,假设要用StorageFile.GetThumbnailAsync获取缩略图,并传给BitmapSource.SetSourceAsync。前者可能会返回nullptr,这是一个可预期的错误,程序员应该自己想办法处理好这个nullptr,而不是抛出异常。

这些抛异常地助手都是通过winrt::throw_last_error or winrt::throw_hresult.来最终抛出异常地

ABI界面必须是noexcept的。在noexcept的函数中抛出异常,会导致std::terminate被调用,从而中止程序。C++/WinRT做成的API会自动保障ABI边界的noexcept要求,然后再投射类中通过winrt::to_hresult将HRESULT转化为异常。

winrt::to_hresult可以处理std::exception,以及从winrt::hresult_error派生出来的异常。

前面提到异常碰到noexcept会调用std::terminate()。这里的一个问题是std::terminate()不会保留异常的抛出痕迹,特别是在coroutine中使用的时候。建议在调用投射方法的时候,将代码包裹在winrt::fire_and_forget,以便增加调试性。(winrt::fire_and_forget调用winrt::terminate,间接调用RoFailFastWithErrorContext来保存更多上下文信息)

HRESULT MyWinRTObject::MyABI_Method() noexcept
{
    winrt::com_ptr<Foo> foo{ get_a_foo() };

    [/*no captures*/](winrt::com_ptr<Foo> foo) -> winrt::fire_and_forget
    {
        co_await winrt::resume_background();

        foo->ABICall();

        AnotherMethodWithLotsOfProjectionCalls();
    }(foo);

    return S_OK;
}

对于同步的代码,可以使用外围try/catch:

HRESULT abi() noexcept try
{
    // ABI code goes here.
} catch (...) { winrt::terminate(); }

对于调试环境下的代码,可以通过WINRT_ASSERT 来进行断言,这个宏会转化为_ASSERTE,在发布环境下,这个断言会消失。

在析构函数下,必须保证资源均被正确释放,推荐使用WINRT_VERIFY_:

WINRT_VERIFY(::CloseHandle(value));
WINRT_VERIFY_(TRUE, ::CloseHandle(value));

Modern C++ best practices for exceptions and error handling

在传统COM编程中,错误会通过HRESULT返回,或者函数特殊的传出参数。在Win32程序中,可以通过GetLastError 来获取上一次的错误。这些都是通过返回值来反映错误状态。

现代C++程序倾向于使用异常来处理逻辑和运行错误。逻辑错误指的是编写代码时考虑不周导致的错误,而运行错误是程序所依赖的运行支持系统产生的错误。使用异常的好处如下:

  • 通过返回值返回的错误可以忽略,但是异常不可忽略。
  • 异常可以沿着栈回溯到一个异常处理点,错误发生点和处理点可以分离
  • 异常发生时栈反解的过程可以保证资源得到释放

健壮的错误处理是编程时候的一大挑战,如果合理使用异常?下面是一些指导原则

  • 使用断言来检查不该发生的错误,使用异常来监测有可能发生的错误
  • 异常是为了在离错误发生地之外处理错误,如果做不到的话,使用返回值可能是更好的办法
  • 异常会对性能有所 影响,性能关键的部分应该避免使用异常
  • 对于会抛出异常的函数,提供三个等级的保证:强保证,弱保证,无异常保证
  • 抛出异常的时候应当传值,捕获异常的时候应该使用引用,不要捕获无法处理的异常
  • 不要使用异常声明(在C++11中废弃)
  • 使用标准库提供的异常体系
  • 不应该允许异常从析构或者内存释放函数中抛出

Exceptions and performance

不发生异常的时候,对性能影响不大。发生异常的时候,回溯栈的代价跟函数调用差不多。当然,在try语句块中,编译器需要生成额外的代码来设置异常处理,帮助栈反解。在内存受限的系统中,异常的使用比较需要比较谨慎。在性能攸关的循环中,要避免异常的额外开销。总的来说,要通过性能剖析来判断异常的影响。

Exceptions vs. assertions

异常和断言是两种机制。断言是用来验证假设,当假设不成立,程序应当停止运行。断言失败时会立即中止程序。而异常发生时,程序有可能继续运行。

C++ exceptions versus Windows SEH exceptions

Windows提供了structured exception handling (SEH)的机制,在C/C++中都可以使用。SEH 提供了__try, __except, and __finally等关键字。总的来说,应该使用C++自带的异常,而不是SEH。

Exception specifications and noexcept

C++ 11废弃了异常声明(throw(...)),不应该再使用它。

How to: Design for exception safety

健壮的错误处理要求再一开始就进行规划。程序调用了底层的代码,有可能产生异常。但是底层代码不知道上层应用的意图,所以无法提供有针对性地的异常处理。底层代码只有在能够自动恢复地情况下才应该吞下异常,否则就交给上层决定。

下面是一些技巧,帮助设计异常安全地代码(也就是异常发生时能够正确处理好资源问题)。

Keep resource classes simple

把资源类设计得简单一些,以避免资源泄露。尽可能使用智能指针。

Use the RAII idiom to manage resources

Resource Acquisition Is Initialization (RAII) 通过栈上对象得生命周期来管理资源得生命周期,以保证资源得自动释放。

像unique_ptr和shared_ptr 函数,其资源是用户分配得,所以不能完全算RAII,而是算Resource Release Is Destruction。

The three exception guarantees

异常的三级保证。

No-fail guarantee

保证无异常。最高的异常安全等级。经过仔细编写,保证一个函数不抛出异常。析构函数通常是需要保证无异常的。所有标准库中的容器要求传入的对象在析构的时候不发生异常。

Strong guarantee

保证稳定态。当函数在异常发生的时候,保证内存能够得到正确释放,并且程序状态能够维持正常。

Basic guarantee

保证无泄漏。当函数在异常发生的时候,保证内存能够得到正确释放,不要求程序状态能够维持正常。

Exception-safe classes

内建类型都是保证无异常的。标准库支持保证无泄漏。对于自定义类型,应该注意:

  • 使用智能指针或者其他RAII来管理资源。如果一个类是专用于管理一种资源的,允许使用其析构函数来管理资源。
  • 基类构造函数抛出的异常无法在派生类析构的内部捕获,需要使用函数级别的try/catch才行
  • 在概念上,C++不允许非初始化或者部分初始化的类型。这意味着,一个构造函数,要么成功要么失败。
  • 一个基本的公里,不要让异常逃出析构函数,并在栈上回溯。析构函数必须在内部消化掉异常,否则就应该中止程序。

其他

(本篇完)

comments powered by Disqus