天天看点

C++ lambda 表达式深剖

目录

  • ​​传统艺能😎​​
  • ​​概念🤔​​
  • ​​语法🤔​​
  • ​​捕获方式🤔​​
  • ​​相互赋值😎​​
  • ​​mutable🤔​​
  • ​​底层原理🤔​​

传统艺能😎

C++ lambda 表达式深剖

概念🤔

自 C++11 开始,C++ 有三种方式可以创建/传递一个可调用的对象:

函数指针

仿函数

Lambda 表达式

lambda 表达式本质上就是一个匿名函数,它是一个强大的功能,他的使用可以简化代码,而且可以提高代码可读性

这里举一个实例,以一个物品为例:

struct Items
{
  string _name;  //名字
  double _price; //价格
  int _num;      //数量
};      

如果要对若干对象分别按照价格和数量进行升序、降序排序。

首先想到可以使用 sort 函数,但由于这里待排序的元素为自定义类型,因此需要用户自行定义排序时的比较规则,要控制 sort 比较方式有常见的两种方法,一是对商品类的的 () 运算符进行重载,二是通过仿函数来指定比较方式。

重载当前类的 () 运算符是不可行的,因为这里要求分别按照价格和数量进行升序、降序排序,每次排序都去修改一下比较方式是很低效且笨重的做法

比如我用仿函数的方式进行比较:

struct ComparePriceLess//价格降序
{
  bool operator()(const Goods& g1, const Goods& g2)
  {
    return g1._price < g2._price;
  }
};
struct ComparePriceGreater//价格升序
{
  bool operator()(const Goods& g1, const Goods& g2)
  {
    return g1._price > g2._price;
  }
};
struct CompareNumLess//数量降序
{
  bool operator()(const Goods& g1, const Goods& g2)
  {
    return g1._num < g2._num;
  }
};
struct CompareNumGreater//数量升序
{
  bool operator()(const Goods& g1, const Goods& g2)
  {
    return g1._num > g2._num;
  }
};
int main()
{
  vector<Goods> v = { { "苹果", 2.1, 300 }, { "香蕉", 3.3, 100 }, { "橙子", 2.2, 1000 }, { "菠萝", 1.5, 1 } };
  sort(v.begin(), v.end(), ComparePriceLess());    //价格升序
  sort(v.begin(), v.end(), ComparePriceGreater()); //价格降序
  sort(v.begin(), v.end(), CompareNumLess());      //数量升序
  sort(v.begin(), v.end(), CompareNumGreater());   //数量降序
  return 0;
}      

如你所见,仿函数也顺利解决了以上问题,但是如果定义和使用位置隔的很远就不好观察,这就要求取名的时候通俗易懂,这种情况下就更加推荐使用 lambda 表达式。

比如我这里有一个做加法的仿函数:

class Plus {
 public:
  int operator()(int a, int b) {
    return a + b;
  }   
};

Plus plus; 
std::cout << plus(11, 22) << std::endl;      

我们把这个加法仿函数改写成 lambda 表达式就是:

auto Plus = [](int a, int b) { return a + b; };      

因为 lambda 表达式是一个匿名函数,该函数无法直接调用,这里我们借助 auto 将其赋值给一个变量,此时这个变量就可以像普通函数一样使用

这前后一对比,显而易见两者的优劣就高下立判了

语法🤔

lambda 表达式定义:

[ capture-list ] ( params ) mutable(optional) exception(optional) attribute(optional) -> ret(optional) { body }      

当然在书写格式上并不是必须写成一行,如果函数体太长可以进行换行

说明一下各个参数:

capture-list:捕捉列表。上面的例子 auto Plus = [](int a, int b) { return a + b; }; 就没有捕获任何变量

params:和普通函数一样的参数。

mutable:只有这个 Lambda 表达式是 mutable 的才允许修改按值捕获的参数。

-> ret:返回值类型,可省略,编译器可以通过 return 语句自动推导

body:具体函数

没说明的就暂时不必理解,其中。lambda 参数列表和返回值类型都是可有可无的,但捕捉列表和函数体是不可省略的,因此最简单的lambda函数如下:

int main()
{
  []{}; //最简单的lambda表达式
  return 0;
}      

捕获方式🤔

Lambda 表达式最基本的两种捕获方式是:按值捕获和按引用捕获

我们对捕获列表的说明描述了上下文中哪些数据可以被 lambda 函数使用,以及使用的方式是传值还是传引用

[var]:值传递捕捉变量var

[=]:值传递捕获所有父作用域中的变量(成员函数包括this指针)

[&var]:引用传递捕捉变量var

[&]:引用传递捕捉所有父作用域中的变量(成员函数包括this指针)

[this]:值传递捕捉当前的this指针

