返回介绍

4.5 C++11:支持对库的开发

发布于 2024-08-19 12:44:37 字数 10331 浏览 0 评论 0 收藏 0

设计 C++ 基础库,往往要在性能和易用性方面同 C++ 及其他语言的内置功能进行竞争。这时,查找规则、重载决策、访问控制、模板实例化规则等特性之中的微妙之处会组合起来,产生强大的表达能力,但同时也暴露出可怕的复杂性。

4.5.1 实现技巧

有些实现技巧实属黑魔法,不应当暴露给非专家。大部分程序员可以愉快地编写多年好的 C++ 代码,而不用了解这些复杂手段和神秘技巧。遗憾的是,初学者们一拥而上去研究这些最可怕的特殊代码,并从给别人(经常是错误地)解释它们的过程中得到巨大 的自豪感。博主和演讲者们通过显摆令人提心吊胆的例子抬高他们的名望。这是 C++ 语言复杂性名声的一个主要来源。在其他语言中,要么不提供这样的优化机会,要么手段被藏在了优化器内部。

我不能在此深入细节,就只提一个技巧,它在 C++11 的发展中作为关键技巧出现,并在基于模板的库(包括 C++ 标准库)中广为使用。它以奇怪的缩写为人所知:SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)。

你如何表达一个当且仅当某个谓词为真时才有的操作?概念为 C++20 提供了这样的支持(GCC 自 2015 年开始支持),但在 21 世纪早期,人们不得不依赖于晦涩的语言规则。例如:

template<typename T, typename U>
struct pair {
    T first;
    U second;
    // ...
    enable_if<is_copy_assignable<T>::value
              && is_copy_assignable<U>::value,pair&>::type
        operator=(const pair&);
    //...
};

这样,当且仅当 pair 的两个成员都有拷贝赋值操作时 pair 才有拷贝赋值操作。这超乎寻常的丑陋,但它对于定义和实现基础库也超乎寻常的有用——在概念还没有出现时。

要点在于,如果成员都有拷贝赋值,enable_if<…,pair&>::type 会成为一个普通的 pair&,否则它的实例化就会失败(因为 enable_if 没有为赋值提供一个返回类型)。这里 SFINAE 就起作用了:替换失败不是错误;失败的结果就如同整条声明不曾出现一样。

这里的 is_copy_assignable 是一个 type trait(类型特征),C++11 提供了数十个这样的特征以便程序员在编译期询问类型的属性。

enable_if 元函数由 Boost 开创并成为 C++11 的一部分。一个大致合理的实现:

template<bool B, typename T = void>
struct enable_if {}; // false 的情况:里面没有 type

template<typename T>
struct enable_if<true, T> { typedef T type; }; // type 是 T

SFINAE 的精确规则非常微妙而难以驾驭,但是在用户的不断压力下,它们在 C++11 的发展过程中变得越来越简单和通用。SFINAE 的一个附带收获是,它从内部显著改善了编译器,因为编译器必须能够从失败的模板实例化中进行无副作用的回退。这就大大阻止了编译器对非本地状态的使用。

4.5.2 元编程支持

二十一世纪的头十年对于 C++ 元编程来说有点像是无法无天的美国西部拓荒时代,新的技巧和应用在仅有基本模板机制支持的情况下被不断尝试。那些基本机制被反复使用到令人痛苦。错误信息 可谓糟糕透顶,编译时间经常奇慢无比,编译器资源(如内存、递归深度和标识符长度)会轻易耗尽。同时,人们纷纷重新发现同样的问题,并重新发明一些基本技 巧。显然,我们需要更好的支持。改进尝试采用了两条(至少理论上)互补的路径:

  • 语言:概念(§6),编译期函数(§4.2.7),lambda 表达式(§4.3.1),模板别名(§4.3.3),以及更精确的模板实例化规范(§4.5.1)。
  • 标准库tuple(§4.3.4),类型特征(§4.5.1),以及 enable_if(§4.5.1)。

遗憾的是,概念在 C++11(§6.2)中失败了,这给(通常复杂得可怕而且容易出错的)权宜之计留下了生存空间,典型情况会涉及类型特征和 enable_if(§4.5.1)。

4.5.3 noexcept 规约

起初的异常设计没有办法表明某个异常可能会从某函数中抛出。我仍然认为那才是正确的设计。为了让异常为 C++98 接纳,我们不得不加入异常规约,来列举一个函数会抛出那些异常 [Stroustrup 1993]。使用异常规约可选,并会在运行期进行检查。正如我担心的那样,这带来了维护的问题,在展开路径上对异常反复检查增加的运行期开销,还有源代码 膨胀。在 C++11 中,异常规约被废弃 [Gregor 2010],而到了 C++17,我们终于(一致同意)移除了异常规约这个特性。

一直有人希望能够在编译时检查函数会抛出什么异常。从类型理论的角度,在小规模程序中,在有高速编译器和对代码完全控制的情况下,那当然行得通。委 员会一再拒绝这种想法,原因是它不能扩展到由数十(或更多)组织维护的百万行代码规模的程序上 [Stroustrup 1994]。参见(§7.4)。

没有异常规约,库实现者们就要面对一个性能问题:在许多重要场合,一个库实现者需要知道一个拷贝操作是否会抛异常。如果会,就必须拿到一份拷贝以避 免留下一个无效对象(这样会违犯异常保证 [Stroustrup 1993])。如果不会,我们可以直接写入到目标中。在这种场合,性能的差别可以非常显著,而最简单的异常规约 throw(), 什么也不抛出,在此可以帮助判断。于是,在异常规约被弃之不用并最终从标准中移除的时候,我们基于 David Abrahams 和 Doug Gregor 的提案 [Abrahams et al. 2010; Gregor 2010; Gregor and Abrahams 2009] 引入了 noexcept 概念。

一个 noexcept 函数仍会被动态检查。例如:

void do_something(int n) noexcept
{
    vector<int> v(n);
    // ...
}

如果 do_something() 抛异常,程序会被终止。这样操作恰好非常接近零开销,因为它简单地短路了通常的异常传播机制。参见(§7.3)。

还有一个条件版本的 noexcept,用它可以写出这样的模板,其实现依赖于某参数是否会抛异常。这是最初促成 noexcept 的用例。例如,下面代码中,当且仅当 pair 的两个元素都有不抛异常的移动构造函数时,pair 的移动构造函数才会声明不抛异常:

template<typename First, typename Second>
class pair {
    // ...
    template <typename First2, typename Second2>
    pair(pair<First2, Second2>&& rhs)
        noexcept(is_nothrow_constructible<First, First2&&>::value
              && is_nothrow_constructible<Second, Second2&&>::value)
    : first(move(rhs.first)),
      second(move(rhs.second))
    {}
    // ...
};

其中的 is_nothrow_constructible<> 是 C++11 标准库的类型特征(type traits)之一(§4.5.1)。

在这相对底层和非常通用的层级写出最优代码可不简单。在基础层面上,懂得到底该按位拷贝,该移动,还是该按成员拷贝,会带来非常大的区别。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文