【C++】11新特性:函数式编程lambda

函数式编程是现代C++里的五种基本编程范式之一,其作用和地位正在不断上升,所以习得这种编程范式对往后的编程意义重大。若掌握了函数式编程,相当于又多了一项非常好用的技能,可以更好地运用STL中的容器和算法,写出更灵活、紧凑、优雅的代码。

个人也在最近开始学习一些现代C++的一些特性,刚好学到了lambda,所以在这里做一个学习总结。
下面就来研究一下lambda函数带给C++的影响。

一、C++中的函数

对于函数式编程,首先要提到的就是函数。

  • C++的函数概念来源于C,是面向过程编程范式的基本部件。严格来说,其实就是严格子过程,是命令的集合、操作执行的抽象。
  • 函数的目的是封装执行的细节,简化程序的复杂度,但因为它有入口参数,有返回值,形式上和数学里的函数很像,所以就被称为“函数”。
  • 在语法层面上,C/C++ 里的函数是比较特别的。虽然有函数类型,但不存在对应类型的变量,不能直接操作,只能用指针去间接操作(即函数指针),这让函数在类型体系里显得有点“格格不入”。
  • 在C或者C++中,所有的函数都是全局的,没有生存周期的概念(可能namespace可以限定应用范围,避免名字冲突)。同时,函数也都是平级的,无法在函数里边再定义函数,也就是无法定义嵌套函数(即函数套函数)。
void multiply(int x, int y)  			//定义函数
{
    cout << x * y << endl; 				// 括号内的为函数具体内容
}
auto func = &mutiply;					//只能用指针去操作函数,指针不是函数
(*func)(3, 4) 							//可以用解引用符号*访问函数
func(3, 4)								//也可以总结调用函数指针

所以,在面向过程编程范式里,函数和变量虽然是程序里最关键的两个组成部分,但却因为没有值、没有作用域而不能一致地处理。函数只能是函数,变量只能是变量,彼此之间虽不能说是“势同水火”,但至少是“泾渭分明”。

二、lambda的引入

上面讲述函数的时候,提到了面向过程编程范式中函数的一些不足之处。C++11也为了解决这些问题,引入了lambda表达式,先来看个简单例子:

auto func = [](int x, int y)			// lambda表达式定义
{
	cout << x * y << endl;				// lambda表达式具体内容
};

func(3, 4);								// 调用lambda表达式

暂时不考虑代码里面的语法细节,单从第一印象上,我们可以看到有一个函数,但更重要的,是这个函数采用了赋值的方式,存入了一个变量。

这就是 lambda 表达式与普通函数最大、也是最根本的区别。

因为 lambda表达式是一个变量,所以,我们就可以“按需分配”,随时随地在调用点“就地”定义函数,限制它的作用域和生命周期,实现函数的局部化。
而且,因为 lambda表达式和变量一样是“一等公民”,用起来也就更灵活自由,能对它做各种运算,生成新的函数。这就像是数学里的复合函数那样,把多个简单功能的小lambda 表达式组合,变成一个复杂的大 lambda 表达式。

如果你比较熟悉 C++98,或者看过一些相关的资料,可能会觉得 lambda 表达式只不过是函数对象(function object)的一种简化形式,只是一个好用的“语法糖”(syntactic sugar)。

大道理上是没错的,但如果把它简单地等同于函数对象,认为它只是免去了手写函数对象的麻烦,那就实在是有点太“肤浅”了。

lambda 表达式为 C++ 带来的变化可以说是革命性的。虽然它表面上只是一个很小的改进,简化了函数的声明 / 定义,但深层次带来的编程理念的变化,却是非常巨大的。

这和 C++ 当初引入 bool、class、template 这些特性时有点类似,乍看上去好像只是一点点的语法改变,但后果却如同雪崩,促使人们更多地去思考、探索新的编程方向,而 lambda 引出的全新思维方式就是“函数式编程”——把写计算机程序看作是数学意义上的求解函数。

C++ 里的 lambda 表达式除了可以像普通函数那样被调用,还有一个普通函数所不具备的特殊本领,就是可以“捕获”外部变量,在内部的代码里直接操作。


int n = 10;                    		// 一个外部变量

auto func = [=](int x)          	// lambda表达式,用“=”值捕获
{
    cout << x * n << endl;        	// 直接操作外部变量
};

func(3);                    		// 调用lambda表达式

在这里,你会发现lambda 表达式就是在其他语言中大名鼎鼎的“闭包”(closure),这让它真正超越了函数和函数对象。

