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,可以看到如下运行结果:
这里我们注意到,结果的第二行和第一行没有区别。仔细查过资料之后,我们发现,内置的仿函数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++程序员梦寐以求的。