C++ Lambda表达式详解:从捕获列表到底层原理

目录

[1.1 lambda表达式语法](#1.1 lambda表达式语法)

[1.1.1 什么是lambda表达式](#1.1.1 什么是lambda表达式)

[1.1.2 lambda表达式的格式](#1.1.2 lambda表达式的格式)

[1.2 捕捉列表](#1.2 捕捉列表)

[1.2.1 捕捉列表的作用](#1.2.1 捕捉列表的作用)

[1.2.2 捕捉规则](#1.2.2 捕捉规则)

[1.2.3 捕捉方式](#1.2.3 捕捉方式)

[1.3 lambda的应用](#1.3 lambda的应用)

[1.4 lambda的原理](#1.4 lambda的原理)

[1.5 lambda与函数指针](#1.5 lambda与函数指针)

[1.5.1 普通函数与函数指针](#1.5.1 普通函数与函数指针)

[1.5.2 lambda不是函数指针?](#1.5.2 lambda不是函数指针?)

[1.5.3 为什么无捕获 lambda 可以赋值给函数指针?](#1.5.3 为什么无捕获 lambda 可以赋值给函数指针?)

[1.5.4 有捕获 lambda 为什么不行?](#1.5.4 有捕获 lambda 为什么不行?)

[1.5.5 总结:](#1.5.5 总结:)


1.1 lambda表达式语法

1.1.1 什么是lambda表达式

lambda 表达式本质上会生成一个匿名的闭包对象(Closure Object),与普通函数不同的是,它既可以像函数一样被调用,又能够捕获并保存其所在作用域中的变量。Lambda 表达式的类型是一个由编译器生成的、唯一的、匿名的闭包类型(Closure Type),由于该类型名无法直接书写,因此在实际开发中通常使用 auto、模板参数或 std::function 来接收和使用 lambda 对象。

关于闭包对象和闭包类型的具体实现原理,我们将在后续 lambda 原理章节中详细介绍。

1.1.2 lambda表达式的格式

复制代码
[capture-list] (parameters)-> return type { function body }

capture-list:捕捉列表,该列表位于lambda函数的开始位置,捕捉列表为空也不能省略,因为编译器需要根据捕捉列表来判断接下来的代码是否为lambda函数,具体细节将在后续捕捉列表章节里详细讲解。

(parameters):参数列表,与普通函数的参数列表功能一样,用来接受传递的参数,如果不需要传递参数,则该参数列表可以省略不写。

-> return_type:返回值类型,与普通函数返回值功能一样,但是对于没有返回值的lambda函数,此部分可以省略不写,对于返回值类型明确的函数,此部分也可以省略不写,由编译器根据返回值的类型进行推导返回类型。

{ function body }:函数体,与普通函数的函数体一样,但在该函数体内,可以使用捕捉列表中捕获的变量,函数体为空也不能省略。

示例:

cpp 复制代码
#include <iostream>
using namespace std;

int main()
{
	// 一个完整的lambda表达式
	auto add1 = [](int a, int b)->int
	{
		return a + b;
	}; // 这个分号一定要加,它不是lambda语法所需要的,它是赋值这条语句所需要的

	// 跟函数的使用方法一摸一样
	cout << add1(3, 5) << endl; 

	// 1.捕捉列表为空不能省略
	// 2.参数为空可以省略
	// 3.返回值可以省略
	// 4.函数体不可以省略

	auto print = [] 
	{
		cout << "hello world" << endl;
	};

	print();

	int a = 3, b = 5;
	auto my_swap = [](int& x, int& y)
	{
		int tmp = x;
		x = y;
		y = tmp;
	}; 

	my_swap(a, b);

	cout << a << " " << b << endl;

	return 0;
}

1.2 捕捉列表

1.2.1 捕捉列表的作用

在lambda表达式中变量的使用规则跟普通函数一样,如lambda函数体和参数中的变量,全局变量等,但lambda通过捕捉列表可以使用lambda对象所处域中的变量。

1.2.2 捕捉规则

普通捕获列表(如x, \&x)只能捕获具有名字的变量,通常是左值。对于临时对象或移动语义场景,需要使用 C++14 引入的初始化捕获(Init Capture),例如x = 10p = std::move(ptr)

cpp 复制代码
// C++11
// 合法
int x = 10;
auto f = [x]()
{
    return x;
};

// 不合法
auto f = [10]()
{
    return 10;
};

// C++14 这里的捕捉不是普通捕捉,而是初始化捕捉,后续lambda底层原理会讲解原因
auto f = [x = 10]()
{
    return x;
};
auto ptr = std::make_unique<int>(100);

auto f = [p = std::move(ptr)]()
{
    return *p;
};

lambda 表达式如果在函数局部域中,它可以捕捉lambda位置之前定义的变量,不能捕捉静态局部变量和全局变量,静态局部变量和全局变量也不需要捕捉,lambda 表达式中可以直接使用。这也意味着lambda 表达式如果定义在全局位置,捕捉列表必须为空。

cpp 复制代码
int x = 0;
// 对于全局的lambda表达式,不需要捕捉任何变量
// 因为全局变量不用捕捉就可以使用,所以捕捉列表必须为空
auto func1 = []()
{
	++x;
};
// 编译错误
// 无法在lambda表达式中捕捉带有静态存储持续时间的变量,
// 也就是无法捕捉静态区的变量
// auto func2 = [x]()
// {
// 	++x;
// };
// [&x] 这样写也不对
//

默认情况下,lambda 捕捉列表是被const修饰的,也就是说传值捕捉的过来的对象不能修改,mutable加在参数列表的后面可以取消其常量性,也就说使用该修饰符后,传值捕捉的对象就可以修改了,但是修改还是形参对象,不会影响实参。使用该修饰符后,参数列表不可省略(即使参数为空)。

cpp 复制代码
#include <iostream>
using namespace std;

int main()
{
	// 只能用当前lambda局部域和捕捉的对象和全局对象​
	int a = 0, b = 1, c = 2, d = 3;
	auto func1 = [a, &b]
	{
		// 值捕捉的变量不能修改,引用捕捉的变量可以修改​
		// a++;
		b++;
		int ret = a + b;
		return ret;
	};
	cout << func1() << endl;

	// 传值捕捉本质是一种拷贝,并且被const修饰了​
	// mutable相当于去掉const属性,可以修改了​
	// 但是修改了不会影响外面被捕捉的值,因为是一种拷贝​
	auto func2 = [a, b, c, d]() mutable
	{
		a++;
		b++;
		c++;
		d++;
		return a + b + c + d;
	};
	return 0;
}

1.2.3 捕捉方式

第一种捕捉方式:在捕捉列表中显示的传值 捕捉和传引用捕捉,捕捉的多个变量用逗号分割。

例如x, y, \&z 表示 x 和 y 是值捕捉,z 是引用捕捉。

第二种捕捉方式:在捕捉列表中隐式捕捉,我们在捕捉列表中写一个**"=" 表示隐式值捕捉** ,在捕捉列表中写一个 "&" 表示隐式引用捕捉编译器会根据函数体内使用了哪些外部变量,就捕捉哪些。编译器不会捕捉全部的外部变量。

第三种捕捉方式:在捕捉列表中混合使用隐式捕捉和显示捕捉,=, \&x 表示其他变量隐式值捕捉,x 变量显示引用捕捉。\&, x, y表示其他变量引用捕捉,x和y值捕捉。当使用混合捕捉时,第一个元素必须是&或=,并且&混合捕捉时,后面的捕捉变量必须是值捕捉,同理=混合捕捉时,后面的捕捉变量必须是引用捕捉。

cpp 复制代码
#include <iostream>
using namespace std;

int main()
{
	// 传值捕捉和传引用捕捉
	int a = 0, b = 1, c = 2, d = 3;
	auto func1 = [a, &b]
	{
		b++;
		int ret = a + b;
		return ret;
	};
	cout << func1() << endl;

	// 隐式值捕捉​
	// 用了哪些变量就捕捉哪些变量​
	auto func2 = [=]
	{
		int ret = a + b + c;
		return ret;
	};
	cout << func2() << endl;

	// 隐式引用捕捉​
	// 用了哪些变量就捕捉哪些变量​
	auto func3 = [&]
	{
		a++;
		c++;
		d++;
	};
	func3();
	cout << a << " " << b << " " << c << " " << d << endl;

	// 混合捕捉1​
	auto func4 = [&, a, b]
	{
		c++;
		d++;
		return a + b + c + d;
	};
	func4();
	cout << a << " " << b << " " << c << " " << d << endl;

	// 混合捕捉2
	auto func5 = [=, &a, &b]
	{
		a++;
		b++;
		return a + b + c + d;
	};
	func5();
	cout << a << " " << b << " " << c << " " << d << endl;

	return 0;
}

1.3 lambda的应用

在学习lambda表达式之前,我们使用可调用对象只有函数指针和仿函数,函数指针的类型定义起来比较麻烦,仿函数需要定义一个类,也比较麻烦。使用lambda去定义可调用对象,既简单又方便。lambda在很多地方用起来也很好用,比如线程中定义线程的执行函数逻辑,智能指针中定义删除器等。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

struct Goods
{
	string _name;  // 名字​
	double _price; // 价格​
	int _evaluate; // 评价​
	// ...
	Goods(const char *str, double price, int evaluate)
		: _name(str), _price(price), _evaluate(evaluate)
	{
	}
};
struct ComparePriceLess
{
	bool operator()(const Goods &gl, const Goods &gr)
	{
		return gl._price < gr._price;
	}
};
struct ComparePriceGreater
{
	bool operator()(const Goods &gl, const Goods &gr)
	{
		return gl._price > gr._price;
	}
};
int main()
{
	vector<Goods> v = {{"苹果", 2.1, 5}, {"香蕉", 3, 4}, {"橙子", 2.2, 3}, {"菠萝", 1.5, 4}};
	// 类似这样的场景,我们实现仿函数对象或者函数指针进行传参就很麻烦,
    // 如果代码不说明注释,还需要去查看它的定义,代码不清晰简洁
	// 对于lambda表达式的优势显而易见
	sort(v.begin(), v.end(), ComparePriceLess());
	sort(v.begin(), v.end(), ComparePriceGreater());
	sort(v.begin(), v.end(), [](const Goods &g1, const Goods &g2)
		 { return g1._price < g2._price; });
	sort(v.begin(), v.end(), [](const Goods &g1, const Goods &g2)
		 { return g1._price > g2._price; });
	sort(v.begin(), v.end(), [](const Goods &g1, const Goods &g2)
		 { return g1._evaluate < g2._evaluate; });
	sort(v.begin(), v.end(), [](const Goods &g1, const Goods &g2)
		 { return g1._evaluate > g2._evaluate; });
	return 0;
}

1.4 lambda的原理

闭包类型 = 类

cpp 复制代码
// 对于lambda表达式
auto f = [x](int y)
{
    return x + y;
};

// 编译器在编译时会生成一个类

class __ClosureType
{
private:
    int x;

public:
    __ClosureType(int value)
        : x(value)
    {}

    int operator()(int y) const
    {
        return x + y;
    }
};
// 对于 __ClosureType 这个编译器生成类就是闭包类型

闭包对象 = 用闭包类型定义一个对象

cpp 复制代码
__ClosureType f(x);

// f 就是闭包对象

为什么叫闭包?

因为它把「函数 + 运行环境」封装在一起了。

lambda的本质就是编译器会生成一个对应的仿函数的类。仿函数的类名是编译时按照一定的规则生成的,保证生成不同的lambda对象的类名,lambda参数、返回类型、函数体就是仿函数operator()的参数、返回类型、函数体,lambda的捕捉列表本质是生成的仿函数类的成员变量,也就是说捕捉列表的变量都是lambda类构造函数的实参,用来走初始化列表初始化生成的仿函数类的成员变量,对于隐式捕捉,编译器首先检查函数体内使用了哪些外部变量,从而编译时生成对应的成员变量。对于值捕捉的变量默认情况下自带const属性,是因为编译器生成的operator被const修饰,无法修改成员变量。

函数调用

示例:通过汇编层面理解lambda表达式的本质

cpp 复制代码
#include <iostream>
using namespace std;
class Rate
{
public:
	Rate(double rate)
		: _rate(rate)
	{
	}
	double operator()(double money, int year)
	{
		return money * _rate * year;
	}

private:
	double _rate;
};

int main()
{
	double rate = 0.49;
	// lambda
	auto r2 = [rate](double money, int year)
	{
		return money * rate * year;
	};
	// 函数对象​
	Rate r1(rate);
	r1(10000, 2);
	
	r2(10000, 2);
	return 0;
}

1.5 lambda与函数指针

很多初学者会认为lambda既然能像函数一样调用,那么它应该就是一个函数指针,实际上并不是,lambda的本质是闭包对象,而函数指针只是一个保存函数地址的指针,两者完全不是同一个东西。

1.5.1 普通函数与函数指针

cpp 复制代码
void func()
{
    cout << "hello" << endl;
}
// 函数名可以退化成函数指针

void (*p)() = func;

p();
func();

// 此时 p() 和 func() 是等价的
// 这里的 p 保存的是函数入口地址

1.5.2 lambda不是函数指针?

cpp 复制代码
auto f = []()
{
    cout << "hello" << endl;
};
// 编译器会推导为一个类
class ClosureType
{
public:
    void operator()() const
    {
        cout << "hello" << endl;
    }
};

f(); 实际上是 f.operator()();
因此lambda是对象,不是函数指针

1.5.3 为什么无捕获 lambda 可以赋值给函数指针?

cpp 复制代码
auto f = []()
{
    cout << "hello" << endl;
};
// 合法
void (*p)() = f;

// 为什么呢?
// 因为无捕获 lambda 没有状态
// 编译器会额外生成一个静态函数

class ClosureType
{
public:
    void operator()() const
    {
        cout << "hello" << endl;
    }

    static void __invoke()
    {
        cout << "hello" << endl;
    }
};
// 然后void (*p)() = f; 等价于 void (*p)() = &ClosureType::__invoke;
// 能够隐式类型转换为函数指针

1.5.4 有捕获 lambda 为什么不行?

cpp 复制代码
int x = 10;

auto f = [x]()
{
    cout << x << endl;
};
// 编译器生成的
class ClosureType
{
private:
    int x;

public:
    ClosureType(int value)
        : x(value)
    {
    }

    void operator()() const
    {
        cout << x << endl;
    }
};
// 这里的 x 已经称为对象的成员变量
// 调用时需要this->x 才能访问
// 因此 f(); == f.operator()(); 需要对象参与
// 而函数指针只有void (*p)() 它只知道函数地址,不知道对象地址
// 因此无法完成调用 void (*p)() = f; 就会编译失败

1.5.5 总结:

Lambda 本质上是一个闭包对象,而函数指针仅保存函数地址。对于无捕获 Lambda,由于其不包含任何状态信息,编译器可以将其转换为对应的函数指针;而有捕获 Lambda 内部保存了成员数据,调用时依赖具体对象,因此无法转换为普通函数指针。

相关推荐
为何创造硅基生物1 小时前
LVGL
c++·ui
MATLAB代码顾问1 小时前
Python NumPy数值计算核心指南
开发语言·python·numpy
只做人间不老仙1 小时前
C++ grpc 拦截器示例学习
开发语言·c++·学习
踏着七彩祥云的小丑1 小时前
Go学习第7天:Map集合 + 递归函数 + 类型转换
开发语言·学习·golang·go
何以解忧,唯有..1 小时前
Go语言变量的声明方式详解
开发语言·后端·golang
半夜燃烧的香烟1 小时前
springboot3.0 集成minio上传文件,支持多个桶名
java·开发语言·spring boot
不会C语言的男孩1 小时前
Linux 系统编程 · 第 1 章:Linux 系统概述
c语言·开发语言
码云骑士2 小时前
05-Python字典底层原理-Hash表与有序性的真相
开发语言·python·哈希算法
J2虾虾2 小时前
Android支持Java语言的标准
android·java·开发语言