C++基础之Lambda表达式

Lambda

lambda表达式是C++11标准引入的新特性之一,它的名字来自于大名鼎鼎的$\lambda$演算。百度百科这样介绍:

$\lambda$演算(英语:lambda calculus,LC)是一套从数学逻辑中发展,以变量绑定和替换的规则,来研究函数如何抽象化定义、函数如何被应用以及递归的形式系统。它由数学家阿隆佐·邱奇在20世纪30年代首次发表。lambda演算作为一种广泛用途的计算模型,可以清晰地定义什么是一个可计算函数,而任何可计算函数都能以这种形式表达和求值,它能模拟单一磁带图灵机的计算过程;尽管如此,lambda演算强调的是变换规则的运用,而非实现它们的具体机器。

有关LC更详细的解读,推荐知乎的这篇:什么是 Lambda 演算?的几个高分回答,用以了解足矣。

本文,主要讲解C++中lambda表达式的相关内容。

lambda 表达式的意义

C++11标准引入的lambda表达式在大多数情况下被看作是一个语法糖,它的使用场景多为一次性的,编写函数对象类。lambda表达式可以很方便地在原地编写一个匿名函数类型的对象,并将其作为另一个函数的参数进行传递。

一个典型的lambda表达式常用于封装几行代码再传递给算法或者异步方法。一个典型的例子就是 std::sort() 函数中传递的第三个参数:

#include <algorithm>
#include <cmath>

void abssort(float* x, unsigned n) {
    std::sort(x, x + n,
        // Lambda expression begins
        [](float a, float b) {
            return (std::abs(a) < std::abs(b));
        } // end of lambda expression
    );
}

使用 Lambda 表达式可以减少程序中函数对象类的数量,使得程序更加优雅。

优势

1)遵守就近原则:随时定义随时使用,lambda表达式的定义和使用在同一个地方,并且lambda表达式可以直接在其他函数中定义使用,其他函数没有这个优势。

2)简洁明了:lambda表达式相比较其他函数的定义和使用更加简洁明了一些。

3)效率相对较高:lambda表达式不会阻止编译器的内联,而函数指针则会阻止编译器内联。

4)捕获动态变量:lambda表达式可以捕获它可以访问的作用域内(父作用域以及全局作用域)的任何动态变量。

Lambda表达式的语法

lambda表达式的语法定义如下:

[ capture clause ] ( parameter list ) mutable / exception -> return type { function body }
  • capture clause:捕捉列表。标识一个Lambda表达式的开始,这部分必须存在,不能省略。[]是Lambda引出符。编译器根据该引出符判断接下来的代码是否是Lambda函数。捕捉列表能够捕捉上下文中的变量以供Lambda函数使用;
  • parameter list:参数列表。与普通函数的参数列表一致。如果不需要参数传递,则可以连同括号“()”一起省略。从C++14开始,支持默认参数,并且参数列表中如果使用 auto 的话,该lambda称为泛化lambda(generic lambda);
  • mutable:mutable修饰符。默认情况下,Lambda函数总是一个const函数,mutable可以取消其常量性。在使用该修饰符时,参数列表不可省略(即使参数为空);
  • -> return type:返回类型,这里使用了返回值类型尾序语法(trailing return type synax)。可以省略,这种情况下根据lambda函数体中的return语句推断出返回类型,就像普通函数使用 decltype / auto 推导返回值类型一样;如果函数体中没有return,则默认返回类型为void。
  • function body:与任何普通函数一样,表示函数体。

简单示例

lambda表达式可以忽略参数列表和返回类型,但必须包含捕获列表和函数体:

auto f = [] { return 42; }
cout << f() << endl;

上面的lambda表达式,定义了一个可调用对象f,它不接受参数,返回42。Lambda的调用方式与普通函数的调用方式相同。

本质

lambda表达式是用于生成闭包的纯右值(prvalue)表达式。每一个lambda表达式都定义了独一无二的闭包类,闭包类内主要的成员有operator()成员函数:

ret operator()(params) const { body } //the keyword mutable was not used
ret operator()(params) { body } //the keyword mutable was used

