前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >揭开lambda的神秘面纱

揭开lambda的神秘面纱

作者头像
高性能架构探索
发布2022-08-25 16:07:00
7520
发布2022-08-25 16:07:00
举报
文章被收录于专栏:技术随笔心得

你好,我是雨乐!

lambda也出现了好长时间,一直以来也仅仅限于使用,今天,借助此文,我们从使用、实现的角度聊聊lambda。

在开始正文之前,我们先看一个问题,对下面的vector进行排序:

代码语言:javascript
复制
std::vector<int> v = {1, 3, 2};

在C++11之前,我们可能会这么做(普通函数,即函数指针作为参数):

代码语言:javascript
复制
bool Compare(int a, int b) {
  return a < b;
}

int main() {
  std::vector<int> v = {1, 3, 2};
  std::sort(v.begin(), v.end(), Compare);
  
  return 0;
}

也有可能这样做(函数对象,即类对象作为参数):

代码语言:javascript
复制
int main() {
  struct Compare {
    bool operator()(int a, int b) {
      return a < b;
    }
  };
  std::vector<int> v = {1, 3, 2};
  std::sort(v.begin(), v.end(), Compare());

  return 0;
}

但是上述两种方式均有其局限性,对于普通函数的实现方式来说,优点是具有最小的语法开销,缺点是不能限定作用域(即必须在使用作用域外进行定义),而对于函数对象的实现方式来说,优点是可以在作用域内进行定义,但缺点是需要有类定义的语法开销

既然函数指针和函数对象都有其优缺点,那么有没有其它方式既保持了二者的优点,又摒弃了二者的缺点呢?当然有了,这就是lambda

本文的主要内容如下:

概念

自C++11开始,引入了lambda(一般称之为为lambda表达式),一个lambda表达式表示一个可调用的代码单元。我们可以将其理解为一个匿名的内联函数。lambda表达式跟普通函数相比不需要定义函数名,取而代之的多了一对方括号[]

先看下lambda的基本语法,如下:

代码语言:javascript
复制
[capture](parameters) specifiers exception attr -> return type { /*code; */ }

在上面定义中:

  • [capture]代表捕获列表,括号内为外部变量的传递方式,包括值传递、引用传递等
  • (parameters)代表参数列表,其中括号内为形参,和普通函数的形参一样
  • specifiers exception attr代表附加说明符,一般为mutablenoexcept
  • ->return type代表lambda函数的返回类型如 -> int-> string等。在大多数情况下不需要,因为编译器可以推导类型
  • {}内为函数主体,和普通函数一样

为了便于我们对lambda的使用有个初步认识,下面是一些常用的例子:

代码语言:javascript
复制
// 1. 最简单的lambda,没有任何行为操作:
[]{};

// 2. 包含两个参数的lambda:
[](float f, int a) { return a * f; };
[](int a, int b) { return a < b; };

// 3. 有返回值的lambda:
[](MyClass t) -> int { auto a = t.compute(); print(a); return a; };

// 4. 存在附加说明符的lambda:
[x](int a, int b) mutable { ++x; return a < b; };
[](float param) noexcept { return param*param; };
[x](int a, int b) mutable noexcept { ++x; return a < b; };

// 5. 参数列表可选:
[x] { std::cout << x; }; // 去掉()
[x] mutable { ++x; };    // 编译失败!
[x]() mutable { ++x; };  // 正常编译,这是因为在附加说明符前面需要有()
[] noexcept { };        // 编译失败!
[]() noexcept { };      // 正常编译,这是因为在附加说明符前面需要有()

好了,现在回到正题,如果我们使用lambda来实现之前排序的话,应该怎么做呢?如下:

代码语言:javascript
复制
int main() {
  std::vector<int> v = {1, 3, 2};
  std::sort(v.begin(), v.end(), [](int a, int b){
    return a < b;
  });
  return 0;
}

从上述实现可以看出,其相较于函数指针函数对象的实现方式,更为简洁直观

捕获列表

在上一节中,我们提到了lambda定义中的几个基本点:捕获列表函数参数附加说明符返回类型以及函数体。函数参数、返回类型和函数体在普通函数或者类成员函数中我们都有用到,那么什么是捕获列表和附加说明符呢?这就是本节的内容。

捕获的作用是捕获lambda所在函数的局部变量(捕获全局变量或者静态变量编译器会报warning,后面有说明)。其中捕获的类型可以分为值捕获,引用捕获和隐式捕获:

值捕获 与函数中的值传递类似。lambda表达式捕获的是变量的一个拷贝,因此我们如果在lambda表达式后面改变该变量值的话,不会影响捕获前的该变量值,这就是所谓的值捕获

