7.3.6 lambda与STL

如7.3.3节中讲到的,lambda对C++11最大的贡献,或者说是改变,应该在STL库中。而更具体地说,我们会发现使用STL的算法更加容易了,也更加容易学习了。

首先我们来看一个最为常见的STL算法for_each。简单地说,for_each算法的原型如下:


UnaryProc for_each(InputIterator beg,InputIterator end,UnaryProc op)


让我们忽略一些细节,大概讲,for_each算法需要一个标记开始的iterator,一个标记结束的iterator,以及一个接受单个参数的“函数”(即一个函数指针、仿函数或者lambda函数)。

for_each的一个示意实现如下:


for_each(iterator begin,iterator end,Function fn){

for(iterator i=begin;i!=end;++i)fn(*i);

}


通过for_each,我们可以完成各种循环操作,如代码清单7-29所示。

代码清单7-29


include <vector>

include <algorithm>

using namespace std;

vector<int> nums;

vector<int> largeNums;

const int ubound=10;

inline void LargeNumsFunc(int i){

if(i>ubound)

largeNums.push_back(i);

}

void Above(){

//传统的for循环

for(auto itr=nums.begin();itr!=nums.end();++itr){

if(*itr>=ubound)

largeNums.push_back(*itr);

}

//使用函数指针

for_each(nums.begin(),nums.end(),LargeNumsFunc);

//使用lambda函数和算法for_each

for_each(nums.begin(),nums.end(),={

if(i>ubound)

largeNums.push_back(i);

});

}

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


在代码清单7-29的例子当中,我们分别用了3种方式来遍历一个vector nums,找出其中大于ubound的值,并将其写入另外一个vector largeNums中。第一种是传统的for循环;第二种,则更泛型地使用了for_each算法以及函数指针;第三种同样使用了for_each,但是第三个参数传入的是lambda函数。

首先必须指出的是使用for_each的好处。如Scott Mayer在Effective STL(item43)中提到的一样,使用for_each算法相较于手写的循环在效率、正确性、可维护性上都具有一定优势。最典型的,程序员不用关心iterator,或者说循环的细节,只需要设定边界,作用于每个元素的操作,就可以在近似“一条语句”内完成循环,正如函数指针版本和lambda版本完成的那样。

那么我们再比较一下函数指针方式以及lambda方式。函数指针的方式看似简洁,不过却有很大的缺陷。第一点是函数定义在别的地方,比如很多行以前(后)或者别的文件中,这样的代码阅读起来并不方便。第二点则是出于效率考虑,使用函数指针很可能导致编译器不对其进行inline优化(inline对编译器而言并非强制),在循环次数较多的时候,内联的lambda和没有能够内联的函数指针可能存在着巨大的性能差别。因此,相比于函数指针,lambda拥有无可替代的优势。

此外,函数指针的应用范围相对狭小,尤其是我们需要具备一些运行时才能决定的状态的时候,函数指针就会捉襟见肘了。倘若回到10年前(C++98时代),遇到这种情况的时候,迫切想应用泛型编程的C++程序员或许会毫不犹豫地使用仿函数,不过现在我们则没有必要那么做。

我们稍微修改一下代码清单7-29所示的例子,如代码清单7-30所示。

代码清单7-30


include <vector>

include <algorithm>

using namespace std;

vector<int> nums;

vector<int> largeNums;

class LNums{

public:

LNums(int u):ubound(u){}

void operator()(int i)const

{

if(i>ubound)

largeNums.push_back(i);

}

private:

int ubound;

};

void Above(int ubound){

//传统的for循环

for(auto itr=nums.begin();itr!=nums.end();++itr){

if(*itr>=ubound)

largeNums.push_back(*itr);

}

//使用仿函数

for_each(nums.begin(),nums.end(),LNums(ubound));

//使用lambda函数和算法for_each

for_each(nums.begin(),nums.end(),={

if(i>ubound)

largeNums.push_back(i);

});

}

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


在代码清单7-30中,为了函数的最大可用性,我们把代码清单7-29中的全局变量ubound变成了代码清单7-30中的Above的参数。这样一来,我们传递给for_each函数的第三个参数(函数指针,仿函数或是lambda)而言就需要知道ubound是多少。由于函数只能通过参数传递这个状态(ubound),那么除非for_each调用函数的方式做出改变(比如增加调用函数的参数),否则编译器不会让其通过编译。因此,这个时候拥有状态的仿函数是更佳的选择。不过比较上面的代码,我们可以直观地看到lambda函数比仿函数书写上的简便性。因此,lambda在这里依然是最佳的选择(如果读者觉得这样还不够过瘾的话,那可以尝试一下C++11新风格的for循环。对于代码清单7-30所示的这个例子,新风格的for会更加简练)。

注意 事实上,STL算法对传入的“函数”的原型有着严格的说明,像for_each就只能传入使用单参数进行调用的“函数”。有的时候用户可以通过STL的一些adapter来改变参数个数,不过必须了解的是,这些adapter其实也是仿函数。

在C++98时代,STL中其实也内置了一些仿函数供程序员使用。代码清单7-31所示的例子中,我们将重点比较一下这些内置仿函数与lambda使用上的特点。

代码清单7-31


include <vector>

include <algorithm>

using namespace std;

extern vector<int> nums;

void OneCond(int val){

//传统的for循环

for(auto i=nums.begin();i!=nums.end();i++)

if(*i==val)break;

//内置的仿函数(template)equal_to,通过bind2nd使其成为单参数调用的仿函数

find_if(nums.begin(),nums.end(),bind2nd(equal_to<int>(),val));

//使用lambda函数

find_if(nums.begin(),nums.end(),={

return i==val;

});

}

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