template<template-params> //since C++14, generic lambda
ret operator()(params) const { body }

template<template-params> //since C++14, generic lambda, the keyword mutable was used
ret operator()(params) { body }

当调用lambda表达式生成的闭包时,执行operator()函数。除非lambda表达式中使用了mutable关键字,否则lambda生成的闭包类的operator()函数具有const饰词,从而lambda函数体中不能修改其按值捕获的变量;如果lambda表达式的参数列表中使用了auto,则相应的参数称为模板成员函数operator()的模板形参,该lambda表达式也就成了泛化lambda表达式。

如果捕获列表中,有按值捕获的局部变量,则闭包类中就会有相应的未命名成员变量副本,这些成员变量在定义lambda表达式时就由那些相应的局部变量进行初始化。如果按值捕获的变量是个函数引用,则相应的成员变量是引用指向函数的左值引用;如果是个对象引用,则相应的成员变量是该引用指向的对象。如果是按引用捕获,标准中未指明是否会在闭包类中引入相应的成员变量。

该闭包类还有其他成员函数。比如转换为函数指针的转换函数、构造函数(包括复制构造函数)、析构函数等,具体可参考https://en.cppreference.com/w/cpp/language/lambda

捕获列表

(Also known as the lambda-introducer in the C++ specification)

lambda可以定义在函数内部,使用其局部变量,但它只能使用那些明确指明的变量。lambda通过将外部函数的局部变量包含在其捕获列表中来指出将会使用这些变量。

当定义一个lambda时,编译器生成一个与lambda对应的新的(未命名的)类。当向函数传递一个lambda时,同时定义了一个新类型和该类型的一个对象:传递的参数就是编译器生成的类类型的未命名对象;类似的,当使用auto定义一个用lambda初始化的变量时,定义了一个从lambda生成的类型的对象。

默认情况下,从lambda生成的类都包含一个对应该lambda所捕获的变量的数据成员。类似任何普通类的数据成员,lambda的数据成员在lambda对象创建时被初始化。

值捕获

类似参数传递,变量的捕获方式可以是值或引用。与传值参数类似,采用值捕获的前提是变量可以拷贝。被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝:

int v1 = 42;
auto f=[v1]{return v1;};
v1=0;
auto j = f(); //j is 42

由于被捕获变量的值是在lambda创建时拷贝,因此随后对其修改不会影响到lambda内对应的值。

引用捕获

定义lambda时可以采用引用方式捕获变量。例如:

int v1 = 42;
auto f=[&v1]{return v1;};
v1=0;
auto j = f(); //j is 0

v1之前的&指出v1应该以引用方式捕获。一个以引用方式捕获的变量与其他任何类型的引用的行为类似。当我们在lambda函数体内使用此变量时,实际上使用的是引用所绑定的对象。在本例中,当lambda返回v1时,它返回的是v1指向的对象的值。

引用捕获与返回引用有着相同的问题和限制。如果我们采用引用方式捕获一个变量,就必须确保被引用的对象在lambda执行的时候是存在的。lambda捕获的都是局部变量,这些变量在函数结束后就不复存在了。如果lambda可能在函数结束后执行,捕获的引用指向的局部变量已经消失,这就是未定义行为。

引用捕获有时是必要的:

void biggies(vector<string> &words,
             vector<string>::size_ type sz,
             ostream &os=cout, char c=' ')
{
    for_each(words.begin(), words.end(), 
              [&os, c](const strinq &s) { os << s << c; });
}

不能拷贝ostream对象,因此捕获os的唯一方法就是捕获其引用。当我们向一个函数传递lambda时,就像本例子调用for_each那样,lambda会在函数内部执行。在此情况下,以引用方式捕获os没有问题,因为当for_each执行时,biggies中的变量是存在的。

我们也可以从一个函数返回lambda。函数可以直接返问一个可调用对象,或者返回一个类对象,该类含有可调用对象的数据成员。如果函数返回一个lambda,则与函数不能返回一个局部变量的引用类似,此lambda也不能包含引用捕获。

隐式捕获

