前言
前一篇文章:# lambda简介和使用
在前面的文章中,我们对lambda的定义以及使用有了一个非常清晰的认识,它是一个可调用的对象,定义很像函数指针,包括参数、返回值和函数体3大要素,不同之处是lambda可以定义在函数内,可以通过捕获来获取外部函数中的局部变量。
同时,我们结合sort
、find_if
和for_each
与lambda的使用,更可以加深理解谓词的含义,以及lambda的捕获功能所带来的便捷性,可以在不传递参数的情况下,使用外部变量。
这个非常好用的lambda究竟是什么呢?编程没有魔法,本篇文章来重点探究一下。
正文
想要完全理解lambda,我们不能把lambda看成一个匿名函数,至于为什么,我们可以简单想一下为什么会把lambda看成函数。
lambda定义有参数列表、返回值和函数体,这完全就是一个函数的定义,但是没有名字,所以我们可以看成是匿名函数。同时,在函数体内部,可以使用外部的局部变量,有一种可以实现,那就是内联,相当于lambda代码块是函数的子作用域,所以我们可以看成是匿名的内联函数。
但是有一点不好解释,那就是捕获的对象,lambda可以对捕获的对象进行值捕获(拷贝)、引用捕获,单纯是函数的话,非常不好实现。所以当定义一个lambda时,编译器生成一个与lambda对应的类类型,当给函数传递一个lambda对象时,传递的参数本质是该类类型的未命名对象。
至于该类类型结构是什么样子的,如何调用,我们一步一步来分析。
函数调用运算符
如果类重载了函数调用运算符 ,则我们可以像使用函数一样使用该类的对象 。同时因为类可以存储状态,所以比普通函数更加灵活。这两句话需要完全理解,举个例子:
c++
#include <iostream>
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
int main() {
int i = -42;
absInt absObj;
int absi = absObj(i);
std::cout << absi << std::endl;
}
在上面代码中,使用operator
重载了函数调用运算符()
,它负责接受一个int类型的实参,然后返回绝对值。在absObj(i)
的使用中,我们可以调用该对象,就像absObj
是一个函数一样。
如果类定义了调用运算符,则把该类的对象称为函数对象(function object),原因非常简单,因为可以调用这种对象,也可以说这种对象的行为像函数一样。
含有状态的函数对象类
搞清楚了重载函数运算符,我们现在探究前面说的它可以做到行为类似函数,但是比函数更灵活的特性。
因为重载运算符的函数必须是类的成员函数,而类的成员函数可以调用其他成员,所以给调用该重载运算符函数提供了更多的操作。我们直接来看个例子:
C++
#include <iostream>
#include <string>
using namespace std;
class PrintString {
public:
PrintString(ostream &o = cout, char c = ' '):
os(o), sep(c) { }
void operator() (const string &s) const { os << s << sep; }
private:
ostream &os;
char sep;
};
int main() {
PrintString printer;
printer("hello");
PrintString errors(cerr, '\n');
errors("error");
}
在代码中,我们定义了一个用于自定义打印字符串的PrintString
类,默认使用std::cout
和空格符号来打印字符串,同时重载了调用运算符,传入一个待打印的字符串。
在使用中,对于对象printer
,可以直接把它当作函数来使用,而对于对象errors
,将会使用std::cerr
打印,并且后面跟一个换行符。
可以看到,当构建不同的对象时,调用它的效果是不一样的,这就是所体现的灵活性。
平时对于这种需求,我们一般都是习惯用函数重载来实现,现在使用重载运算符的方式,可以说是更加灵活。
lambda是函数对象
铺垫了这么多,终于讲到了重点,当我们编写一个lambda时,编译器会将该表达式翻译成一个未命名类的未命名对象。比如上一篇文章中的排序例子:
C++
sort(words.begin(), words.end(), [](const string &a, const string &b){
return a.size() < b.size();
});
其行为就类似于下面类的一个对象:
C++
class ShorterString {
public:
bool operator() (const string &a, const string &b) const {
return a.size() < b.size();
}
}
产生的类,只有一个重载了的函数调用运算符成员,参数和lambda完全一样。上一篇文章我们说过,标准库中传递给算法的谓词是一个可调用对象 ,所以前面我们演示了可以传递给sort
分别为函数和lambda,现在我们可以传递给它一个类对象:
C++
sort(words.begin(), words.end(), ShorterString());
在sort
内部的代码中,每次比较两个string
时,都会调用这个对象。
表示捕获行为的类
既然搞清楚lambda的本质其实就是函数对象后,捕获特性就非常容易理解了。当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时的对象确实存在,因此,编译器可以直接使用该引用而无需在lambda产生的类中将其存储为数据成员。
相反,通过值捕获的变量会被拷贝到lambda中,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
还是上一篇文章中的例子,我们写了一个lambda传递给find_if
用来查询第一个满足条件的元素:
C++
auto wc = find_if(words.begin(), words.end(), [sz](const std::string &s){
return s.size() >= sz;
});
该lambda使用了值捕获,所以它产生的类将如下:
C++
class SizeComp {
public:
SizeComp(size_t n): sz(n) { }
bool operator() (const string &s) const {
return s.size() >= sz;
}
private:
size_t sz;
}
在该类中,直接多了构造函数和用来保存捕获的值的数据成员,上面lambda的写法就和下面效果一样:
C++
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
这么一看,我们就可以发现lambda其实并没有神奇之处,关于捕获的最特别特性在了解其本质后,也非常容易理解。
总结
本篇文章以重载函数调用运算符开始,逐步揭开lambda的本质,可以看出lambda其实就是一个语法糖,是函数对象的简写方式而已,由编译器为我们做了后续的工作。
当我们能在大脑中为每个lambda都可以自动转换为一个相应的未命名类时,其中的捕获和参数列表也就容易理解,我们使用lambda和阅读lambda将不再困难。