返回介绍

4.3 C++11:改进对泛型编程的支持

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

泛型编程(及其产物模板元编程(§10.5.2))在 C++ 98 中迅速轻松地获得了成功。它的使用对语言造成了严重的压力,而不充分的语言支持导致了巴洛克式矫揉造作的编程技巧和可怕的错误消息。这证明了泛型编程和元编程的实用性,许多明智的程序员为了获得其好处而甘愿承受其痛苦。这些好处是

  • 超越以 C 风格或面向对象风格所可能获得的灵活性
  • 更清晰的代码
  • 更细的静态类型检查粒度
  • 效率(主要来自内联、让编译器同时查看多处的源代码,以及更好的类型检查)

C++11 中支持泛型编程的主要新特性有:

  • §4.3.1:lambda 表达式
  • §4.3.2:变参模板
  • §4.3.3:template 别名
  • §4.3.4:tuple
  • §4.2.5:统一初始化

在 C++11 中,概念本应是改进支持泛型编程的核心,但这并没有发生(§6.2.6)。我们不得不等到 C++20(§6.4)。

4.3.1 lambda 表达式

BCPL 允许将代码块作为表达式,但是为了节省编译器中的空间,Dennis Ritchie 没有在 C 中采用这个特性。我在这点上遵循了 C 的做法,但是添加了 inline 函数,从而(重新)得到在没有函数调用的开销下执行代码的能力。不过,这仍然不能提供以下能力

  • 把代码写在需要它的那个准确位置上(通常作为函数参数)。
  • 从代码内部访问代码的上下文。

在 C++98 的开发过程中,曾有人提议使用局部函数来解决第二点,但被投票否决了,因为这可能成为缺陷的来源。

C++ 不允许在函数内部定义函数,而是依赖于在类内部定义的函数。这使得函数的上下文可以表示为类成员,因而函数对象变得非常流行。函数对象只是一个带有调用运算符(operator()())的类。这曾是一种非常高效和有效的技术,我(和其他人)认为有名字的对象比未命名的操作更清晰。然而,只有当我们可以在某样东西使用的上下文之外给它一个合理的名称,特别是如果它会被使用多次时,这种清晰度上的优势才会表现出来。

2002 年,Jaakko Järvi 和 Gary Powell 编写了 Boost.Lambda 库 [Järvi and Powell 2002] 这让我们可以写出这样的东西

find_if(v.begin(), v.end(), _1<i);  // 查找值小于 i 的元素

这里,_1 是代码片段 _1<i 的某个第一个实参的名称,而 i 是表达式所在作用域(enclosing scope)中的一个变量。_1<i 展开为一个函数对象,其中 i 被绑定到一个引用,_1 成为 operator()() 的实参:

struct Less_than {
    int& i;
    Less_than(int& ii) :i(ii) {}  // 绑定到 i
    bool operator()(int x) { return x<i; }  // 跟参数比较
}

lambda 表达式库是早期模板元编程的典范(§10.5.2), 非常方便和流行。不幸的是,它的效率并不特别高。多年来,我追踪了它相对于手工编码的同等实现的性能,发现它的开销是后者的 2.5 倍且这种差距相当一致。我不能推荐一种方便但却很慢的东西。这样做会损害 C++ 作为产生高效代码的语言的声誉。显然,这种慢在一定程度上是由于优化不当造成的,但出于这个和其他原因,我们有一群人在 Jaakko Järvi 领导下决定将 lambda 表达式作为一种语言特性 [Willcock et al. 2006] 来提出。举例来说:

template<typename Oper>
void g(Oper op)
{
    int xx = op(7);
    // ...
}

void f()
{
    int y = 3;
    g(<>(int x) -> int {return x + y;});  // 以 lambda 表达式作为参数调用 g()
}

这里,xx 会变成 3+7

<> 是 lambda 表达式引导器。我们不敢提出一个新的关键词。

这一提议引起了相当多的兴奋和许多热烈的讨论:

  • 语法应该是富有表现力的还是简洁的?
  • lambda 表达式可以从哪个作用域引用什么名字?[Crowl 2009]。
  • 从 lambda 表达式生成的函数对象应该是可变的吗?默认情况下不是。
  • lambda 表达式能是多态的吗?到 C++14 才可以(§5.4)。
  • lambda 表达式的类型是什么?独有的类型,除非它基本上是一个局部函数。
  • lambda 表达式可以有名字吗?不可以。如果你需要一个名字,就把它赋给一个变量。
  • 名称是由值绑定还是由引用绑定?你来选择。
  • 变量可以移动到 lambda 表达式中(相对于复制)吗?到 C++14 才可以(§5)。
  • 语法是否会与各种非标准扩展发生冲突?(不严重)。