除了显式列出我们希望使用的来自所在函数的变量之外,还可以让编译器根据lambda体中的代码来推断我们要使用哪些变量。为了指示编译器推断捕获列表,应在捕获列表中写一个” &” 或”=”。 ” &”告诉编译器采用引用捕获方式,”=”则表示采用值捕获方式。例如:

we = find_if(words.begin(), words.end(), 
             [=](const string &s)
              { return s.size() >= sz; });

如果希望对一部分变量采用值捕获,对其他变量采用引用捕获,可以混合使用隐式捕获和显式捕获:

void biggies(vector<string> &words, vector<string>::size_ type sz,
             ostream &os=cout, char c=' ')
{
    //os隐式捕获,引用捕获方式;c显式捕获,值捕获方式
    for_each(words.begin(), words.end(), 
              [&, c](const strinq &s) { os << s << c; });
              
    //os显式捕获,引用捕获方式;c隐式捕获,值捕获方式
    for_each(words.begin(), words.end(), 
              [=, &os](const strinq &s) { os << s << c; });
}

当混合使用隐式捕获和显式捕获时,捕获列表中的第一个元素必须是一个”&”或”=“。此符号指定了默认捕获方式为引用或值;并且显式捕获的变量必须使用与隐式捕获不同的方式。即,如果隐式捕获是引用方式,则显式捕获命名变量必须采用值方式;类似的,如果隐式捕获采用的是值方式,则显式捕获命名变量必须采用引用方式。

小结

  • 空:没有任何参数。
  • [=]:函数体内可以使用lambda所在范围内(备注)所有可见的局部变量(包括lambda所在类的this),并且是值传递方式(相当于编译器自动为我们按值传递了所有局部变量)。
  • [&]:函数体内可以使用lambda所在范围内(备注)所有可见的局部变量(包括lambda所在类的this),并且是引用传递方式(相当于是编译器自动为我们按引用传递了所有局部变量)。
  • [this]:函数体内可以使用lambda所在类中的成员变量。
  • [a]:将变量a按值进行传递。按值进行传递时,函数体内不能修改传递进来的a的拷贝,因为默认情况下函数是const的,要修改传递进来的拷贝,可以添加 mutable 修饰符。
  • [&a]:将变量a按引用进行传递。
  • [a, &b]:将变量a按值传递,变量b按引用进行传递。
  • [=, &a, &b]:除变量a和变量b按引用进行传递外,其他参数都按值进行传递。
  • [&, a, b]:除变量a和变量b按值进行传递外,其他参数都按引用进行传递。

【备注】有效范围为lambda所在父作用域,即包含lambda函数的语句块,通俗点就是包含lambda的最小“{}”代码块。另,全局变量不用在捕获列表中声明,可在函数体内直接使用。

参数列表

标识重载的 operator() 操作符的参数,没有参数时,这部分可以省略。参数可以通过按值 (int a, int b) 和按引用 (int &a, int &b) 两种方式进行传递。

mutable / exception 

这部分可以省略。按值传递函数对象参数时,加上 mutable 修饰符后,可以修改传递进来的拷贝(注意是能修改拷贝,而不是值本身)。 exception 声明用于指定函数抛出的异常,如抛出整数类型的异常,可以使用 throw(int) 。

mutable示例

默认情况下,对于一个按值捕获的变量,lambda不能改变其值。如果希望能改变这个被捕获的变量的值,就必须在参数列表之后加上关键字mutable,因此,可变lambda不能省略参数列表:

int v1 = 42;
auto f=[v1] () mutable {return ++v1;};
v1=0;
auto j = f(); //j is 43

一个引用捕获的变量是否可以修改依赖于此引用指向的是一个const类型还是一个非const类型:

int v1 = 42;
auto f=[&v1] () {return ++v1;};
v1=0;
auto j = f(); //j is 1

More about Exception

推荐微软的这篇:Exception specifications (throw, noexcept) (C++)作为扩展阅读。

-> 返回值类型

尾置返回值类型trailing return type)。【关于trailing return type的更多内容,推荐模板函数——后置返回值类型(trailing return type)C++11新特性:尾置返回类型快速了解】

