当前位置:   article > 正文

【C++】一文详解lambda表达式用法及发展历程_无状态lambda表达式

无状态lambda表达式

1. lambda表达式语法

lambda表达式是现代编程语言的一个基础特性,比如LISP、Python、C#等具备该特性。但是遗憾的是,直到C++11标准之前,C++都没有在语言特性层面上支持lambda表达式。程序员曾尝试使用库来实现lambda表达式的功能,比如Boost.Bind或Boost.Lambda,但是它们有着共同的缺点,实现代码非常复杂,使用的时候也需要十分小心,一旦有错误发生,就可能会出现一堆错误和警告信息,总之其编程体验并不好。

另外,虽然C++一直以来都没有支持lambda表达式,但是它对lambda表达式的需求却非常高。最明显的就是STL,在STL中有大量需要传入谓词的算法函数,比如std::find_if、std::replace_if等。过去有两种方法实现谓词函数:编写纯函数或者仿函数。但是它们的定义都无法直接应用到函数调用的实参中,面对复杂工程的代码,我们可能需要四处切换源文件来搜索这些函数或者仿函数。

为了解决上面这些问题,C++11标准为我们提供了lambda表达式的支持,而且语法非常简单明了。这种简单可能会让我们觉得它与传统的C++语法有点格格不入。不过在习惯新的语法之后,就会发觉lambda表达式的方便之处。

lambda表达式的语法非常简单,具体定义如下:

[ captures ] ( params ) specifiers exception -> ret { body }
  • 1
  • [ captures ] —— 捕获列表,它可以捕获当前函数作用域的零个或多个变量,变量之间用逗号分隔。在对应的例子中,[x]是一个捕获列表,不过它只捕获了当前函数作用域的一个变量x,在捕获了变量之后,我们可以在lambda表达式函数体内使用这个变量,比如return x * y。另外,捕获列表的捕获方式有两种:按值捕获和引用捕获,下文会详细介绍。
  • ( params ) —— 可选参数列表,语法和普通函数的参数列表一样,在不需要参数的时候可以忽略参数列表。对应例子中的(int y)。
  • specifiers —— 可选限定符,C++11中可以用mutable,它允许我们在lambda表达式函数体内改变按值捕获的变量,或者调用非const的成员函数。上面的例子中没有使用说明符。
  • exception —— 可选异常说明符,我们可以使用noexcept来指明lambda是否会抛出异常。对应的例子中没有使用异常说明符。
  • ret —— 可选返回值类型,不同于普通函数,lambda表达式使用返回类型后置的语法来表示返回类型,如果没有返回值(void类型),可以忽略包括->在内的整个部分。另外,我们也可以在有返回值的情况下不指定返回类型,这时编译器会为我们推导出一个返回类型。对应到上面的例子是->int。
  • { body } —— lambda表达式的函数体,这个部分和普通函数的函数体一样。对应例子中的{ return x * y; }。

由于参数列表,限定符以及返回值都是可选的,于是我们可以写出的最简单的lambda表达式是:

[]{}
  • 1

虽然看上去非常奇怪,但它确实是一个合法的lambda表达式。需要特别强调是,上面的语法定义只属于C++11标准,C++14和C++17标准对lambda表达式又进行了很有用的扩展,后面会进行介绍。

2. 捕获列表

在lambda表达式的语法中,与传统C++语法差异最大的部分应该算是捕获列表了。实际上,除了语法差异较大之外,它也是lambda表达式中最为复杂的一个部分。接下来我们会把捕获列表分解开来逐步讨论其特性。

2.1 作用域

我们必须了解捕获列表的作用域,通常我们说一个对象在某一个作用域内,不过这种说法在捕获列表中发生了变化。

捕获列表中的变量存在于两个作用域——lambda表达式定义的函数作用域以及lambda表达式函数体的作用域。前者是为了捕获变量,后者是为了使用变量。另外,标准还规定能捕获的变量必须是一个自动存储类型。简单来说就是非静态的局部变量。

让我们看一看下面的例子:

int x = 0;
int main()
{
int y = 0;
static int z = 0;
auto foo = [x, y, z] {};
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

以上代码可能是无法通过编译的(为什么是可能?因为不同厂商的编译器的处理可能也不一样,比如GCC就不会报错,而是给出警告),其原因有两点:第一,变量x和z不是自动存储类型的变量;第二,x不存在于lambda表达式定义的作用域。那么如果想在lambda表达式中使用全局变量或者静态局部变量该怎么办呢?马上能想到的办法是用参数列表传递全局变量或者静态局部变量,其实不必这么麻烦,直接用就行了,来看一看下面的代码:

#include <iostream>
int x = 1;
int main()
{
int y = 2;
static int z = 3;
auto foo = [y] { return x + y + z; };
std::cout << foo() << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

在上面的代码中,虽然我们没有捕获变量x和z,但是依然可以使用它们。进一步来说,如果我们将一个lambda表达式定义在全局作用域,那么lambda表达式的捕获列表必须为空。因为根据上面提到的规则,捕获列表的变量必须是一个自动存储类型,但是全局作用域并没有这样的类型,比如:

int x = 1;
auto foo = [] { return x; };
int main()
{
foo();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
2.2 捕获值与捕获引用

捕获列表的捕获方式分为捕获值和捕获引用,其中捕获值的语法我们已经在前面的例子中看到了,在[]中直接写入变量名,如果有多个变量,则用逗号分隔,例如:

int main()
{
int x = 5, y = 8;
auto foo = [x, y] { return x * y; };
}
  • 1
  • 2
  • 3
  • 4
  • 5

捕获值是将函数作用域的x和y的值复制到lambda表达式对象的内部,就如同lambda表达式的成员变量一样。

捕获引用的语法与捕获值只有一个&的区别,要表达捕获引用我们只需要在捕获变量之前加上&,类似于取变量指针。只不过这里捕获的是引用而不是指针,在lambda表达式内可以直接使用变量名访问变量而不需解引用,比如:

int main()
{
int x = 5, y = 8;
auto foo = [&x, &y] { return x * y; };
}
  • 1
  • 2
  • 3
  • 4
  • 5

上面的两个例子只是读取变量的值,从结果上看两种捕获没有区别,但是如果加入变量的赋值操作,情况就不同了,请看下面的例子:

void bar1()
{
int x = 5, y = 8;
auto foo = [x, y] {
x += 1; // 编译失败,无法改变捕获变量的值
y += 2; // 编译失败,无法改变捕获变量的值
return x * y;
};
std::cout << foo() << std::endl;
}
void bar2()
{
int x = 5, y = 8;
auto foo = [&x, &y] {
x += 1;
y += 2;
return x * y;
};
std::cout << foo() << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

在上面的代码中函数bar1无法通过编译,原因是我们无法改变捕获变量的值。这就引出了lambda表达式的一个特性:捕获的变量默认为常量,或者说lambda是一个常量函数(类似于常量成员函数)。

bar2函数里的lambda表达式能够顺利地通过编译,虽然其函数体内也有改变变量x和y的行为。这是因为捕获的变量默认为常量指的是变量本身,当变量按值捕获的时候,变量本身就是值,所以改变值就会发生错误。相反,在捕获引用的情况下,捕获变量实际上是一个引用,我们在函数体内改变的并不是引用本身,而是引用的值,所以并没有被编译器拒绝。

另外,还记得上文提到的可选说明符mutable吗?使用mutable说明符可以移除lambda表达式的常量性,也就是说我们可以在lambda表达式的函数体中修改捕获值的变量了,例如:

void bar3()
{
int x = 5, y = 8;
auto foo = [x, y] () mutable {
x += 1;
y += 2;
return x * y;
};
std::cout << foo() << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

以上代码可以通过编译,也就是说lambda表达式成功地修改了其作用域内的x和y的值。值得注意的是,函数bar3相对于函数bar1除了增加说明符mutable,还多了一对(),这是因为语法规定lambda表达式如果存在说明符,那么形参列表不能省略。

编译运行bar2和bar3两个函数会输出相同的结果,但这并不代表两个函数是等价的,捕获值和捕获引用还是存在着本质区别。

当lambda表达式捕获值时,表达式内实际获得的是捕获变量的复制,我们可以任意地修改内部捕获变量,但不会影响外部变量。而捕获引用则不同,在lambda表达式内修改捕获引用的变量,对应的外部变量也会被修改:

#include <iostream>
int main()
{
int x = 5, y = 8;
auto foo = [x, &y]() mutable {
x += 1;
y += 2;
std::cout << "lambda x = " << x << ", y = " << y <<
std::endl;
return x * y;
};
foo();
std::cout << "call1 x = " << x << ", y = " << y << std::endl;
foo();
std::cout << "call2 x = " << x << ", y = " << y << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

运行结果如下:

lambda x = 6, y = 10
call1 x = 5, y = 10
lambda x = 7, y = 12
call2 x = 5, y = 12
  • 1
  • 2
  • 3
  • 4

对于捕获值的lambda表达式还有一点需要注意,捕获值的变量在lambda表达式定义的时候已经固定下来了,无论函数在lambda表达式定义后如何修改外部变量的值,lambda表达式捕获的值都不会变化。

#include <iostream>
int main()
{
    int  x = 5, y = 8;
    auto foo = [x, &y]() mutable
    {
        x += 1;
        y += 2;
        std::cout << "lambda x = " << x << ", y = " << y << std::endl;
        return x * y;
    };
    x = 9;
    y = 20;
    foo();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

运行结果如下:

lambda x = 6, y = 22
  • 1

在上面的代码中,虽然在调用foo之前分别修改了x和y的值,但是捕获值的变量x依然延续着lambda定义时的值,而在捕获引用的变量y被重新赋值以后,lambda表达式捕获的变量y的值也跟着发生了变化。

2.3 特殊捕获方法

lambda表达式的捕获列表除了指定捕获变量之外还有3种特殊的捕获方法。

  1. [this] —— 捕获this指针,捕获this指针可以让我们使用this类型的成员变量和函数。
  2. [=] —— 捕获lambda表达式定义作用域的全部变量的值,包括this。
  3. [&] —— 捕获lambda表达式定义作用域的全部变量的引用,包括this。

首先来看看捕获this的情况:

#include <iostream>
class A
{
public:
    void print()
    {
        std::cout << "class A" << std::endl;
    }
    void test()
    {
        auto foo = [this]
        {
            print();
            x = 5;
        };
        foo();
    }

private:
    int x;
};
int main()
{
    A a;
    a.test();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

在上面的代码中,因为lambda表达式捕获了this指针,所以可以在lambda表达式内调用该类型的成员函数print或者使用其成员变量x。

捕获全部变量的值或引用则更容易理解:

#include <iostream>
int main()
{
int x = 5, y = 8;
auto foo = [=] { return x * y; };
std::cout << foo() << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

3. lambda表达式实现原理

如果你是一个C++的老手,可能已经发现lambda表达式与函数对象(仿函数)非常相似,所以让我们从函数对象开始深入探讨lambda表达式的实现原理。请看下面的例子:

#include <iostream>
class Bar
{
public:
    Bar(int x, int y) : x_(x), y_(y) {}
    int operator()()
    {
        return x_ * y_;
    }

private:
    int x_;
    int y_;
};
int main()
{
    int  x = 5, y = 8;
    auto foo = [x, y]
    {
        return x * y;
    };
    Bar bar(x, y);
    std::cout << "foo() = " << foo() << std::endl;
    std::cout << "bar() = " << bar() << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

在上面的代码中,foo是一个lambda表达式,而bar是一个函数对象。它们都能在初始化的时候获取main函数中变量x和y的值,并在调用之后返回相同的结果。这两者比较明显的区别如下:

  1. 使用lambda表达式不需要我们去显式定义一个类,这一点在快速实现功能上有较大的优势。
  2. 使用函数对象可以在初始化的时候有更加丰富的操作,例如Bar bar(x+y, x * y),而这个操作在C++11标准的lambda表达式中是不允许的(C++14以后对其进行了扩展,已允许此操作)。另外,在Bar初始化对象的时候使用全局或者静态局部变量也是没有问题的。

这样看来在C++11标准中,lambda表达式的优势在于书写简单方便且易于维护,而函数对象的优势在于使用更加灵活不受限制,但总的来说它们非常相似。而实际上这也正是lambda表达式的实现原理。

lambda表达式在编译期会由编译器自动生成一个闭包类,在运行时由这个闭包类产生一个对象,我们称它为闭包。在C++中,所谓的闭包可以简单地理解为一个匿名且可以包含定义时作用域上下文的函数对象。现在让我们抛开这些概念,观察lambda表达式究竟是什么样子的。

首先,定义一个简单的lambda表达式:

#include <iostream>
int main()
{
int x = 5, y = 8;
auto foo = [=] { return x * y; };
int z = foo();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

接着,我们用GCC输出其GIMPLE的中间代码:

main()
{
    int D .39253;
    {
        int                      x;
        int                      y;
        struct __lambda0         foo;
        typedef struct __lambda0 __lambda0;
        int                      z;
        try
        {
            x       = 5;
            y       = 8;
            foo.__x = x;
            foo.__y = y;
            z       = main()::<lambda()>::operator()(&foo);
        }
        finally
        {
            foo = { CLOBBER };
        }
    }
    D .39253 = 0;
    return D .39253;
}
main()::<lambda()>::operator()(const struct __lambda0* const __closure)
{
    int       D .39255;
    const int x [value - expr:__closure->__x];
    const int y [value - expr:__closure->__y];
    _1       = __closure->__x;
    _2       = __closure->__y;
    D .39255 = _1 * _2;
    return D .39255;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

从上面的中间代码可以看出lambda表达式的类型名为__lambda0,通过这个类型实例化了对象foo,然后在函数内对foo对象的成员__x和__y进行赋值,最后通过自定义的()运算符对表达式执行计算并将结果赋值给变量z。在这个过程中,__lambda0是一个拥有operator()自定义运算符的结构体,这也正是函数对象类型的特性。所以,在某种程度上来说,lambda表达式是C++11给我们提供的一块语法糖而已,lambda表达式的功能完全能够手动实现,而且如果实现合理,代码在运行效率上也不会有差距,只不过实用lambda表达式让代码编写更加轻松了。

4. 无状态lambda表达式

C++标准对于无状态的lambda表达式有着特殊的照顾,即它可以隐式转换为函数指针,例如:

void f(void (*)()) {}
void g()
{
    f([] {});
}   // 编译成功
  • 1
  • 2
  • 3
  • 4
  • 5

在上面的代码中,lambda表达式[] {}隐式转换为void(*)()类型的函数指针。同样,看下面的代码:

void f(void (&)()) {}
void g()
{
    f(*[] {});
}
  • 1
  • 2
  • 3
  • 4
  • 5

这段代码也可以顺利地通过编译。我们经常会在STL的代码中遇到lambda表达式的这种应用。

5. 在STL中使用lambda表达式

要探讨lambda表达式的常用场合,就必须讨论C++的标准库STL。在STL中我们常常会见到这样一些算法函数,它们的形参需要传入一个函数指针或函数对象从而完成整个算法,例如std::sort、std::find_if等。

在C++11标准以前,我们通常需要在函数外部定义一个辅助函数或辅助函数对象类型。对于简单的需求,我们也可能使用STL提供的辅助函数,例如std::less、std::plus等。另外,针对稍微复杂一点的需求还可能会用到std::bind1st、std::bind2nd等函数。总之,无论使用以上的哪种方法,表达起来都相当晦涩。大多数情况下,我们可能必须自己动手编写辅助函数或辅助函数对象类型。

幸运的是,在有了lambda表达式以后,这些问题就迎刃而解了。我们可以直接在STL算法函数的参数列表内实现辅助函数,例如:

#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> x = {1, 2, 3, 4, 5};
std::cout << *std::find_if(x.cbegin(),
x.cend(),
[](int i) { return (i % 3) == 0; }) <<
std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

函数std::find_if需要一个辅助函数帮助确定需要找出的值,而这里我们使用lambda表达式直接在传参时定义了辅助函数。无论是编写还是阅读代码,直接定义lambda表达式都比定义辅助函数更加简洁且容易理解。

6. 广义捕获

C++14标准中定义了广义捕获,所谓广义捕获实际上是两种捕获方式,第一种称为简单捕获,这种捕获就是我们在前文中提到的捕获方法,即[identifier]、[&identifier]以及[this]等。第二种叫作初始化捕获,这种捕获方式是在C++14标准中引入的,它解决了简单捕获的一个重要问题,即只能捕获lambda表达式定义上下文的变量,而无法捕获表达式结果以及自定义捕获变量名,比如:

int main()
{
int x = 5;
auto foo = [x = x + 1]{ return x; };
}
  • 1
  • 2
  • 3
  • 4
  • 5

以上在C++14标准之前是无法编译通过的,因为C++11标准只支持简单捕获。而C++14标准对这样的捕获进行了支持,在这段代码里捕获列表是一个赋值表达式,不过这个赋值表达式有点特殊,因为它通过等号跨越了两个作用域。等号左边的变量x存在于lambda表达式的作用域,而等号右边x存在于main函数的作用域。如果读者觉得两个x的写法有些绕,我们还可以采用更清晰的写法:

int main()
{
int x = 5;
auto foo = [r = x + 1]{ return r; };
}
  • 1
  • 2
  • 3
  • 4
  • 5

很明显这里的变量r只存在于lambda表达式,如果此时在lambda表达式函数体里使用变量x,则会出现编译错误。初始化捕获在某些场景下是非常实用的,这里举两个例子,第一个场景是使用移动操作减少代码运行的开销,例如:

#include <string>
int main()
{
std::string x = "hello c++ ";
auto foo = [x = std::move(x)]{ return x + "world"; };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

上面这段代码使用std::move对捕获列表变量x进行初始化,避免了对象的拷贝赋值操作,从而提高了代码效率。

第二个场景是在异步调用时复制this对象,防止lambda表达式被调用时因原始this对象被析构造成未定义的行为,比如:

#include <iostream>
#include <future>
class Work
{
private:
int value;
public:
Work() : value(42) {}
std::future<int> spawn()
{
return std::async([=]() -> int { return value; });
}
};
std::future<int> foo()
{
Work tmp;
return tmp.spawn();
}
int main()
{
std::future<int> f = foo();
f.wait();
std::cout << "f.get() = " << f.get() << std::endl;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

输出结果如下:

f.get() = 32766
  • 1

这里我们期待f.get()返回的结果是42,而实际上返回了32766,这就是一个未定义的行为,它造成了程序的计算错误,甚至有可能让程序崩溃。为了解决这个问题,我们引入初始化捕获的特性,将对象复制到lambda表达式内,让我们简单修改一下spawn函数:

class Work
{
private:
int value;
public:
Work() : value(42) {}
std::future<int> spawn()
{
return std::async([=, tmp = *this]() -> int { return
tmp.value; });
}
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

以上代码使用初始化捕获,将*this复制到tmp对象中,然后在函数体内返回tmp对象的value。由于整个对象通过复制的方式传递到lambda表达式内,因此即使this所指的对象析构了也不会影响lambda表达式的计算。编译运行修改后的代码,程序正确地输出f.get() =42。

7. 泛型lambda表达式

C++14标准让lambda表达式具备了模版函数的能力,我们称它为泛型lambda表达式。虽然具备模版函数的能力,但是它的定义方式却用不到template关键字。实际上泛型lambda表达式语法要简单很多,我们只需要使用auto占位符即可,例如:

int main()
{
auto foo = [](auto a) { return a; };
int three = foo(3);
char const* hello = foo("hello");
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

8. 常量lambda表达式和捕获*this

C++17标准对lambda表达式同样有两处增强,一处是常量lambda表达式,另一处是对捕获*this的增强。这里主要说明一下对于捕获this的增强。还记得前面初始化捕获*this对象的代码吗?我们在捕获列表内复制了一份this指向的对象到tmp,然后使用tmp的value。没错,这样做确实解决了异步问题,但是这个解决方案并不优美。试想一下,如果在lambda表达式中用到了大量this指向的对象,那我们就不得不将它们全部修改,一旦遗漏就会引发问题。为了更方便地复制和使用*this对象,C++17增加了捕获列表的语法来简化这个操作,具体来说就是在捕获列表中直接添加[*this],然后在lambda表达式函数体内直接使用this指向对象成员,还是以前面的Work类为例:

class Work
{
private:
int value;
public:
Work() : value(42) {}
std::future<int> spawn()
{
return std::async([=, *this]() -> int { return value; });
}
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在上面的代码中没有再使用tmp=*this来初始化捕获列表,而是直接使用*this。在lambda表达式内也没有再使用tmp.value而是直接返回了value。编译运行这段代码可以得到预期的结果42。从结果可以看出,[*this]的语法让程序生成了一个*this对象的副本并存储在lambda表达式内,可以在lambda表达式内直接访问这个复制对象的成员,消除了之前lambda表达式需要通过tmp访问对象成员的尴尬。

9. 捕获[=, this ]

在C++20标准中,又对lambda表达式进行了小幅修改。这一次修改没有加强lambda表达式的能力,而是让this指针的相关语义更加明确。我们知道[=]可以捕获this指针,相似的,[=,*this]会捕获this对象的副本。但是在代码中大量出现[=]和[=,*this]的时候我们可能很容易忘记前者与后者的区别。为了解决这个问题,在C++20标准中引入了[=, this]捕获this指针的语法,它实际上表达的意思和[=]相同,目的是让程序员们区分它与[=,*this]的不同:

[=, this]{}; // C++17 编译报错或者报警告, C++20成功编译
  • 1

虽然在C++17标准中认为[=, this]{};是有语法问题的,但是实践中GCC和CLang都只是给出了警告而并未报错。另外,在C++20标准中还特别强调了要用[=, this]代替[=],如果用GCC编译下面这段代码:

template <class T>
void g(T) {}
struct Foo {
int n = 0;
void f(int a) {
g([=](int k) { return n + a * k; });
}
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

编译器会输出警告信息,表示标准已经不再支持使用[=]隐式捕获this指针了,提示用户显式添加this或者*this。最后值得注意的是,同时用两种语法捕获this指针是不允许的,比如:

[this, *this]{};
  • 1

这种写法在CLang中一定会给出编译错误,而GCC则稍显温柔地给出警告,在我看来这种写法没有意义,是应该避免的。

10. 模板语法的泛型lambda表达式

我们讨论了C++14标准中lambda表达式通过支持auto来实现泛型。大部分情况下,这是一种不错的特性,但不幸的是,这种语法也会使我们难以与类型进行互动,对类型的操作变得异常复杂。用提案文档的举例来说:

template <typename T> struct is_std_vector : std::false_type { };
template <typename T> struct is_std_vector<std::vector<T>> :
std::true_type { };
auto f = [](auto vector) {
static_assert(is_std_vector<decltype(vector)>::value, "");
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

普通的函数模板可以轻松地通过形参模式匹配一个实参为vector的容器对象,但是对于lambda表达式,auto不具备这种表达能力,所以不得不实现is_std_vector,并且通过static_assert来辅助判断实参的真实类型是否为vector。在C++委员会的专家看来,把一个本可以通过模板推导完成的任务交给static_assert来完成是不合适的。除此之外,这样的语法让获取vector存储对象的类型也变得十分复杂,比如:

auto f = [](auto vector) {
using T = typename decltype(vector)::value_type;
// …
};
  • 1
  • 2
  • 3
  • 4

当然,能这样实现已经是很侥幸了。我们知道vector容器类型会使用内嵌类型value_type表示存储对象的类型。但我们并不能保证面对的所有容器都会实现这一规则,所以依赖内嵌类型是不可靠的。进一步来说,decltype(obj)有时候并不能直接获取我们想要的类型。不记得decltype推导规则的读者可以复习一下前面的章节,这里就直接说明示例代码:

auto f = [](const auto& x) {
using T = decltype(x);
T copy = x; // 可以编译,但是语义错误
using Iterator = typename T::iterator; // 编译错误
};
std::vector<int> v;
f(v);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

请注意,在上面的代码中,decltype(x)推导出来的类型并不是std::vector ,而是const std::vector &,所以T copy = x;不是一个复制而是引用。对于一个引用类型来说,T::iterator也是不符合语法的,所以编译出错。在提案文档中,作者很友好地给出了一个解决方案,他使用了STL的decay,这样就可以将类型的cv以及引用属性删除,于是就有了以下代码:

auto f = [](const auto& x) {
using T = std::decay_t<decltype(x)>;
T copy = x;
using Iterator = typename T::iterator;
};
  • 1
  • 2
  • 3
  • 4
  • 5

问题虽然解决了,但是要时刻注意auto,以免给代码带来意想不到的问题,况且这都是建立在容器本身设计得比较完善的情况下才能继续下去的。

鉴于以上种种问题,C++委员会决定在C++20中添加模板对lambda的支持,语法非常简单:

[]<typename T>(T t) {}
  • 1

于是,上面那些让我们为难的例子就可以改写为:

auto f = []<typename T>(std::vector<T> vector) {
// …
};
  • 1
  • 2
  • 3

以及

auto f = []<typename T>(T const& x) {
T copy = x;
using Iterator = typename T::iterator;
};
  • 1
  • 2
  • 3
  • 4

上面的代码是否能让你眼前一亮?这些代码不仅简洁了很多,而且也更符合C++泛型编程的习惯。

最后再说一个有趣的故事,事实上早在2012年,让lambda支持模板的提案文档N3418已经提交给了C++委员会,不过当时这份提案并没有被接受,到2013年N3559中提出的基于auto的泛型在C++14标准中实现,而2017年lambda支持模板的提案又一次被提出来,这一次可以说是踩在N3559的肩膀上成功地加入了C++20标准。回过头来看整个过程,虽说算不上曲折,但也颇为耐人寻味,C++作为一个发展近30年的语言,依然在不断地探索和纠错中砺志前行。

11. 可构造和可赋值的无状态lambda表达式

我们提到了无状态lambda表达式可以转换为函数指针,但遗憾的是,在C++20标准之前无状态的lambda表达式类型既不能构造也无法赋值,这阻碍了许多应用的实现。举例来说,我们已经了解了像std::sort和std::find_if这样的函数需要一个函数对象或函数指针来辅助排序和查找,这种情况我们可以使用lambda表达式完成任务。但是如果遇到std::map这种容器类型就不好办了,因为std::map的比较函数对象是通过模板参数确定的,这个时候我们需要的是一个类型:

auto greater = [](auto x, auto y) { return x > y; };
std::map<std::string, int, decltype(greater)> mymap;
  • 1
  • 2

这段代码的意图很明显,它首先定义了一个无状态的lambda表达式greate,然后使用decltype(greater)获取其类型作为模板实参传入模板。这个想法非常好,但是在C++17标准中是不可行的,因为lambda表达式类型无法构造。编译器会明确告知,lambda表达式的默认构造函数已经被删除了(“note:a lambda closure type has a deleted default constructor”)。除了无法构造,无状态的lambda表达式也没办法赋值,比如:

auto greater = [](auto x, auto y) { return x > y; };
std::map<std::string, int, decltype(greater)> mymap1, mymap2;
mymap1 = mymap2;
  • 1
  • 2
  • 3

这里mymap1 = mymap2;也会被编译器报错,原因是复制赋值函数也被删除了(“note: a lambda closure type has a deleted copy assignment operator”)。为了解决以上问题,C++20标准允许了无状态lambda表达式类型的构造和赋值,所以使用C++20标准的编译环境来编译上面的代码是可行的。

总结

以上介绍了lambda表达式的语法、使用方法以及原理。总的来说lambda表达式不但容易使用,而且原理也容易理解。它很好地解决了过去C++中无法直接编写内嵌函数的尴尬。虽然在GCC中提供了一个叫作nest function的C语言扩展,这个扩展允许我们在函数内部编写内嵌函数,但这个特性一直没有被纳入标准当中。当然我们也并不用为此遗憾,因为现在提供的lambda表达式无论在语法简易程度上,还是用途广泛程度上都要优于nest function。合理地使用lambda表达式,可以让代码更加短小精悍的同时也具有良好的可读性。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/505678
推荐阅读
相关标签
  

闽ICP备14008679号