代码语言:javascript
复制
int a = 1;
[a](){printf("%d\n", a;);}

引用捕获 引用捕获和值捕获形式完全一样,只是在捕获列表中传的是变量的引用,类似于函数中的引用传递,变成下面这个样子

代码语言:javascript
复制
int a = 1;
[&a](){printf("%d\n", a;);}

隐式捕获的方式,就是捕获的列表可以用=&代替,让编译器隐式的推断你使用的是哪个变量,然后这两个字符表示捕获的类型=表示值捕获,&是引用捕获;写出来之后就变成了如下的形式:

代码语言:javascript
复制
int a = 1;
[=](){printf("%d\n", a);};
[&](){printf("%d\n", a;);}

下面是捕获列表的一些语法规则:

  • [&]通过引用捕获作用域内的全部局部变量
  • [=]通过引用捕获作用域内的全部局部变量
  • [x, &y] x按照值捕获和y按照引用捕获。
  • [x = expr] 带有初始化表达式的捕获 (C++14)
  • [args...] 捕获模板参数包,全部按值。
  • [&args...] 捕获模板参数包,全部通过引用。
  • [...capturedArgs = std::move(args)](){} 通过移动操作符捕获包(C++20)

捕获规则示例代码如下:

代码语言:javascript
复制
int x = 2, y = 3;

const auto l1 = []() { return 1; };   // 没有捕获任何内容 
const auto l2 = [=]() { return x; };  // 按值捕获所有变量
const auto l3 = [&]() { return y; };  // 按引用捕获所有变量
const auto l4 = [x]() { return x; };  // 仅对x进行按值捕获
const auto l5 = [&y]() { return y; }; // 仅对y进行按引用捕获
const auto l6 = [x, &y]() { return x * y; }; // 对x按值捕获,对y按引用捕获
const auto l7 = [=, &x]() { return x + y; }; // 对x按引用捕获,其余的按值捕获
const auto l8 = [&, y]() { return x - y; };  // 对y按值捕获,其余的按引用捕获
const auto l9 = [this]() { } // 捕获this指针
const auto la = [*this]() { } // 按值捕获*this对象

值捕获

lambda表达式可以将作用域内的变量捕获到lambda函数中。在lambda的表达式定义中,我们有提到[=]指定可以按值捕获作用域内的任何变量[x]则仅仅按值捕获变量x

仅捕获某个变量,代码如下:

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [x]() { printf("%d\n", x); };
  fun();
  return 0;
}

捕获所有变量,代码如下:

代码语言:javascript
复制
int main() {
  int x = 5;
  int y = 6;
  auto fun = [=]() { printf("%d, %d\n", x, y); };
  fun();
  return 0;
}

引用捕获

可以使用引用捕获调用lambda表达式。当使用引用捕获时候,捕获的值实际上是对lambda外部范围内变量的引用。

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [&x]() { printf("%d\n", ++x); };
  fun();
  printf("%d\n", x);
  return 0;
}

输出如下:

代码语言:javascript
复制
6
6

如果外部变量很多,想按引用捕获外部所有变量的话,可以使用[&]方式,如下:

代码语言:javascript
复制
int main() {
  int x = 5;
  int y = 0;
  auto fun = [&]() { printf("%d, %d\n", ++x, --y); };
  fun();
  printf("%d, %d\n", x, y);
  return 0;
}

输出如下:

代码语言:javascript
复制
6 -1
6 -1

mutable关键字

本来mutable关键字应该单列一节来进行说明,但是因为其与捕获列表关系紧密,所以就暂时放在了本节一起来进行说明。

我们经常有一种需求,需要对某个变量进行修改,或者说局部范围内的修改,当退出该作用域的时候,变量又恢复原值。对于这种需求,我们可以尝试使用值捕获来完成,代码如下:

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [x]() { printf("%d\n", ++x); };
  fun();
  printf("%d\n", x);
  return 0;
}

编译之后,发现编译器会报错,如下:

代码语言:javascript
复制
错误:令只读变量‘x’自增
auto fun = [x]() { printf("%d\n", ++x); };

从上述编译器的输出来看,对于按值捕获的变量,编译器会将其设置为只读(read only),所以对只读变量进行尝试修改的操作是不被编译器所允许的,而mutable 则可以解决此类错误,如下:

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [x]() mutable { printf("%d\n", ++x); };
  fun();
  printf("%d\n", x);
  return 0;
}

代码输出如下:

代码语言:javascript
复制
6
5

捕获全局变量和静态变量

一般情况下,lambda是用来捕获局部变量的,如果用其来捕获全局变量或者静态变量,那么编译器会报warning ,如下代码:

代码语言:javascript
复制
#include <iostream>
#include <vector>
#include <algorithm>

int x = 4;
int main() {
  auto fun = [x]() { printf("%d\n", x); };
  fun();

  return 0;
}

编译器输出如下:

代码语言:javascript
复制
test.cc: In function ‘int main()’:
test.cc:7:15: warning: capture of variable ‘x’ with non-automatic storage duration
    7 |   auto fun = [x]() { printf("%d\n", x); };
      |               ^
test.cc:5:5: note: ‘int x’ declared here
    5 | int x = 4;
      |     ^

捕获初始化表达式

自C++14开始,在捕获列表中可以使用初始化表达式,也就是说可以创建新的变量并在捕获子句中对其进行初始化。这种方式称之为带有初始化程序的捕获或者广义lambda捕获

代码语言:javascript
复制
int main() {
  int x = 1;
  int y = 2;
  auto fun = [z = x + y]() { printf("%d\n", z); };
  fun();

  return 0;
}

在上面的例子中,编译器生成一个新的成员变量并用x+y对其进行初始化,也就是是说上面示例等价于:

代码语言:javascript
复制
int main() {
  int x = 1;
  int y = 2;
  int z = x + y;
  auto fun = [z]() { printf("%d\n", z); };
  fun();

  return 0;
}

混合捕获

混合捕获,还是比较好理解的,话不多说,直接上代码:

代码语言:javascript
复制
int main() {
  int x = 1;
  int y = 2;
  auto fun = [x, &y](){
    printf("%d, %d\n", x, ++y);
  };
  fun();
  
  return 0;
}

在上述代码中,对x进行按值捕获,而堆y则进行按引用捕获。

编译器实现

经常看我文章的读者,可能发现我的文章有个特点,喜欢说明白底层实现,其实这也是C++开发人员的一个特点,知其然,更要知其所有然,毕竟知己知彼,方能百战不殆嘛。

好了,言归正传,开始聊聊lambda的底层实现。那么我们该如何知道编译器的底层是如何实现的呢?在这里推荐一个工具cppinsights,是一款C++源代码到源代码的转换,它可以把C++中的模板、auto以及C++11新特性展开。通过使用cppinsights,我们可以清楚地看到编译器做了哪些事情。

值捕获

仍然使用前面的代码,如下:

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [x]() { printf("%d\n", x); };
  fun();
  return 0;
}

cppinsights输出如下:

代码语言:javascript
复制
int main()
{
  int x = 5;
    
  class __lambda_8_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d\n", x);
    }
    
    private: 
    int x;
    
    public:
    __lambda_8_14(int & _x)
    : x{_x}
    {}
    
  };
  
  __lambda_8_14 fun = __lambda_8_14{x};
  fun.operator()();
  return 0;
}

从上面内容,我们可以看出,编译器针对lambda会生成一个类__lambda_8_14,然后调用该类的成员函数:

  • __lambda_8_14为由编译器针对lambda函数生成的一个类
  • __lambda_8_14定义了一个成员变量x,其初始值为
  • __lambda_8_14重载operator()其函数体为lambda函数体(本例中为printf("%d\n", x))
  • 源码中的fun在编译器实现之后,变成了一个__lambda_8_14对象
  • 对fun函数的调用,变成了调用__lambda_8_14对象的operator()函数

如果捕获列表内容为[=],则类的private成员变量中会包含范围内的且在lambda中被使用的局部变量。假如有x和y两个变量,如果只使用了x这个变量,那么private成员变量就只有x,反之如果都使用了,则成员变量就变成了x和y。

如下代码:

代码语言:javascript
复制
int main() {
  int x = 5;
  int y = 6;
  auto fun = [=]() { printf("%d, %d\n", x, y); };
  fun();
  return 0;
}

上述代码的lambda部分,经过编译器编译之后,会变成如下:

代码语言:javascript
复制
class __lambda_9_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d, %d\n", x, y);
    }
    
    private: 
    int x;
    int y;
    
    public:
    __lambda_9_14(int & _x, int & _y)
    : x{_x}
    , y{_y}
    {}
    
  };

在捕获列表中使用[=],但是lambda实现体内只使用变量x,那么编译器又将如何操作呢?

代码语言:javascript
复制
int main() {
  int x = 5;
  int y = 6;
  auto fun = [=]() { printf("%d\n", x); };
  fun();
  return 0;
}

编译器对lambda部分的实现如下所示:

代码语言:javascript
复制
class __lambda_9_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d\n", x);
    }
    
    private: 
    int x;
    
    public:
    __lambda_9_14(int & _x)
    : x{_x}
    {}
    
  };