到 2009 年 lambda 表达式被批准时,语法已经发生了变化,变得更加合乎惯例 [Vandevoorde 2009]:

void abssort(float* x, unsigned N)
{
    std::sort(x, x+N,
        [](float a, float b) { return std::abs(a) < std::abs(b); }
             );
}

<> 切换到 [] 是由 Herb Sutter 建议并由 Jonathan Caves 实现的。这种变化在一定程度上是由于需要一种简单的方法来指定 lambda 表达式可以使用周围作用域中的哪些名称。Herb Sutter 回忆道:

我的并行算法项目需要 lambda 表达式,这是我的动机……看到 EWG 所采用的 lambda 表达式那实在丑到爆的用法,以及从语法一致性/干净性的角度来看极为糟糕的设计(例如,捕获出现在两个分开的位置,语法元素使用不一致,顺序错误——因为构造函数元素应该先出现然后才是调用运算符元素,以及其他一些小问题)。

默认情况下,lambda 表达式不能引用在本地环境的名字,所以它们只是普通的函数。然而,我们可以指定 lambda 表达式应该从它的环境中捕获一些或所有的变量。回调是 lambda 表达式的一个常见用例,因为操作通常只需要写一次,并且操作会需要安装该回调的代码上下文中的一些信息。考虑:

void test()
{
    string s;
    // ... 为 s 计算一个合适的值 ...
    w.foo_callback([&s](int i){ do_foo(i,s); });
    w.bar_callback([=s](double d){ return do_bar(d,s); });
}

[&s] 表示 do_foo(i,s) 可以使用 ss 通过引用来传递(捕获)。[=s] 表示 do_bar(d,s) 可以使用 ss 是通过值传递的。如果回调函数在与 test 相同的线程上被调用,[&s] 捕获可能效率更高,因为 s 没有被复制。如果回调函数在不同的线程上被调用,[&s] 捕获可能是一个灾难,因为 s 在被使用之前可能会超出作用域;这种情况下,我们想要一份副本。一个 [=] 捕获列表意味着将所有局部变量复制到 lambda 表达式中。而一个 [&] 捕获列表意味着lambda 表达式可以通过引用指代所有局部变量,并意味着 lambda 表达式可以简单地实现为一个局部函数。事实证明,捕获机制的灵活性非常有价值。捕获机制允许控制可以从 lambda 表达式引用哪些名称,以及如何引用。这是对 1990 年代人们担心局部函数容易出错的一种回答。

lambda 表达式的实现基本上是编译器构建一个合适的函数对象并传递它。捕获的局部变量成为由构造函数初始化的成员,lambda 表达式的代码成为函数对象的调用运算符。例如,bar_callback 变成:

struct __XYZ {
    string s;
    __XYZ(const string& ss) : s{ss} {}
    int operator()(double d) { return do_bar(d,s); }
};

lambda 表达式的返回类型可以从它的返回语句推导出来。如果没有 return 语句,lambda 表达式就不会返回任何东西。

我把 lambda 表达式归类为对泛型编程的支持,因为最常见的用途之一——也是主要的动机——是用作 STL 算法的参数:

// 按降序排序:
sort(v.begin(),v.end(),[](int x, int y) { return x>y; });

因此,lambda 表达式显著地增加了泛型编程的吸引力。

在 C++11 之后,C++14 添加了泛型 lambda 表达式(§5.4)和移动捕获(§5)。

4.3.2 变参模板

2004 年,Douglas Gregor、Jaakko Järvi 和 Gary Powell(当时都在印第安纳大学)提出了变参模板 [Gregor et al. 2004] 的特性,用来:

直接解决两个问题:

  • 不能实例化包含任意长度参数列表的类模板和函数模板。
  • 不能以类型安全的方式传递任意个参数给某个函数

