Lambda表达式

2021-10-14 16:48:02 浏览数 (1)

各位国庆节快乐,祝祖国繁荣昌盛!

常见的语言中都提供Lambda语法糖,比如C#, Python, Golang等。本文将探讨下C 11引入的Lambda语法糖。语法糖是一种让程序员使用更加便利的一种语法,并不会带来额外的功能,比如Lambda,没有这种语法糖,其可以用已有的语法等价的实现出相应的功能。 有编程实践经验的同学一定能够快速的理解Lamdba产生的意义,而缺乏编程经验的同学,跟着我一起来梳理下Lamdba给我们带来了哪些便利性?

函数指针和对象函数

因为笔者用Lambda最多的场景是回调函数,先说说回调函数。在编程中回调函数是一个常见的设计方式, 下图是一个常见的同步调用的回调函数:

  1. 调用方访问被调用方的实现函数InvokeFunction
  2. 被调用方访问调用方的回调函数CallbackFunction

上述是一个同步调用的回调方式,是实践中,也有可能是一个异步的回调方式。 一般回调的使用场景可以是被调用方使用调用方指定的方法去实现内部的一个逻辑。常见的比如:

  1. 被调用模块使用调用模块指定的方法完成其功能,比如常见的std::sort
  2. 比如SDK没有写DebugLog的功能,而是通过回调函数的方式,让调用方实现写DebugLog功能。
  3. 通知机制:在一些场景下,被调用方通过回调函数去通知调用模块,去进行相应操作。

回调的场景应该不止上述描述的这些,这一章节的重点让我们回归到回调函数函数对象仿函数)。

回调函数最常见的C和C 中都使用的函数指针,我们以std::sort为例。一个vector容器中存储了若干的Student信息,想要将这些学生信息根据年龄进行升序排序,于是可以调用std::sort,并且使用自定义的函数StudentSortFunctionsort作为回调函数来完成排序。

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

struct Student
{
  std::string  m_strName;
  unsigned int m_uAge;
};

void PrintStudentVector(const std::vector<Student>& vecStudents)
{
  for (auto&& student : vecStudents)
  {
    std::cout << student.m_strName.c_str() << ":" << student.m_uAge << std::endl;
  }
  std::cout << std::endl;
}

bool StudentSortFunction(const Student& student1, const Student& student2)
{
  return student1.m_uAge < student2.m_uAge;
}

int main()
{
  std::vector<Student> vecStudents= {
    {"xiaoqiang", 15}, 
    {"xiaoming", 13},
    {"xiaoke", 13}
  };

  PrintStudentVector(vecStudents);

  std::sort(vecStudents.begin(), vecStudents.end(), StudentSortFunction);

  //Print after sort
  PrintStudentVector(vecStudents);
  return 0;
}

C 中有了函数对象概念,我们同样以上述的例子,实现了一个函数对象StudentSort,其包含一个重载的函数接口bool operator() (const Student& student1, const Student& student2),同样可以实现同样的功能。

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

struct Student
{
  std::string  m_strName;
  unsigned int m_uAge;
};

void PrintStudentVector(const std::vector<Student>& vecStudents)
{
  for (auto&& student : vecStudents)
  {
    std::cout << student.m_strName.c_str() << ":" << student.m_uAge << std::endl;
  }
  std::cout << std::endl;
}

class StudentSort
{
public:
  bool operator() (const Student& student1, const Student& student2)
{
    return student1.m_uAge < student2.m_uAge;
  }
};

int main()
{
  std::vector<Student> vecStudents= {
    {"xiaoqiang", 15}, 
    {"xiaoming", 13},
    {"xiaoke", 13}
  };

  PrintStudentVector(vecStudents);

  std::sort(vecStudents.begin(), vecStudents.end(), StudentSort());

  //Print after sort
  PrintStudentVector(vecStudents);
  return 0;
}

当然上述的例子函数指针函数对象似乎没有太多区别。我们注意看回调的方法的入参是由被调用方给定的并且传入的。但是在一些场景,我们是需要在回调方法中同样传入被调用方的一些信息。这个时候的回调方法一般的形式是, 会传入一个pCtx,其存储调用方所需要传递给回调函数的一些信息。

代码语言:javascript复制
void CallbackFunction(Contex* pCtx, Parameter par1, Parameter par2.....)

在这种情况下函数指针函数对象就有了区别了,函数指针是没有成员的,而函数对象是可有成员函数的,这个时候在C 中,回调的方法一般采用函数对象来实现上述的方式, 比如定义了一个回调函数对象CallbackContext callbackContext设置给被调用方被调用方使用callbackContext(par1, par2)即完成了回调方法的调用。

代码语言:javascript复制
class CallbackContex
{
public:
  bool operator() (Parameter par1, Parameter par2) { ; };
private:
  Contex* m_pCtx;
};

那么也就是说,每次我们设置给被调用放都需要定义一个class,将调用方需要设置给被调用方的变量给打包到一个叫做Contex中,这个时候手写一个函数对象,感觉比较繁琐。注意只是繁琐,而不是无法实现。 这个时候使用Lambda来实现就显的十分的方便快捷了,因为其有一个很棒的功能,叫做捕获变量。接下来让我们一起来看看本文的主角lambda吧。

