揭开lambda的神秘面纱

2022-08-25 16:07:00 浏览数 (1)

你好,我是雨乐!

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("%dn", a;);}

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

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

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

代码语言:javascript复制
int a = 1;
[=](){printf("%dn", a);};
[&](){printf("%dn", 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("%dn", x); };
  fun();
  return 0;
}

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

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

引用捕获

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

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

输出如下:

代码语言:javascript复制
6
6

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

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

输出如下:

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

mutable关键字

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

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

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

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

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

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

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

代码输出如下:

代码语言:javascript复制
6
5

捕获全局变量和静态变量

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

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

int x = 4;
int main() {
  auto fun = [x]() { printf("%dn", 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("%dn", 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("%dn", z); };
  fun();

  return 0;
}

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

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

  return 0;
}

混合捕获

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

代码语言:javascript复制
int main() {
  int x = 1;
  int y = 2;
  auto fun = [x, &y](){
    printf("%d, %dn", 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("%dn", x); };
  fun();
  return 0;
}

cppinsights输出如下:

代码语言:javascript复制
int main()
{
  int x = 5;
    
  class __lambda_8_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%dn", 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("%dn", 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, %dn", x, y); };
  fun();
  return 0;
}

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

代码语言:javascript复制
class __lambda_9_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d, %dn", 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("%dn", x); };
  fun();
  return 0;
}

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

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

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

引用捕获

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

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

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

代码语言:javascript复制
class __lambda_8_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%dn",   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("%dn",   x); };
  fun();
  return 0;
}

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

代码语言:javascript复制
 class __lambda_8_14
  {
    public: 
    inline /*constexpr */ void operator()()
    {
      printf("%dn",   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, %dn", x,   y);
  };
  fun();
  
  return 0;
}

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

代码语言:javascript复制
class __lambda_9_14
  {
    public: 
    inline /*constexpr */ void operator()() const
    {
      printf("%d, %dn", 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("%dn", 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 "%dn"

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

结语

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

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

0 人点赞