“闭包”是什么,很难一下子说清楚,可以单独去查。说得形象一点,你可以把闭包理解为一个“活的代码块”“活的函数”。它虽然在出现时被定义,但因为保存了定义时捕获的外部变量,就可以跳离定义点,把这段代码“打包”传递到其他地方去执行,而仅凭函数的入口参数是无法做到这一点的。

这就导致函数式编程与命令式编程(即面向过程)在结构上有很大不同,程序流程不再是按步骤执行的“死程序”,而是一个个的“活函数”,像做数学题那样逐步计算、推导出结果,有点像下面的这样:


auto a = [](int x)      	// a函数执行一个功能
            {...} 
auto b = [](double x)    	// b函数执行一个功能
            {...}
auto c = [](string str)  	// c函数执行一个功能
            {...}

auto f = [](...)        	// f函数执行一个功能
            {...}

return f(a, b, c)           // f调用a/b/c运算得到结果

你也可以再对比面向对象来理解。在面向对象编程里,程序是由一个个实体对象组成的,对象通信完成任务。而在函数式编程里,程序是由一个个函数组成的,函数互相嵌套、组合、调用完成任务。

这些话,听其实其实不好理解,我们可以来看看 lambda 表达式的使用细节,掌握了以后多用,就能够更好地理解了。

三、使用 lambda 的注意事项

要学好用好 lambda,我觉得就是三个重点:语法形式,变量捕获规则,还有泛型的用法。

1.lambda的形式

首先知道的是,C++ 没有为 lambda表达式引入新的关键字,并没有“lambda”这样的词汇,而是用了一个特殊的形式“[]”,术语叫“lambda 引出符”(lambda introducer)。

在 lambda 引出符后面,就可以像普通函数那样,用圆括号声明入口参数,用花括号定义函数体。

下面的代码展示了我最喜欢的一个 lambda 表达式(也是最简单的):

auto f1 = [](){};      // 相当于空函数,什么也不做

这行语句定义了一个相当于空函数的 lambda 表达式,三个括号“排排坐”,看起来有种奇特的美感,让人不由得想起那句经典台词:“一家人最要紧的就是整整齐齐。”(不过还是差了个尖括号 <>)。

当然了,实际开发中不会有这么简单的 lambda表达式,它的函数体里可能会有很多语句,所以一定要有良好的缩进格式——特别是有嵌套定义的时候,尽量让人能够一眼就看出 lambda表达式的开始和结束,必要的时候可以用注释来强调。


auto f2 = []()                 	// 定义一个lambda表达式
{
    cout << "lambda f2" << endl;

    auto f3 = [](int x)         // 嵌套定义lambda表达式
    {
        return x*x;
    };// lambda f3              // 使用注释显式说明表达式结束

    cout << f3(10) << endl;
};  // lambda f2               	// 使用注释显式说明表达式结束

你可能注意到了,在 lambda 表达式赋值的时候,我总是使用 auto 来推导类型。这是因为,在 C++ 里,每个 lambda 表达式都会有一个独特的类型,而这个类型只有编译器才知道,我们是无法直接写出来的,所以必须用 auto。

不过,因为 lambda 表达式毕竟不是普通的变量,所以 C++ 也鼓励程序员尽量“匿名”使用 lambda表达式。也就是说,它不必显式赋值给一个有名字的变量,直接声明就能用,免去你费力起名的烦恼。
这样不仅可以让代码更简洁,而且因为“匿名”,lambda表达式调用完后也就不存在了(也有被拷贝保存的可能),这就最小化了它的影响范围,让代码更加安全。


vector<int> v = {3, 1, 8, 5, 0};     	// 标准容器

cout << *find_if(begin(v), end(v),   	// 标准库里的查找算法
            [](int x)                	// 匿名lambda表达式,不需要auto赋值
            {
                return x >= 5;        	// 用做算法的谓词判断条件 
            }                        	// lambda表达式结束
        )
     << endl;                        	// 语句执行完,lambda表达式就不存在了
2.lambda的变量捕获

lambda 的“捕获”功能需要在“[]”里做文章,由于实际的规则太多太细,记忆、理解的成本高,所以我只说几个要点,帮你快速掌握它们:

  • “[=]”表示按值捕获所有外部变量,表达式内部是值的拷贝,并且不能修改;
  • “[&]”是按引用捕获所有外部变量,内部以引用的方式使用,可以修改;
  • 你也可以在“[]”里明确写出外部变量名,指定按值或者按引用捕获,C++ 在这里给予了非常大的灵活性。

int x = 33;               	// 一个外部变量

auto f1 = [=]()           	// lambda表达式,用“=”按值捕获
{
    //x += 10;            	// x只读,不允许修改
};

