7.3.5 关于lambda的一些问题及有趣的实验

使用lambda函数的时候,捕捉列表不同会导致不同的结果。具体地讲,按值方式传递捕捉列表和按引用方式传递捕捉列表效果是不一样的。对于按值方式传递的捕捉列表,其传递的值在lambda函数定义的时候就已经决定了。而按引用传递的捕捉列表变量,其传递的值则等于lambda函数调用时的值。我们可以看一下代码清单7-25所示的例子。

代码清单7-25


include <iostream>

using namespace std;

int main(){

int j=12;

auto by_val_lambda=[=]{return j+1;};

auto by_ref_lambda=[&]{return j+1;};

cout<<"by_val_lambda:"<<by_val_lambda()<<endl;

cout<<"by_ref_lambda:"<<by_ref_lambda()<<endl;

j++;

cout<<"by_val_lambda:"<<by_val_lambda()<<endl;

cout<<"by_ref_lambda:"<<by_ref_lambda()<<endl;

}

编译选项:g++ -std=c++11 7-3-9.cpp


完成编译运行后,我们可以看到运行结果如下:


by_val_lambda:13

by_ref_lambda:13

by_val_lambda:13

by_ref_lambda:14


第一次调用by_val_lambda和by_ref_lambda时,其运算结果并没有不同。两者均计算的是12+1=13。但在第二次调用by_val_lambda的时候,其计算的是12+1=13,相对地,第二次调用by_ref_lambda时计算的是13+1=14。这个结果的原因是由于在by_val_lambda中,j被视为了一个常量,一旦初始化后不会再改变(可以认为之后只是一个跟父作用域中j同名的常量)而在by_ref_lambda中,j仍在使用父作用域中的值。

因此简单地总结的话,在使用lambda函数的时候,如果需要捕捉的值成为lambda函数的常量,我们通常会使用按值传递的方式捕捉;反之,需要捕捉的值成为lambda函数运行时的变量(类似于参数的效果),则应该采用按引用方式进行捕捉。

此外,关于lambda函数的类型以及该类型跟函数指针之间的关系,读者可能存在一些困惑。回顾之前的例子,大多数情况下把匿名的lambda函数赋值给了一个auto类型的变量,这是一种声明和使用lambda函数的方法。结合之前关于auto的知识,有人会猜测totalChild是一种函数指针类型的变量,而在阅读过7.3.2节关于lambda与仿函数之间关系之后,大多数读者会更倾向于认为lambda是一种自定义类型。而事实上,lambda的类型并非简单的函数指针类型或者自定义类型。

从C++11标准的定义上可以发现,lambda的类型被定义为“闭包”(closure)的类[1],而每个lambda表达式则会产生一个闭包类型的临时对象(右值)。因此,严格地讲,lambda函数并非函数指针。不过C++11标准却允许lambda表达是向函数指针的转换,但前提是lambda函数没有捕捉任何变量,且函数指针所示的函数原型,必须跟lambda函数有着相同的调用方式。我们可以通过代码清单7-26所示的这个例子来说明。

代码清单7-26


int main(){

int girls=3,boys=4;

auto totalChild=->int{return x+y;};

typedef int(*allChild)(int x,int y);

typedef int(*oneChild)(int x);

allChild p;

p=totalChild;

oneChild q;

q=totalChild;//编译失败,参数必须一致

decltype(totalChild)allPeople=totalChild;//需通过decltype获得lambda的类型

decltype(totalChild)totalPeople=p;//编译失败,指针无法转换为lambda

return 0;

}

//编译选项:g++ -std=c++11 7-3-10.cpp


在代码清单7-26所示的例子中,我们可以把没有捕捉列表的totalChild转化为接受参数类型相同的allChild类型的函数指针。不过,转化为参数类型不一致的oneChild类型则会失败。此外,将函数指针转化为lambda也是不成功的(虽然似乎C++11标准并没有明确禁止这一点)。

值得注意的是,程序员也可以通过decltype的方式来获得lambda函数的类型。其方式如同代码清单7-26中声明allPeople一样,虽然使用decltype来获得lambda函数类型的做法不是很常见,但在实例化一些模板的时候使用该方法会较为有用。

除此之外,还有一个问题是关于lambda函数的常量性及mutable关键字的。我们来看看代码清单7-27所示的这个例子[2]

代码清单7-27


int main(){

int val;

//编译失败,在const的lambda中修改常量

auto const_val_lambda=={val=3;};

//非const的lambda,可以修改常量数据

auto mutable_val_lambda==mutable{val=3;};

//依然是const的lambda,不过没有改动引用本身

auto const_ref_lambda=[&]{val=3;};

//依然是const的lambda,通过参数传递val

auto const_param_lambda={v=3;};

const_param_lambda(val);

return 0;

}