上述输出中可见,对于[=]捕获方式,如果函数体内没有使用的变量,编译器不会生成对应的成员变量

引用捕获

在上述值列表中,编译器会生成对应的成员变量,这样成员变量是对值列表中对应变量的一个拷贝,那么如果是引用列表,则成员变量则是对应变量的一个引用

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [&x]() { printf("%d\n", ++x); };
  fun();
  printf("%d\n", x);
  return 0;
}

lambda部分经过编译器操作之后,如下:

代码语言:javascript
复制
class __lambda_8_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d\n", ++x);
    }
    
    private: 
    int & x;
    
    public:
    __lambda_8_14(int & _x)
    : x{_x}
    {}
    
  };

可以看到,成员变量部分是引用列表中的引用,即int &x

如果列表为[&],则编译器将会生成对应变量的引用,规则与值列表类似,在此不再赘述。

mutable关键字

在前面内容中,可以看到,无论是按值捕获还是按引用捕获,编译器都会生成一个成员函数operator(),且被声明为const ,这也就意味着不能修改成员变量。

如果要修改此行为,则需要在参数列表后添加mutable关键字,这样就可以将const从operator()函数的声明中去除。

代码语言:javascript
复制
int main() {
  int x = 5;
  auto fun = [x]() mutable { printf("%d\n", ++x); };
  fun();
  return 0;
}

上述lambda在编译器中的实现如下:

代码语言:javascript
复制
 class __lambda_8_14
  {
    public: 
    inline /*constexpr */ void operator()()
    {
      printf("%d\n", ++x);
    }
    
    private: 
    int x;
    
    public:
    __lambda_8_14(int & _x)
    : x{_x}
    {}
    
  };

混合捕获

混合列表是值列表和引用列表的一种组合,了解了这两种实现,混合列表的编译器实现就更好理解了。

代码语言:javascript
复制
int main() {
  int x = 1;
  int y = 2;
  auto fun = [x, &y](){
    printf("%d, %d\n", x, ++y);
  };
  fun();
  
  return 0;
}

lambda部分编译器的底层实现如下:

代码语言:javascript
复制
class __lambda_9_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d, %d\n", x, ++y);
    }
    
    private: 
    int x;
    int & y;
    
    public:
    __lambda_9_14(int & _x, int & _y)
    : x{_x}
    , y{_y}
    {}
    
  };

生成规则

看了前面的内容,lambda编译器的底层实现基本有了一个初步的认识,借助此文,将这个规则整理下:

编译器对lambda的生成规则如下:

  • lambda表达式中的捕获列表,对应lambda_xxxx类的private 成员
  • lambda表达式中的形参列表,对应lambda_xxxx类成员函数 operator()的形参列表
  • lambda表达式中的mutable,对应lambda_xxxx类成员函数 operator() 的常属性 const,即是否是常成员函数
  • lambda表达式中的返回类型,对应lambda_xxxx类成员函数 operator() 的返回类型
  • lambda表达式中的函数体,对应lambda_xxxx类成员函数 operator() 的函数体

效率

作为cpp开发人员,最关心的是性能问题。有些读者看完编译器对lambda的实现之后,感觉这么复杂的代码会不会效率很低?为了打消读者的疑虑,在本节中将从汇编角度进行分析。

我们以下面代码为例:

代码语言:javascript
复制
int main() {
  int x = 1;
  auto fun = [x](){
    printf("%d\n", x);
  };
  fun();
  
  return 0;
}

使用-std=c++17 -stdlib=libc++ -O3优化之后,汇编代码如下:

代码语言:javascript
复制
main: # @main
  push rax
  mov edi, offset .L.str
  mov esi, 1
  xor eax, eax
  call printf
  xor eax, eax
  pop rcx
  ret
.L.str:
  .asciz "%d\n"

从上述汇编代码可以看出,经过编译器优化之后,效率非常高,所以我们上面的担心完全是多余的。

结语

lambda已经成为C++中一个强大的工具,了解lambda的使用以及底层实现原理,能够帮助我们更加高效更加便捷的进行编码。希望本文能够帮助到您。

好了,今天的文章就到这,我们下期见!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-04-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 高性能架构探索 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 概念
  • 捕获列表
    • 值捕获
      • 引用捕获
        • mutable关键字
          • 捕获全局变量和静态变量
            • 捕获初始化表达式
              • 混合捕获
              • 编译器实现
                • 值捕获
                  • 引用捕获
                    • mutable关键字
                      • 混合捕获
                        • 生成规则
                        • 效率
                        • 结语
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档