标识函数返回值的类型,当返回值为void,或者函数体中只有一处return的地方(此时编译器可以自动推断出返回值类型)时,这部分可以省略

函数体

标识函数的实现,这部分不能省略,但函数体可以为空。

示例与总结

[] (int x, int y) { return x + y; } // 隐式返回类型
[] (int& x) { ++x;  } // 没有 return 语句 -> Lambda 函数的返回类型是 'void'
[] () { ++global_x;  } // 没有参数,仅访问某个全局变量
[] { ++global_x; } // 与上一个相同,省略了 (操作符重载函数参数)

可以像下面这样显示指定返回类型:

[] (int x, int y) -> int { int z = x + y; return z; }

在这个例子中创建了一个临时变量 z 来存储中间值。和普通函数一样,这个中间值不会保存到下次调用。什么也不返回的lambda函数可以省略返回类型,而不需要使用 -> void 形式。lambda函数可以引用在它之外声明的变量. 这些变量的集合叫做一个闭包. 闭包被定义在 Lambda 表达式声明中的方括号 [] 内。这个机制允许这些变量被按值或按引用捕获。如下图的例子:

示例1

std::vector<int> some_list;
int total = 0;
for (int i = 0; i < 5; ++i) some_list.push_back(i);
std::for_each(begin(some_list), end(some_list), [&total](int x)
{
    total += x;
});

此例计算 list 中所有元素的总和。变量 total 被存为 Lambda 函数闭包的一部分。因为它是栈变量(局部变量)total 引用,所以可以改变它的值。

示例2

std::vector<int> some_list;
int total = 0;
int value = 5;
std::for_each(begin(some_list), end(some_list), [&, value, this](int x)
{
    total += x * value * this->some_func();
});

此例中 total 会存为引用, value 则会存一份值拷贝。对this的捕获比较特殊,它只能按值捕获。this只有当包含它的最靠近它的函数不是静态成员函数时才能被捕获。对protect和private成员来说,这个lambda函数与创建它的成员函数有相同的访问控制。如果this被捕获了,不管是显式还是隐式的,那么它的类的作用域对lambda函数就是可见的。访问this的成员不必使用 this->  语法,可以直接访问。

总结

不同编译器的具体实现可以有所不同,但期望的结果是: 按引用捕获的任何变量,lambda 函数实际存储的应该是这些变量在创建这个lambda函数的函数的栈指针,而不是lambda函数本身栈变量的引用。不管怎样,因为大多数lambda函数都很小且在局部作用中,与候选的内联函数很类似,所以按引用捕获的那些变量不需要额外的存储空间。如果一个闭包含有局部变量的引用,在超出创建它的作用域之外的地方被使用的话,这种行为是未定义的!lambda函数是一个依赖于实现的函数对象类型,这个类型的名字只有编译器知道. 如果用户想把lambda函数做为一个参数来传递, 那么形参的类型必须是模板类型或者必须能创建一个 std::function 类似的对象去捕获lambda 函数。使用 auto 关键字可以帮助存储lambda函数:

auto my_lambda_func = [&](int x) { /* ... */ };
auto my_onheap_lambda_func = new auto([=](int x) { /* ... */ });

这里有一个例子, 把匿名函数存储在变量、数组或 vector 中,并把它们当做命名参数来传递:

一个没有指定任何捕获的 lambda 函数,可以显式转换成一个具有相同声明形式函数指针.所以,像下面这样做是合法的:

auto a_lambda_func = [](int x) { /* ... */ };
void (*func_ptr)(int) = a_lambda_func;
func_ptr(4); // calls the lambda

更多lambda示例详见微软的这篇:Examples of Lambda Expressions

参考资料

https://docs.microsoft.com/en-us/cpp/cpp/lambda-expressions-in-cpp?view=msvc-160

https://www.cnblogs.com/gqtcgq/p/9939651.html

https://blog.csdn.net/u010984552/article/details/53634513

https://blog.csdn.net/u010984552/article/details/53634513

posted @ 2021-04-17 20:56  箐茗  阅读(1443)  评论(0编辑  收藏  举报