auto f2 = [&]()         	// lambda表达式,用“&”按引用捕获
{
    x += 10;            	// x是引用,可以修改
};

auto f3 = [=, &x]()     	// lambda表达式,用“&”按引用捕获x,其他的按值捕获
{
    x += 20;              	// x是引用,可以修改
};

“捕获”也是使用 lambda 表达式的一个难点,关键是要理解“外部变量”的含义。

可以简单地按照其他语言的习惯,称之为“upvalue”,也就是在 lambda 表达式定义之前所有出现的变量,不管它是局部的还是全局的。

这就有一个变量生命周期的问题。

使用“[=]”按值捕获的时候,lambda 表达式使用的是变量的独立副本,非常安全。而使用“[&]”的方式捕获引用就存在风险,当lambda 表达式在离定义点“很远的地方”被调用的时候,引用的变量可能发生了变化,甚至可能会失效,导致难以预料的后果。

所以,建议在使用捕获功能的时候要小心,对于“就地”使用的小 lambda 表达式,可以用“[&]”来减少代码量,保持整洁;而对于非本地调用、生命周期较长的 lambda 表达式应慎用“[&]”捕获引用,而且,最好是在“[]”里显式写出变量列表,避免捕获不必要的变量。


class DemoLambda final
{
private:
    int x = 0;
public:
    auto print()              	// 返回一个lambda表达式供外部使用
    {
        return [this]()      	// 显式捕获this指针
        {
            cout << "member = " << x << endl;
        };
    }
};
3.泛型的lambda

在 C++14 里,lambda 表达式又多了一项新本领,可以实现“泛型化”,相当于简化了的模板函数,具体语法还是利用了“多才多艺”的 auto:


auto f = [](const auto& x)        	// 参数使用auto声明,泛型化
{
    return x + x;
};

cout << f(3) << endl;             	// 参数类型是int
cout << f(0.618) << endl;         	// 参数类型是double

string str = "matrix";
cout << f(str) << endl;          	// 参数类型是string

这个新特性在写泛型函数的时候非常方便,摆脱了冗长的模板参数和函数参数列表。如果你愿意的话,可以尝试在今后的代码里都使用 lambda 来代替普通函数,能够少写很多代码。

四、总结

lambda 不仅仅是对旧有函数对象的简单升级,而是更高级的“闭包”,给 C++ 带来了新的编程理念:函数式编程范式。

函数在 C 语言中其实就是一个“静止”的代码块,只能被动地接受输入然后输出。而 lambda 的出现则让函数“活”了起来,极大地提升了函数的地位和灵活性。

虽然目前在 C++ 里,纯函数式编程还比较少见,但“轻度”使用 lambda 表达式也能够改善代码,比如用“map+lambda”的方式来替换难以维护的 if/else/switch,可读性要比大量的分支语句好得多。

小结一下今天的要点内容:

  • lambda 表达式是一个闭包,能够像函数一样被调用,像变量一样被传递;
  • 可以使用 auto 自动推导类型存储 lambda 表达式,但 C++ 鼓励尽量就地匿名使用,缩小作用域;
  • lambda 表达式使用“[=]”的方式按值捕获,使用“[&]”的方式按引用捕获,空的“[]”则是无捕获(也就相当于普通函数);
  • 捕获引用时必须要注意外部变量的生命周期,防止变量失效;
  • C++14 里可以使用泛型(auto参数)的 lambda 表达式,相当于简化的模板函数。
  • 需要注意的是,和 C++ 里的大多数新特性一样,滥用 lambda 表达式的话,就会产生一些难以阅读的代码,比如多个函数的嵌套和串联、调用层次过深。这也需要你在实践中慢慢积累经验,找到最适合你自己的使用方式。

问题一:这里是引用 用“map+lambda”的方式来替换难以维护的 if/else/switch,举例?

这个需要用到std::function,存储lambda表达式,比如

map<int, function<void()>> funcs;
funcs[1] = [](){...};
funcs[7] = [](){...};
funcs[42] = [](){...};

return funcs[x]();

这样,就把switch/case语句转换成了function+lambda,让map替你自动switch。

问题二:函数名和函数指针是否对等?

一般来说,函数名就是相当于函数指针,但对于成员函数来说,必须要加上&。

问题三: 什么情况下按值捕获?什么情况下按引用捕获呢?

一般而言,按值捕获是比较安全的做法。按引用捕获时则需要更小心些,必须能够确保被捕获的变量和 lambda 表达式的生命期至少一样长,并在有下面需求之一时才使用:

  • 需要在 lambda 表达式中修改这个变量并让外部观察到
  • 需要看到这个变量在外部被修改的结果
  • 这个变量的复制代价比较高
  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值