//编译选项:g++ -std=c++11 7-3-11.cpp


在代码清单7-27所示的例子中,我们定义了4种不同的lambda函数,这4种lambda函数本身的行为都是一致的,即修改父作用域中传递而来的val参数的值。不过对于const_val_lambda函数而言,编译器认为这是一个错误。


7-3-10.cpp:In lambda function:

7-3-10.cpp:4:43:error:assignment of read-only variable‘val’


而对于声明了mutable属性的mutable_val_lambda函数,以及通过引用传递变量val的const_ref_lambda函数,甚至是通过参数来传递变量val的const_param_lambda,编译器均不会报错。

如我们之前的定义中提到一样,C++11中,默认情况下lambda函数是一个const函数。按照规则,一个const的成员函数是不能在函数体中改变非静态成员变量的值的。但这里明显编译器对不同传参或捕捉列表的lambda函数执行了不同的规则有着不同的见解。其究竟是基于什么样的规则而做出了这样的决定呢?

初看这个问题比较让人困惑,但事实上这跟lambda函数的特别的常量性相关。这里我们还是需要使用7.3.3中的知识将lambda函数转化为一个完整的仿函数,需要注意的是,lambda函数的函数体部分,被转化为仿函数之后会成为一个class的常量成员函数。整个const_val_lambda看起来会是代码清单7-28所示代码的样子。

代码清单7-28


class const_val_lambda{

public:

const_val_lambda(int v):val(v){}

public:

void operator()() const{val=3;}/注意:常量成员函数/

private:

int val;

};

//编译选项:g++ -std=c++11 7-3-12.cpp-c


对于常量成员函数,其常量的规则跟普通的常量函数是不同的。具体而言,对于常量成员函数,不能在函数体内改变class中任何成员变量。因此,如果将代码清单7-28中的仿函数替代代码清单7-27中的lambda函数,编译报错则显得理所应当。

现在问题就比较清楚了。lambda的捕捉列表中的变量都会成为等价仿函数的成员变量(如const_val_lambda中的成员val),而常量成员函数(如operator())中改变其值是不允许的,因而按值捕捉的变量在没有声明为mutable的lambda函数中,其值一旦被修改就会导致编译器报错。

而使用引用的方式传递的变量在常量成员函数中值被更改则不会导致错误。关于这一点在很多C++书籍中已经有过讨论。简单地说,由于函数const_ref_lambda不会改变引用本身,而只会改变引用的值,因此编译器将编译通过。至于按传递参数的const_param_lambda,就更加不会引起编译器的“抱怨”了。

准确地讲,现有C++11标准中的lambda等价的是有常量operator()的仿函数。因此在使用捕捉列表的时候必须注意,按值传递方式捕捉的变量是lambda函数中不可更改的常量(如同我们之前在按值和按引用方式捕捉的讨论中提到的一样,不过现在我们已经在语言层面看到了限制)。标准这么设计可能是源自早期STL算法一些设计上的缺陷(对仿函数没有做限制,从而导致一些设计不算特别良好的算法出错,可以参考Scott Mayer的Effective STL item 39,或者Nicolai M.Josuttis的The C++Standard Library-A Tutorial and Reference关于仿函数的部分)。而更一般地讲,这样的设计有其合理性,改变从上下文中拷贝而来的临时变量通常不具有任何意义。绝大多数时候,临时变量只是用于lambda函数的输入,如果需要输出结果到上下文,我们可以使用引用,或者通过让lambda函数返回值来实现。

此外,lambda函数的mutable修饰符可以消除其常量性,不过这实际上只是提供了一种语法上的可能性,现实中应该没有多少需要使用mutable的lambda函数的地方。大多数时候,我们使用默认版本的(非mutable)的lambda函数也就足够了。

注意 关于按值传递捕捉的变量不能被修改这一点,有人认为这算是“闭包”类型的名称的体现,即在复制了上下文中变量之后关闭了变量与上下文中变量的联系,变量只与lambda函数运算本身有关,不会影响lambda函数(闭包)之外的任何内容。

[1]C++11标准定义,closure类型被定义为特有的(unique)、匿名且非联合体(unnamed nonunion)的class类型。

[2]该例子的问题实际上来源自网络上的一次讨论:http://stackoverflow.com/questions/5501959/why-does-c0xs-lambda-require-mutable-keyword-for-capture-by-value-by-defau。