异常边界

在现代C++中,异常是运行时错误报告和处理的首选方法。是的,在某些情况下,其他形式的错误报告可能更适合错误代码,例如,但通常例外情况是首选的。当使用C++构建全新的库或应用程序时,最好编写异常安全代码并使用异常来持续执行错误报告。

null

即便如此,在许多情况下,根本不可能使用异常,或者不能使用异常。有大量的遗留C++代码,它们不使用异常,更糟的是,它不例外。通常,希望在这些遗留代码库中开始使用较新的库,以帮助提高代码质量、降低复杂性并使代码更易于维护。

请注意,当我说“更新的库”时,我并不一定是指任何花哨的东西。作为一个平凡但极为常见的例子,我们可以考虑这样一种情况,即我们决定开始使用标准库容器作为手动数据结构的替代品。标准库容器依赖异常来报告某些运行时错误,如内存不足错误,因此在将它们引入非异常安全的代码库时必须小心。

[旁白:对于异常安全和编写异常安全代码的最佳实践的极好概述,我强烈推荐Jon Kalb的 C++中的异常安全编码 交谈。]

那么,我们如何才能安全地将异常抛出库的使用引入到不安全的代码库中呢?最直接的方法是从划分代码基的小块开始,使之成为异常安全的,并在这些小块中封装异常用法。例如,您可以从单个类或组件开始,使其成为异常安全的,然后开始使用异常进行错误处理 在内部 那个班。

当这样做时,一个自然的 异常边界 已形成:异常用于边界一侧的错误处理,但不能允许它们跨边界泄漏。使用我们的单一异常安全类示例:异常可以由类在内部使用,但是每个公共成员函数必须确保捕获所有异常并将它们转换为可由外部调用方使用的错误。

注意,这种异常边界的概念并不局限于遗留代码。在许多其他情况下,需要例外边界。考虑C++用于实现可重用共享库(DLL)的情况,库具有C接口或COM接口。无论哪种情况,都不能让异常跨越API边界。因此,API边界也是一个异常边界:您可以在库的实现中随意使用异常,但必须确保在API边界捕获所有异常,并将它们转换为错误代码或以其他方式适当地处理它们。

一个简单的异常边界