在代码清单7-31中,我们还是列出了3种方式来寻找vector nums中第一个值等于val的元素。我们可以看一下使用内置仿函数的方式。没有太多接触过STL算法的人可能对bind2nd(equal_to<int>(),val)这段代码相当迷惑,但简单地说,就是定义了一个功能是比较i==val的仿函数,并通过一定方式(bind2nd)使其函数调用接口只需要一个参数即可。反观lambda函数,其意义简洁明了,使用者使用的时候,也不需要有太多的背景知识。

但现在为止,我们并不能说lambda已经赢过了内置仿函数。而实际上,内置的仿函数应用范围很受限制,我们改动一下代码清单7-31,使其稍微复杂一点,如代码清单7-32所示。

代码清单7-32


include <vector>

include <algorithm>

using namespace std;

extern vector<int> nums;

void TwoCond(int low,int high){

//传统的for循环

for(auto i=nums.begin();i!=nums.end();i++)

if(i>=low&&i<high)break;

//利用了3个内置的仿函数,以及非标准的compose2

find_if(nums.begin(),nums.end(),

compose2(logical_and<bool>(),

bind2nd(less<int>(),high),

bind2nd(greater_equal<int>(),low)));

//使用lambda函数

find_if(nums.begin(),nums.end(),={

return i>=low&&i<high;

});

}

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


在代码清单7-32中,我们将代码清单7-31中的判断条件稍微调整得复杂了一些,即需找到vector nums中第一个值介于[low,high)间的元素。这里我们看到内置仿函数变得异常复杂,而且程序员不得不接受使用非标准库函数的事实(compose2)。这对于需要移植性的程序来说,是非常难以让人接受的。即使之前曾经很多人对内置仿函数这样的做法点头称赞,但现实情况下可能人人都必须承认:lambda版本的实现非常的清晰,而且这一次代码量甚至少于内置仿函数的版本,简直无可挑剔。当然,这还不是全部,我们来看下一个例子,如代码清单7-33所示。

代码清单7-33


include <vector>

include <algorithm>

include <iostream>

using namespace std;

vector<int> nums;

void Add(const int val){

auto print=[&]{

for(auto s:nums){cout<<s<<'\t';}

cout<<endl;

};

//传统的for循环方式

for(auto i=nums.begin();i!=nums.end();++i){

i=i+val;

}

print();

//试一试for_each及内置仿函数

for_each(nums.begin(),nums.end(),bind2nd(plus<int>(),val));

print();

//实际这里需要使用STL的一个变动性算法:transform

transform(nums.begin(),nums.end(),nums.begin(),bind2nd(plus<int>(),val));

print();

//不过在lambda的支持下,我们还是可以只使用for_each

for_each(nums.begin(),nums.end(),={

i+=val;

});

print();

}

int main(){

for(int i=0;i<10;i++){

nums.push_back(i);

}

Add(10);

return 1;

}

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


在代码清单7-33所示的例子中,我们试图改变vector中的内容,即将vector中所有的元素的数值加10。这里我们还是使用了传统的for方式、内置仿函数的方式,以及lambda的方式。此外,为了方便调试,我们使用了一个lambda函数来打印局部运行的结果。

在机器上编译运行代码清单7-33,可以看到如下运行结果:

7.3.6 lambda与STL - 图1

这里我们注意到,结果的第二行和第一行没有区别。仔细查过资料之后,我们发现,内置的仿函数plus<int>()仅仅将加法结果返回,为了将返回结果再应用于vector nums,通常情况下,我们需要使用transform这个算法。如我们第三段代码所示,transform会遍历nums,并将结果写入nums.begin()出首地址的目标区(第三参数)。

事实上,在书写STL的时候人们总是会告诫新手for_each和transform之间的区别,因为for_each并不像transform一样写回结果。这在配合STL内置的仿函数的时候就会有些使用上的区别。但在C++11的lambda来临的时候,这样的困惑就变少了。因为内置的仿函数并非必不可少,lambda中包含的代码逻辑一目了然,使用STL算法的规则也因此变得简单多了。

我们来看最后一个STL的例子,如代码清单7-34所示。

代码清单7-34


include <vector>

include <algorithm>

include <iostream>

using namespace std;

void Stat(vector<int> &v){

int errors;

int score;

auto print=[&]{

cout<<"Errors:"<<errors<<endl

<<"Score:"<<score<<endl;

};

//使用accumulate算法,需要两次循环

errors=accumulate(v.begin(),v.end(),0);

score=accumulate(v.begin(),v.end(),100,minus<int>());

print();

errors=0;

score=100;

//使用lambda则只需要一次循环

for_each(v.begin(),v.end(),{

errors+=i;

score-=i;

});

print();

}

int main(){

vector<int> v(10);

generate(v.begin(),v.end(),[]{

return rand()%10;

});

Stat(v);

}

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


代码清单7-34是一个区间统计的例子,通常情况下,我们可以使用STL的accumulate算法及内置仿函数来完成这个运算。从代码的简洁性上来看,这样做好像也不错。不过实际上我们产生了两次循环,一次计算errors,一次计算score。而使用lambda和万能的for_each的版本的时候,我们可以从源代码层将循环合并起来。事实上,我们可能在实际工作中能够合并的循环更多。如果采用accumulate的方式,而编译器不能合理有效地合并循环的话,我们可能就会遭受一定的性能损失(比如cache方面)。

可以看到,对于C++的STL和泛型编程来讲,lambda存在的意义重大,尤其是对于STL的算法方面,lambda的出现使得学习使用STL算法的代价大大降低,甚至会改变一些使用STL算法的思维。当然,在一些情况下,lambda还能在代码自说明、性能上具有一定的优势。这些都是很多C++程序员,尤其是喜欢泛型编程的C++程序员梦寐以求的。