Lambda

Lambda的表达式如上图所示,其主要构成部分就比普通的函数多了一个捕获列表,主要由5个部分构成。

  1. 捕获列表,其可以捕获当前上下文的变量,可以是值捕获或者引用捕获
  2. 函数参数,不用赘述,和普通函数一样
  3. specifiers, 可选的,主要说明下mutable, 默认情况下值捕获,将无法修改其值(可以想象为其成员函数后面跟了个const),除非设置为mutable.
  4. 返回值,如果不写表示返回void
  5. 函数体, 这部分可以使用你捕获列表里面的变量,也可以使用参数列表里面的变量。

看到这里是不是来演练下第一章节的例子,使用Lambda如何更简洁的写出一个排序的回调, 是不是比较简单。

代码语言:javascript复制
std::sort(vecStudents.begin(), vecStudents.end(), [](const Student& student1, const Student& student2) -> bool {
    return student1.m_uAge < student2.m_uAge;
  });

Lambda的表达式的结果(注意不是返回值)是一个匿名函数对象,我们一般可以使用 auto来获取其表达式结果,同样也可以使用。std::function<T>。 下面我们来举个例子让我们来更加好的理解Lambda, 尤其是值捕获引用捕获

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

int main()
{
  unsigned int uYear = 2020;
  unsigned int uMonth = 9;

  std::cout << "uYear: " << uYear
    << " Month: " << uMonth << std::endl << std::endl;

  auto lambda = [&uYear, uMonth]() -> bool {
    uYear = 2021;
    std::cout << "uYear: " << uYear
      << " Month: " << uMonth << std::endl << std::endl;

    //error C3491: 'uMonth': a by copy capture cannot be modified in a non-mutable lambda
    //uMonth = 10;

    return true;
  };
  
  lambda();

  std::cout << "uYear: " << uYear
    << " Month: " << uMonth << std::endl << std::endl;
  return 0;
}

这个例子我们可以看到在Lambda中使用引用捕获uYear, 值捕获uMonth。那么在Lambda函数体内:

  • uYearmain函数中的uYear的引用,对uYear的重新复制为2021也会影响到mainuYear
  • uMonth只是main函数中的uMonth的值传递,默认情况下不能够直接进行改写,除非将Lambda指定为mutable。如果其为mutable, 在函数体内的修改并不会影响mainuMonth的改变。

其实上述的Lamdba表达式可以用下面的类来表达其含义, 这样的表达易于读者去理解Lambda在编译器中的实现,也能够更好的掌握Lambda

代码语言:javascript复制
class LambdaClass_XXXXX
{
public:
  LambdaClass_XXXXX(unsigned int& uYear, unsigned int uMonth) :m_uYear(uYear), m_uMonth(uMonth) {}
  bool operator()() const
{
    m_uYear = 2021;
    std::cout << "uYear: " << m_uYear
      << " Month: " << m_uMonth << std::endl << std::endl;

    return true;
  }
private:
  unsigned int& m_uYear;
  unsigned int  m_uMonth;
};

LambdaClass_XXXXX的命名方式是避免的名字冲突。实际可以查看编译器MSVC命名的方式如下图所示:

如果有很多的参数需要捕获,Lambda也提供了一些简便的方式:

  • [&, uMonth] 表示uMonth采用值捕获,其他可见的变量均采用`引用捕获
  • [=, &uYear] 表示uYear采用引用捕获,其他可见的变量均采用值捕获

那么如果捕获列表的变量名字和函数参数名字相同呢? ,我试了几个不同的编译器,结果不相同,有的报错,有的优先选择函数参数,有的优先选择捕获列表。总之使用者尽量避开名字相同的问题。关于这个在Stackoverflow上也有所讨论: <<Lambda capture and parameter with same name - who shadows the other? (clang vs gcc)>>, 个人的角度来说更希望是编译阶段直接报错。

通过这一章节的内容,你是否能够举一反三了呢?出一道题目给读者做一做吧。

给读者的问题

为了更好的让读者理解Lambda的实现,请问以下的程序结果输出是什么呢?先想一个答案,然后不确定的同学用编译器跑了试一试吧。如果答案错误,欢迎和笔者一起讨论哦。

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

int main()
{
  int iVal = 100;
  auto lambda = [iVal]() mutable {
    iVal  = 100;
    std::cout << iVal << std::endl;
  };
  lambda();
  lambda();
  return 0;
}

总结

Lambda是一种让C 对象函数编写更加便利的语法糖,在使用Lambda的时候一定要理解其实现原理,尤其是捕获列表值捕获引用捕获, 以及要注意其生命周期,以防非法的内存访问导致程序出错。另一点就是文中提到的一个注意点,尽量避免捕获列表的变量名称和函数参数的变量名称相同的情况,因为当前的不同编译器的实现不同,否则掉进坑里了哦。

0 人点赞