让我们考虑一个非常简单的 边界函数 它在实现中使用异常抛出代码,但不能向调用方泄漏任何异常。在这里的示例中,我们将考虑一个返回HRESULT的C函数:

    extern "C" HRESULT boundary_function()    {        // ... code that may throw ...        return S_OK;    }

实际的 可能引发 与此无关:它是实现此函数所需的任何代码。唯一重要的是 可能引发 可能引发异常。从正确的角度来看,我们应该假设 可能引发 是一个抛出表达式。

显然这个函数是不正确的:我们的一个要求是边界函数不能泄漏任何异常,但是 可能引发 可能引发异常。我们如何捕捉异常?当然,使用try/catch块:

    extern "C" HRESULT boundary_function()    {        try        {            // ... code that may throw ...            return S_OK;        }        catch (...)        {            return E_FAIL;        }    }

这个实现是正确的: 可能引发 包含在捕获所有异常的try块中,因此此函数不会向其调用方泄漏任何异常。不过,这个实现对调用方不是很友好,因为它总是在失败时报告一个通用的Eu FAIL错误代码,这不是很有用。不过,这个异常边界很容易定制,因为我们可以添加单独的catch子句以不同方式处理特定类型的错误。

出于讨论的目的,假设我们的库在内部对失败使用自己的异常类型,名为myu hresultu error。此外,我们的库使用new和delete,因此我们可能还需要在边界处处理std::badu alloc。我们不希望在边界处出现任何异常,因此对于所有其他异常,我们希望立即终止,因为我们不知道系统的状态是什么。以下是更新后的实现在这些约束条件下的外观:

    extern "C" HRESULT boundary_function()    {        try        {            // ... code that may throw ...            return S_OK;        }        catch (my_hresult_error const& ex) { return ex.hresult();  }        catch (std::bad_alloc const&)      { return E_OUTOFMEMORY; }        catch (...)                        { std::terminate();     }    }

每个库可能需要处理不同的异常类型,因此要处理的异常类型的实际列表以及处理它们的方式在不同的库中是不同的。

我的一位同事指出,std::systemu error exception类型对于封装失败的系统调用和其他常见错误的错误代码和类别信息最为有用。他提供了一个常见的例子,说明对于我们的函数,这个异常的处理程序可能是什么样子的:

    catch (std::system_error const& e)    {        if (e.code().category() == std::system_category())            return HRESULT_FROM_WIN32(e.code().value);        if (e.code().category() == hresult_category())            return e.code().value;        // possibly more classifiers for other kinds of system errors:        return E_FAIL;    }

(为了简洁起见,我从主要示例中省略了这一点,因为我们将在本文的其余部分逐步修改它。)

很明显,我们可以根据需要自定义异常到错误代码转换。这里只有一个问题:错误代码翻译的例外是不可重用的。通常我们会有多个边界函数,所有这些边界函数通常需要相同的异常转换逻辑。我们绝对不想把这段代码到处复制粘贴。

拯救宏?

大多数情况下最好避免使用宏,但如果它们对任何事情都有好处,那么它们就可以反复删除代码。很容易将catch子句封装在宏中,然后在边界函数中使用该宏:

    #define TRANSLATE_EXCEPTIONS_AT_BOUNDARY                                 catch (my_hresult_error const& ex) { return ex.hresult();  }         catch (std::bad_alloc const&)      { return E_OUTOFMEMORY; }         catch (...)                        { std::terminate();     }    extern "C" HRESULT boundary_function()    {        try        {            // ... code that may throw ...            return S_OK;        }        TRANSLATE_EXCEPTIONS_AT_BOUNDARY    }

与必须将catch子句复制并粘贴到每个边界函数中相比,这无疑是一个改进。还有一点陈词滥调,但很合理。不过,这个解决方案并不好。它相当不透明,因为try仍然存在于函数中,但是catch子句隐藏在宏定义中。通过宏生成的代码进行调试也很困难。

这个解决方案并不可怕,但我们可以做得更好…

翻译功能

有什么比宏更好?函数怎么样?我们可以编写一个函数来封装catch子句中的翻译。我第一次在C++中介绍了这种技术,现在在Jon Kalb的“C++中的异常安全编码”(2012)中进行了链接。边界函数的解如下所示:

    inline HRESULT translate_thrown_exception_to_hresult()    {        try        {            throw;        }        catch (my_hresult_error const& ex) { return ex.hresult();  }        catch (std::bad_alloc const&)      { return E_OUTOFMEMORY; }        catch (...)                        { std::terminate();     }    }    extern "C" HRESULT boundary_function()    {        try        {            // ... code that may throw ...            return S_OK;        }        catch (...)        {            return translate_thrown_exception_to_hresult();        }    }

在这个实现中,我们的函数捕获所有异常,然后在catch all catch块中调用异常转换函数。在翻译函数的内部,我们使用C++的一个漂亮的特性:一个没有操作数的抛出将重新抛出 当前异常 ,即当前正在处理的异常。这种不带操作数的抛出形式只能在catch块中直接使用,也可以像这里的情况那样间接使用。一旦异常被重新抛出,我们就可以像直接在boundary函数中处理它一样处理它。

这是一种非常简洁的技术,用于整合异常转换逻辑,无需使用宏,并且每个边界函数中只有少量样板。有一个轻微的缺点是异常会被重新抛出,因此如果您使用 第一次破例 启用时,调试器将中断两次,一次在源代码抛出时中断,一次在边界转换抛出时中断。两次抛出也会带来一些开销,不过实际上这可能不是问题,因为开销只在异常代码路径上产生。

有关此技术的更多详细信息,请参阅本文 使用Lippincott函数进行集中异常处理 上个月由尼古拉斯·吉尔莫特撰写。我在研究这篇文章的时候看到了他的文章,他比我这里更详细地介绍了这项技术。

[旁白:我们的翻译功能应该声明为无例外;我省略了它,只是因为Visual C++ 2013不支持No..]

Lambda的表达让一切都变得美好

翻译功能可能非常好,但是使用C++ 11 lambda表达式有一个更干净更简单的解决方案。我们来看看:

    template <typename Callable>    HRESULT call_and_translate_for_boundary(Callable&& f)    {        try        {            f();            return S_OK;        }        catch (my_hresult_error const& ex) { return ex.hresult();  }        catch (std::bad_alloc const&)      { return E_OUTOFMEMORY; }        catch (...)                        { std::terminate();     }    }    extern "C" HRESULT boundary_function()    {        return call_and_translate_for_boundary([&]        {            // ... code that may throw ...        });    }

在这个实现中,我们的边界函数非常简单:它打包了整个函数体,包括 可能引发 ,转换为lambda表达式。然后它接受这个lambda表达式并将其传递给我们的翻译函数,调用u和u translate u作为u边界。

此翻译函数模板采用任意 可调用对象 ,女。实际上,可调用对象几乎总是lambda表达式,但也可以传递函数指针、函数对象或std::函数。您可以传递任何可以无参数调用的内容。

翻译函数模板从try块中调用f。如果f抛出任何异常,translation函数将处理它们并将它们转换为适当的HRESULT,就像我们在过去几个示例中所做的那样。

这种技术是侵入性最小的,需要最少的样板。请注意,我们甚至可以封装返回的Su OK;对于成功的返回案例。要使用这种技术,只需将每个边界函数的主体包装在lambda表达式中,并将该lambda表达式传递给异常转换器。

注意lambda表达式本身不需要任何参数;它应该始终是无参数可调用的。如果边界函数有参数,那么它们将被[&]捕获。类似地,对于成员函数边界函数,将捕获this指针,并且可以从lambda表达式中访问其他成员。

【2016年1月20日编辑: 本文的原始版本断言这种方法没有开销。 确实,这种方法不应该有任何开销。 然而,此时VisualC++编译器无法内嵌包含尝试块的函数,因此使用这种方法将导致少量的开销,以额外的函数调用的形式调用Call和AyTraseLeFux边界函数。

我第一次学习这种基于lambda的技术是在使用C的visualstudioide时。VisualStudioSDK有一个函数 ErrorHandler.CallWithComConvention() 它执行HRESULT转换的异常,通常由VisualStudio扩展用于使用托管代码实现COM接口。后来,我自己将此技术改编为在使用WRL实现Windows运行时组件时使用的技术,并发现它非常宝贵。

正在完成…

我们不能到处使用现代C++,但我们应该在任何地方使用它。这里介绍的这些技术应该可以帮助您在使用异常的代码和不泄漏异常的api之间保持干净的边界。

虽然我们只考虑了一个简单的例子,其中涉及一个返回HRESULT的C函数,但请记住,这些技术实际上适用于 任何 一种API边界,也是异常边界。它们同样适用于C函数、COM组件、使用WRL实现的WinRT组件等。返回类型不必是HRESULT:它可以是bool(success/failure)或errno 或特定于库或应用程序的错误代码枚举。最后,也是最重要的一点,这些技术可以扩展到支持库或组件使用的任何异常集。

下一次,我们将看看这个问题的另一面:在主要使用异常进行错误处理的代码中,我们如何最有效地利用通过错误代码报告失败的api?


James McNellis是Visual C++库团队的高级工程师,他在这里维护VisualC++和C++标准库的实现和C运行时(CRT)。他在C++上发微博。 @詹姆斯奈利斯 .

特别感谢Gor Nishanov和Sridhar Madhugiri审阅本文。

编辑: 在我发表这篇文章后不久,我注意到这一主题在之前的一篇文章中有所涉及, “例外边界: 使用多种错误处理机制 作者:大卫 布雷基。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享