C++11/14 lambda表达式使用及解析(二)

本文深入探讨C++11的Lambda表达式,包括闭包对象、闭包类型、捕获方式的细节,以及转换为函数指针、作为基类和存储于容器等高级用法。重点解析了按值和按引用捕获变量的区别,并举例说明了为何按值捕获的变量在lambda中不可修改。
摘要由CSDN通过智能技术生成

引言

很久之前总结过一篇C++11/14 lambda表达式使用及解析,其大体介绍了C++11/14中lambda表达式的基本使用,关于其他高级特性/使用等均未涉及;因此本文继该文章,进一步总结lambda表达时的高级特性(包括基本属性,细节)和使用。

由于目前C++20已经发布,因此,计划总结成如下系列:

C++11/14 lambda表达式使用及解析(二)主要总结C++11中的lambda特性及使用。

C++11/14 lambda表达式使用及解析(三)主要总结C++14中的lambda特性及使用。

C++11/14/17 lambda表达式使用及解析(四)主要总结C++17中的lambda特性及使用。

C++17/20 lambda表达式使用及解析(五)主要总结C++20中lambda特性及使用。

上述三、四、五目前未完成本篇主要旨在完成二。

在开始本文之前,先给出本文代码测试所用的环境(机器信息以及gcc版本

uname -a

Linux qls-VirtualBox 5.11.0-37-generic #41~20.04.2-Ubuntu SMP Fri Sep 24 09:06:38 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

gcc version 9.3.0 (Ubuntu 9.3.0-17ubuntu1~20.04)

然后,我们由一个在现代C++标准中使用lambda表示式经常会碰到的一个问题:

即当lambda表达式按值捕获某个变量a,在其函数体中修改a,会报错!

具体可参考如下代码:

int main() {
  int a = 0;
  auto b = [a]() {
    ++a;
  };
  b();
  return 0;
}

构建上述代码后,编译器给出如下输出

test.cc: In lambda function:
test.cc:8:7: error: increment of read-only variable ‘a’
    8 |     ++a;
      |       ^

而当我们按引用捕获a时,便可正常work!

关于该问题的原因和解决方案,会在本文高级使用场景中给出答案。

下面开始进入正文,从lambda表达式的基本概念说起,其也可参考:C++11/14 lambda表达式使用及解析,本文的基础概念部分是对该文的进一步补充。

闭包/闭包类型(closure object/closure type)

闭包/闭包类型名称参考自Effective Modern C++ 简体中文版

关于何为闭包,C++标准给出的定义如下expr.prim.lambda#2

The evaluation of a lambda-expression results in a prvalue temporary. This temporary is called the closure object .

上述定义可推出如下两点(个人理解):

  1.  lambda表达式是一个prvalue
  2. lambda表达式会生成一个临时对象, 该临时对象便叫做闭包对象(闭包)

关于什么是prvalue,其在Value categories - cppreference.com中有如下定义

a prvalue (“pure” rvalue) is an expression whose evaluation

  • computes the value of an operand of a built-in operator (such prvalue has no result object), or
  • initializes an object (such prvalue is said to have a result object).

The result object may be a variable, an object created by new-expression, a temporary created by temporary materialization, or a member thereof. Note that non-void discarded expressions have a result object (the materialized temporary). Also, every class and array prvalue has a result object except when it is the operand of decltype;

简言之:一个prvalue是一个表达式,其执行的结果用在

  1. 计算内建操作符的操作数(此时没有result object)
  2. 初始化一个对象,此时prvalue会产生一个result object

关于prvalue本文仅介绍到此,详细可参考

Value categories - cppreference.com

c++ - What are rvalues, lvalues, xvalues, glvalues, and prvalues? - Stack Overflow

C++ Templates(第2版 英文版)

关于何为闭包类型,C++标准给出定义如下[expr.prim.lambda]

The type of the lambda-expression (which is also the type of the closure object) is a unique, unnamed non-union class type — called the closure type .

由此可知:闭包类型主要是闭包对象的类类型。

在介绍完基本概念后,下面便进行lambda表达式相应属性介绍。

lambda表达式属性

编译器扩展

首先,本文会通过C++ Insights观察编译器对lambda表达式的扩展,在C++11/14 lambda表达式使用及解析中给出过无状态lambda表达式的扩展,因此,此处不再给出具体示例。

lambda表达式闭包类型

在该部分,主要表述一个观点:纵使两个lambda表达式实现一样,其闭包类型也不同。

可参考如下代码理解

int main() {
  std::cout << std::boolalpha;
  auto lb1 = []() {};
  auto lb2 = []() {};
  std::cout << "lb1 and lb2 type same: " 
            << std::is_same<decltype(lb1), decltype(lb2)>::value << "\n";
  return 0;
}

上述结果会显示为false。

闭包类的构造函数与拷贝赋值函数

在标准[expr.prim.lambda] 中有关于闭包类的构造函数等规定有如下定义

The closure type associated with a lambda-expression has a deleted ([dcl.fct.def.delete]) default constructor and a deleted copy assignment operator.

也即,针对闭包类,其构造函数和拷贝赋值函数等是delete的。

因此在C++11中,不能通过decltype(闭包) 默认声明一个闭包。也即不能实现如下操作

decltype(lb1) lb3;

标准并没有说拷贝构造函数为delete,因此可以拷贝闭包,且拷贝的闭包和被拷贝的闭包其闭包类型相同。

具体可参考如下代码

int main() {
  std::cout << std::boolalpha;
  auto lb1 = []() {};
  auto lb2 = lb1;
  
  std::cout << "lb1 and lb2 type same: " 
            << std::is_same<decltype(lb1), decltype(lb2)>::value << "\n";
  return 0;
}

上述结果输出为true。

捕获(Captures)

关于捕获列表的定义,可参考该系列第一篇,本文不赘述。在本文中,将捕获归为无状态捕获,有状态捕获两大类,相对应为无状态闭包和有状态闭包

其有状态捕获又可归为如下如下几类:

  1. 按值捕获,形如[a]
  2. 按引用捕获,形如[&a]
  3. 默认捕获,形如[=]
  4. 按值,引用混合捕获,形如[a,&b]
  5. 按值捕获模版参数包,形如[packs...]
  6. 按引用捕获模版参数包,形如[&packs...]

关于1,2,3,4相关的说明和示例可参考C++11/14 lambda表达式使用及解析,本文会讲解5,6并给出相应示例。

关于lambda捕获全局变量/静态变量,捕获全局变量,有些编译器可能会报错,所以最好不要这样做;针对静态变量捕获,其lambda表达式并不会产生拷贝,也相当于未捕获,所以最好也不要这样做。相关示例,本文略过。

下面,便详细讲解文章开头给出的例子:即lambda表达式捕获变量a,按引用捕获b,在其函数体中修改a会报错,修改b可以通过的问题。

首先看当lambda表达式按值捕获a的情景,参考如下代码

int main() {
  int a = 1;
  auto lb1 = [a]() { ++a; };
  lb1();
  std::cout << "a= " << a << "\n";
  return 0;
}

构建上述代码,编译器报告如下错误

test.cc: In lambda function:
test.cc:8:24: error: increment of read-only variable ‘a’
    8 |   auto lb1 = [a]() { ++a; };
      |                      

编译器竟然报变量a为一个只读变量。

通过C++ Insights观察上述lambda表达式经编译器后扩展为如下实现

  class __lambda_7_13
  {
    public: 
    inline void operator()() const
    {
      ++a;
    }
    
    private: 
    int a;
    public: 
    // inline /*constexpr */ __lambda_7_13(__lambda_7_13 &&) noexcept = default;
    __lambda_7_13(int & _a)
    : a{_a}
    {}
    
  };

由上述生成代码可知,

  1. 闭包类有一个 operator()操作符,其默认为const,故闭包类为一个可调用对象
  2. 捕获列表中的变量a,直接成为闭包类的成员,且由其构造函数在初始化列表中直接初始化

因此,由上述说明便可知,lambda表达式不可以修改按值捕获的变量,因为operator() 被编译器默认扩展为const属性,因此不能在该接口中修改闭包类的成员。

解决该问题,最直观的方式是将operator()接口的const修饰符去掉,或者将闭包类中的成员变量a声明为mutable!

在C++11中,可将关键词mutable应用lambda表达式,这样会使编译器扩展的operator()为不带const属性的接口,便可以修该被捕获的变量!

关于按值捕获,标准中也给出了相应的定义[expr.prim.lambda]

When the lambda-expression is evaluated, the entities that are captured by copy are used to direct-initialise each corresponding non-static data member of the resulting closure object.

接下来看一看当按引用捕获变量a时,编译器的行为,示例代码如下

int main() {
  int a = 1;
  auto lb1 = [&a]() { ++a; };
  lb1();
  std::cout << "a= " << a << "\n";
  return 0;
}

上述代码能正常通过,且能输出正确的结果。

通过C++ Insights观察上述lambda表达式生成的代码,其结果如下

 class __lambda_7_13
  {
    public: 
    inline void operator()() const
    {
      ++a;
    }
    
    private: 
    int & a;
    public: 
    // inline /*constexpr */ __lambda_7_13(__lambda_7_13 &&) noexcept = default;
    __lambda_7_13(int & _a)
    : a{_a}
    {}
    
  };
  
  __lambda_7_13 lb1 = __lambda_7_13(__lambda_7_13{a});

由上述代码可得出如下两点结论

  1. 同按值捕获场景一样,编译器生成的闭包类型有一个const属性的operator()接口
  2. 按引用捕获的变量a成为闭包类的成员变量,且其声明为相应的引用类型。

关于为什么在const 属性的operator()接口中可以修改引用变量a,需要搞明白顶层const与底层const的概念,相应的概念可参考C++ Primer 中文版(第 5 版)相应章节的内容,此处不细讲。

简单来说,成员函数const属性作用于成员变量相当于底层const,譬如指针int *p,为成员变量,这在const 属性成员函数中相当于声明int *const p, 即无法修改p,但可以修改*p;由于引用不存在底层const(在成员函数中), 因此,const属性对其没有影响,故可以修改引用成员变量。

关于解释也可参考:Modifying reference member from const member function in C++ - Stack Overflow

至此, 关于文章开头的问题已经讲解分析完毕。

保持变量的常量属性

简言之,当lambda表达式捕获一个const属性的变量a,则a在lambda表达式生成的函数体中,保持相应的const属性,其可以通过如下代码验证

int main() {
  std::cout << std::boolalpha;
  const int a = 1;
  auto lb1 = [a]() { 
    std::cout << "a is const: " << std::is_const<decltype(a)>::value << "\n";
   };
  lb1();

  return 0;
}

其输出结果为true。

可移动对象的捕获

针对该种对象的捕获,C++11中只能通过按引用捕获实现该种类型的对象捕获。

模版参数包捕获

本部分,仅讲解按值捕获模版参数包。lambda表达式可以通过在捕获列表中声明[args...]这种形式,进行可变参数捕获。

但在C++11中,如果需要使用这些捕获的参数,这需要一些辅助函数。

按值捕获参数包,可参考如代码

template <typename... Args>
void captureTest(Args... args) {
  auto lb = [args...] () {
    auto tpl = std::make_tuple(args...);
    std::cout << "tpl size: " << std::tuple_size<decltype(tpl)>::value << "\n";
  };
  lb();
}

int main() {
  captureTest(1.0, 2.0, "hello world");

  return 0;
}

上述代码输出结果为 3。

关于C++11中lambda表达式的属性总结,便到此结束。

下面进入高级使用特性。

lambda表达式高级使用特性

转换lambda表达式到函数指针

C++11/14 lambda表达式使用及解析 lambda与pthread_create的例子小节给出了一个lambda表达式转换为函数指针的例子。其给出了粗略的解释。下面给出更详尽的解释。

只有无状态闭包才能转换为函数指针,也即没有捕获任何变量的lambda表达式才能转换为相应的函数指针。

其在标准中也给出了如下定义[expr.prim.lambda]

The closure type for a lambda-expression with no lambda-capture has a public non- virtual non-explicit const conversion function to pointer to function having the same parameter and return types as the closure type’s function call operator. The value returned by this conversion function shall be the address of a function that, when invoked, has the same effect as invoking the closure type’s function call operator.

看一个来自C++11/14 lambda表达式使用及解析的例子,其lambda表达式如下

auto f = [] (int x) {return 42;};

通过C++ Insights观察到上述代码生成如下的类定义:

 class __lambda_6_11
  {
    public: 
    inline /*constexpr */ int operator()(int x) const
    {
      return 42;
    }
    
    using retType_6_11 = int (*)(int);
    inline /*constexpr */ operator retType_6_11 () const noexcept
    {
      return __invoke;
    };
    
    private: 
    static inline int __invoke(int x)
    {
      return 42;
    }
    
    
  };

由上述生成代码可知:

  1. 当lambda表达式的捕获列表为空时,其闭包类会生成一个转换操作符operator retType_611(), 该操作符会将lambda表达式转换成同operator() 相同声明的函数指针
  2. 在闭包类中同时定义了一个静态成员函数,同operator()接口声明类型一致
  3. 在operator retType_611()函数中,返回2中的静态成员函数

上述便为 无状态lambda表达式转为函数指针的全部内容。因此在pthread_create中,我们需要用无状态lambda表达式,且只能用该种表达式。

lambda表达式作为基类

由于lambda表达式会被编译器扩展为闭包类,故该闭包类当然也可作为继承体系中的基类。

在C++11种,由于无法直接获得闭包类,因此需要利用template获得相应的闭包类。具体可参考如下代码

template <typename Callable>
class Derived : public Callable {
public:
  Derived(Callable callable) : Callable(callable) {}
};

template <typename Callable>
Derived<Callable> makeDerived(Callable&& f) {
  return Derived<Callable>(std::forward<Callable>(f));
}

int main() {
  auto lb = makeDerived([](int i) {
    std::cout << "hello world! \n";
  });
  lb(0);

  return 0;
}

上述中makeDerived函数模版可看作为一个功能函数模版,主要是为了获得lambda表达式的闭包类,然后利用该类作为基类,构造Derived<Callable>类。

当然,你也可以继承多个闭包类,感兴趣可以自己实现。

lambda表达式存储于容器

很通用的场景,便是将一类lambda表达式存储在std::vector容器中。

在C++11中,我们一般通过std::function,将lambda表达式存储于vector容器中。

针对同类型的无状态lambda表达式,其可以直接存储于vector容器中,这是因为无状态lambda表达式会转换为函数指针,因此此时vector容器中,存储的为相应的同类型的函数指针。

在C++11中,如果你写如下的代码,编译器会报错

int main() {
  auto lb = []() {
  };
  std::vector<decltype(lb)> vc;
  vc.push_back([](){});
  
  return 0;
}

原因我们在本文中已经讲过,此处赘述一遍:每个lambda表达式所生成的闭包的类型都是唯一的且互不相同,而vector中需要存储相同类型的闭包,故上述代码不能通过编译。

总结

本文基本概括了C++11中lambda表达式需要了解的知识,本文未讲解lambda表达在多线程环境下的表现。如果发现本文有什么不对的地方,也欢迎补充指正。

附录

本文主要参考如下书籍内容总结而出

C++ Lambda Story: Everything you need to know about Lambda Expressions in Modern C++!

Effective Modern C++ 简体中文版

C++ Templates(第2版 英文版)

C++ Primer 中文版(第 5 版)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

qls315

感觉好可打赏几毛钱增强更新动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值