实际当我们以 [&](引用传递全捕获) 或 [=] (值传递全捕获)的方式捕获变量时,编译器也不一定会把父作用域中所有的变量捕获进来,编译器可能只会对 lambda 表达式中用到的变量进行捕获,实际要看编译器的具体实现

父作用域就是包含 lambda 表达式的语句块,语法上捕捉列表可由多个逗号分割的捕捉项组成,比如KaTeX parse error: Expected '}', got '&' at position 18: …olor{red} {[=, &̲a, &b]}

  1. 捕捉列表不允许重复传递,否则会导致编译错误,比如重复传递了变量 a
  2. lambda表达式之间不能相互赋值,即使看起来类型相同
  3. 在块作用域以外的 lambda 表达式捕捉列表必须为空!即全局lambda函数的捕捉列表必须为空,且在块作用域中的 lambda 表达式仅能捕捉父作用域中的局部变量,除此以外的都会导致编译报错。

相互赋值😎

lambda表达式之间不能相互赋值,就算是两个一模一样的也不行,这不邪门儿了嘛,为啥呢?

因为 lambda 表达式底层处理方式和仿函数是一样的(本文后面的底层原理部分有细谈),在VS下 lambda 表达式会被处理为函数对象,该函数对象对应的类名叫做<lambda_uuid>

类名中的uuid叫做通用唯一识别码,简单来说就是通过算法生成的一串字符串,它具有随机性和不重复性,保证在当前程序中每次生成不同的 uuid,因为 lambda 表达式底层的类名包含 uuid,这就保证了每个 lambda 表达式底层类名都是唯一的!

我们可以通过的方式来获取lambda表达式的类型来验证上述结论:

int main()
{
  int a = 10, b = 20;
  auto Swap1 = [](int& x, int& y)->void
  {
    int tmp = x;
    x = y;
    y = tmp;
  };
  auto Swap2 = [](int& x, int& y)->void
  {
    int tmp = x;
    x = y;
    y = tmp;
  };
  cout << typeid(Swap1).name() << endl; //class <lambda_797a0f7342ee38a60521450c0863d41f>
  cout << typeid(Swap2).name() << endl; //class <lambda_f7574cd5b805c37a13a7dc214d824b1f>
  return 0;
}      

如你所见,就算是一模一样的 lambda 表达式,它们的类型也是不同的

mutable🤔

在实际使用中,比如实现一个交换函数,我们用 lambda 表达式实现:

int main()
{
  int a = 1, b = 2;
  auto Swap = [a, b]()
  {
    int tem = a;
    a = b;
    b = tem;
  };
  Swap(); 
  return 0;
}      

这里一眼就是传值捕获,但是真的可以吗?答案是 No,他的编译不会通过,因为传值捕获到的变量默认是不可修改的(const):

//值捕获的类型是 const 类型
int i = 100;
auto func = [i]() {
    i = 200;  // 编译错误:assignment of read-only variable ‘i’
};      

如果要取消其常量属性,就需要在 lambda 表达式中加上 mutable 像这样:

auto Swap = [a, b]()mutable
  {
    int tem = a;
    a = b;
    b = tem;
  };      

但由于是传值捕捉,lambda 表达式中对局部变量的修改不会影响本身的变量,与函数的传值传参是一个道理,因此这种方法无法完成交换功能。

底层原理🤔

实际编译器在底层对于 lambda 表达式的处理方式,完全就是按照函数的方式处理的,函数对象就是我们平常所说的仿函数,就是在类中对 () 运算符进行了重载的类对象

我们编写了一个 Add 类,然后对 () 运算符进行了重载,因此 Add 类实例化出的 add1 对象就是函数对象,add1 可以像函数一样使用。接着写了一个 lambda 表达式,并借助 auto 将其赋值给 add2 对象,这时 add1 和 add2 都可以像普通函数一样使用

class Add
{
public:
  Add(int base)
    :_base(base)
  {}
  int operator()(int num)
  {
    return _base + num;
  }
private:
  int _base;
};
int main()
{
  int base = 1;

  //函数对象
  Add add1(base);
  add1(1000);

  //lambda表达式
  auto add2 = [base](int num)->int
  {
    return base + num;
  };
  add2(1000);
  return 0;
}      

我们再通过反汇编对代码进行观察:

C++ lambda 表达式深剖

创建函数对象 add1 时,会调用 Add 类的构造函数,使用 add1 时,会调用 Add 类的 () 运算符重载函数。

C++ lambda 表达式深剖

然后 lambda 表达式这边也是和函数的过程非常类似:在借助 auto 将 lambda 表达式赋值给 add2 对象时,会调用 <lambda_uuid> 类的构造函数,在使用add2对象时,会调用<lambda_uuid>类的()运算符重载函数