这些都是重要目标,但我起初发现其解决方案过于复杂,写法太过晦涩,按我的品味其编程风格又太递归。不过在 Douglas Gregor 于 2004 年做的精彩演示之后,我改变了主意并全力支持这项提案,帮助它在委员会顺利通过。我被说服的部分原因是变参模板和当时的变通方案在编译时间上的对比测量。 编译时间过长的问题随模板元编程的大量使用(§10.5.2)变得越来越严重,对此变参模板是一项重大(有时是 20 倍)改进。可惜,变参模板越变越流行,也成了 C++ 标准库中必需的部分,以至编译时间的问题又出现了。不过,成功的惩罚(在当时)还是在遥远的将来。

变参模板的基本思路是,递归构造一个参数包,然后在另一个递归过程来使用它。递归技巧是必须的,因为参数包中的每个元素都有它自己的类型(和大小)。

考虑 printf 的一种实现,能够处理可由标准库 iostream 的输出运算符 << 输出的每种类型 [Gregor 2006]:

为了创建类型安全的 printf(),我们采用以下策略:写出字符串直至碰到第一个格式说明符,按格式打印相应的值,然后递归调用 printf() 来打印字符串剩下部分和其余各值。

template<typename T, typename... Args>
void printf(const char* s, const T& value, const Args&... args)
{
    while (*s) {
        if (*s == '%' && *++s != '%') { // 忽略 % 后的字符:
                                        // 我们已经知道要打印的类型了!
            std::cout << value;
            return printf(++s, args...);
        }
        std::cout << *s++;
    }
    throw std::runtime_error("extra arguments provided to printf");
}

这里 <typename T, typename... Args> 指定了一个传统的列表,有头(T)和尾(Args)。每次调用会处理头,然后以尾为参数来调用自身。普通字符会被简单打印,而格式符 % 则表示某个参数要被打印了。Doug(当时他住在印第安纳州)提供了一个测试例子:

const char* msg = "The value of %s is about %g (unless you live in %s).\n";
printf(msg, std::string("pi"), 3.14159, "Indiana");

结果会打印

The value of pi is about 3.14159 (unless you live in Indiana).

这个实现的好处之一是,和标准的 printf 不同,用户定义的类型也和内建类型一样会得到正确处理。通过使用 << 也避免了类型指示符和参数类型之间的不匹配,比如 printf("%g %c","Hello",7.2)

这个 printf 所展示的技巧是 C++20 format(§9.3.7)的基础之一。

变参模板的缺点是容易导致代码膨胀,因为 N 个参数意味着模板的 N 次实例化。

4.3.3 别名

C 定义类型别名的机制是靠 typedef。例如:

typedef double (*pf)(int);   // pf 是一个函数指针,该函数接受一个 int
                             // 返回一个 double

这是有点诘屈聱牙,但是类型别名在 C 和 C++ 代码中非常有用,使用非常普遍。从最初有 C++ 模板的时候,人们就一直考虑是否可以有 typedef 模板;如果可以,它们应该是什么样子。2002 年时,Herb Sutter 提出一个方案 [Sutter 2002]:

template<typename A, typename B> class X { /* ... */ };
template<typename T> typedef X<T,int> Xi;  // 定义别名
Xi<double> Ddi;                            // 相当于 X<double, int>

在此基础之上,又经历了冗长的邮件列表讨论,Gabriel Dos Reis(当时在法国国立计算机及自动化研究院)和 Matt Marcus(Adobe)解决了特化相关的若干棘手问题,并引入 David Vandevoorde 称之为别名模板的简化语法 [Dos Reis and Marcus 2003]。例如:

template<typename T, typename A> class MyVector { /* ... */};
template<typename T> using Vec = MyVector<T, MyAlloc<T> >;

其中的 using 语法,即要引入的名字总是出现在前面,则是我的建议。

我和 Gabriel Dos Reis 一道把这个特性推广成一个(几乎)完整的别名机制,并最终得到接受 [Stroustrup and Dos Reis 2003c]。即便不涉及模板,它也给了人们一种写法上的选择:

typedef double (*analysis_fp)(const vector<Student_info>&);

using analysis_fp = double (*)(const vector<Student_info>&);

类型和模板别名是某些最有效的零开销抽象及模块化技巧的关键。别名让用户能够使用一套标准的名字而同时让各种实现使用各 自(不同)的实现技巧和名字。这样就可以在拥有零开销抽象的同时保持方便的用户接口。考虑某通讯库(利用了 Concepts TS [Sutton 2017] 和 C++20 的写法简化)中的一个实例:

template<InputTransport Transport, MessageDecoder MessageAdapter>
class InputChannel {
public:
    using InputMessage = MessageAdapter::InputMessage<Transport::InputBuffer>;
    using MessageCallback = function<void(InputMessage&&)>;
    using ErrorCallback = function<void(const error_code&)>;
    // ...
};

概念和别名对于规模化地管理这样的组合极有价值。

InputChannel 的用户接口主要由三个别名组成,InputMessageMessageCallbackErrorCallback,它们由模板的参数初始化而来。

InputChannel 需要初始化它的传输层,该传输层由一个 Transport 对象表示。然而,InputChannel 不应该知道传输层的实现细节,所以它不应直接初始化它的 Transport 成员。变参模板(§4.3.2)就派上了用场:

template<InputTransport Transport, MesssageDecoder MessageAdapter>
class InputChannel {
public:
    template<typename... TransportArgs>
        InputChannel(TransportArgs&&... transportArgs)
            : _transport {forward<TransportArgs>(transportArgs)... }
        {}
    // ...
    Transport _transport;
}

如果没有变参模板,就得定义出一个通用接口来初始化传输层,或者得把传输层暴露给用户。

这个漂亮的例子展示了如何把 C++11 的特性(加上概念)组合起来以优雅的零开销方案解决一个困难问题。

4.3.4 tuple

C++98 有个 pair<T,U> 模板;它主要用来返回成对的值,比如两个迭代器或者一个指针加上一个成功标志。2002 年时,Jaakko Järvi 在参考 Haskell、ML、Python 和 Eiffel 后,提议把这个思路进一步推广,变成 tuple(元组)[Järvi 2002]:

元组是大小固定而成员类型可以不同的容器。作为一种通用的辅助工具,它们增加了语言的表现力。举几个元组类型一般用法的例子:

  • 作为返回类型,用于需要超过一个返回类型的函数
  • 编组相关的类型或对象(如参数列表中的各条目)成为单个条目
  • 同时赋多个值

对于特定的设计意图,定义一个类,并在里面对成员进行合理命名、清晰表述成员间的语义关系,通常会是最好的做法。Alisdair Meredith 在委员会内力陈以上观点,劝阻在接口中过度使用未命名的类型。然而,当撰写泛型代码时,把多个值打包到一个元组中作为一个实体进行处理往往能简化实现。元 组对于不值得命名、不值得设计类的一些中间情况特别有用。

比如,考虑一个只需返回三个值的矩阵分解:

auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
    Matrix U, V;
    Vector S;
    // ...
    return make_tuple(U,S,V);
};

void use()
{
    Matrix A, U, V;
    Vector S;
    // ...
    tie(U,S,V) = SVD(A); // 使用元组形式
}

在这里,make_tuple() 是标准库函数,可以从参数中推导元素类型来构造 tupletie() 是标准库函数,可以把 tuple 的成员赋给有名字的变量。

使用 C++17 的结构化绑定(§8.2),上面例子可简化为:

auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
    Matrix U, V;
    Vector S;
    // ...
    return {U,S,V};
};

void use()
{
    Matrix A;
    // ...
    auto [U,S,V] = SVD(A); // 使用元组形式和结构化绑定
}

进一步的写法简化被提议加入 C++20 [Spertus 2018],但没来得及成功通过:

tuple SVD(const Matrix& A) // 从返回语句中推导出元组模板参数
{
    Matrix U, V;
    Vector S;
    // ...
    return {U,S,V};
};

为什么 tuple 不是语言特性?我不记得当时有人这么问过,尽管一定有人想到过这一点。长期以来(自 1979 年),我们的策略就是,如果能合理地将新特性以库的形式加入 C++,就不要以语言特性加入;如果不能,就要改进抽象机制使其成为可能。这一策略有显而易见的优势:

  • 通常对一个库做试验比对一个语言特性做试验更容易,这样我们就更快地得到更好的反馈。
  • 库可以早在所有编译器升级到支持新特性之前就得到严肃使用。
  • 抽象机制(类,模板等)上的改进,能在眼前问题之外提供帮助。

tuple 以 Boost.Tuple 为基础构建,其实现之巧妙也足以让众人引以为傲。在这一特性上,并没有出现运行期效率方面的理由,使我们去偏向一个语言实现而不是库实现。这让人颇为敬佩。

参数包就是一个拥有编译器支持接口的元组的例子(§4.3.2)。

元组大量用于 C++ 和其他语言(例如 Python)交互的程序库里。

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

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

发布评论

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