C ++ lambdas函数,适合初学者

作者 : IT 大叔 本文共9413个字,预计阅读时间需要24分钟 发布时间: 2020-09-14

当C ++ 11发布时,它带来了很多好处,而语言库中最好的补充之一就是lambda函数

大多数人最先接触C ++中的lambda函数的方式是散布在代码中的“可抛弃函数”,因为在大多数时候,这就是lambda的用途。举个例子:

auto addition = [](int x, int y) { return x + y; };
std::cout << addition(4, 3) << std::endl;
std::cout << addition(99, 1) << std::endl;

在这里,创建了一个lambda函数以执行两个数字之间的简单加法,然后每次调用lambda两次,每次输入参数不同。当执行退出创建lambda对象的范围时,lambda将被破坏,就像其他任何本地变量一样。

不过,Lambda可以做的还不止这些。Lambda是可执行对象,与其他对象一样,它们可以移动,复制,存储在容器中,作为参数传递等。Lambda可以携带它们的上下文,以便可以在一个地方创建并转移到另一个地方执行。Lambda可以是有状态的,也可以链接到外部对象的状态。

必须说,lambda并没有真正为C ++表带来任何根本性的新变化。如果您不费力气地编写lambda,那么现在可以使用旧的仿函数对象(可以像函数一样调用的对象)在C ++ 11之前的版本中使用lambda进行的所有操作。

不过,lambda表示法的真正优势在于,它提供了一种紧凑的方式来描述可执行对象,从而使编译器可以为我们执行实现。然后,程序员可以将重点放在“ 什么”而不是“ 如何”上,并探索原本可以避免的设计可能性

在本文的其余部分中,我将尝试向您介绍如何声明lambda函数以及变量捕获的来龙去脉。

请注意,本文针对尝试“获取它”的初学者和/或正在寻求刷新的中级用户。如果您已经“获取” lambda,则可以在最后尝试引用这些参考资料以获得更好的材料。

Lambda的全名

让我们先从语义上入手。

实际上,没有lambda这样的东西。“ Lambda”只是一个俗语,根据上下文,通常指的是三件事之一。

  • lambda表达式
  • 一个闭包对象
  • 一个封闭类

所有这三个词都在下面的句子中出现:

auto addition = [](int x, int y) { return x + y; };

这里,

  • [](int x, int y) { return x + y; } 是lambda表达式。
  • addition 是闭包对象。
  • 您看不到闭包类,但是它以闭包对象类型的形式存在。

每当您听到有人在谈论lambda时,他们很可能在谈论上述三个之一,并且在大多数情况下,它将是前两个的其中之一(第三个的新闻时间要短得多)。

我会保持清淡,并讨论lambdas何时更适合我,但是当区别很重要时,我将使用上述术语。

lambda表达式分析

lambda表达式是代码中lambda最可见的表达式。

lambda表达式的结构是

[...capture list...](...parameters...) -> <return type> { ... body ... }

但实际上,这要简单得多。实际上,所有lambda表达式中最卑鄙的是

[]{}

它定义了一个lambda,该lambda定义不捕获任何内容,不接受任何参数,不执行任何操作且不返回任何数据;换句话说,lambda等效于以下功能:

void f() {}

就像函数声明一样,lambda的代码括在{}方括号内,输入参数放在()对内。关于lambda的这两个组件,没有太多要说的,因为它们在形式和功能上都遵循函数声明中的等效形式。

()参数列表可以当拉姆达并不意味着收到任何参数可以被完全忽略。这就是为什么最谦虚的lambda []{}不是[](){}。但是,如果指定了返回类型,则()即使参数列表为空,也需要编写返回值。

我想详细谈谈捕获,这是将lambda与仅作为一次性函数分离开来的原因,但允许我首先避免一些麻烦,并首先讨论lambda的返回类型。

Lambda的返回类型

lambda的返回值类型可以从表达式中省略,大多数人不会编写它。当类型不存在时,编译器将从表达式的其余部分推断出它。

除非我真的需要,否则我不会在本文的其余示例中编写返回类型。这就是为什么我想提早完成这项工作的原因:它对我来说少打字。

仅在少数情况下,您会看到lambda表达式完全说明了lambda的返回类型。在这些情况下,通常是由于以下两个原因之一:

  • lambda主体使得返回类型对于编译器是不明确的。
  • 编码人员希望将返回类型强制为不同于编译器推断的类型。

每当返回类型没有在lambda表达式写的编译器使用一个简单而有效的一套规则,以确定什么类型你的意思返回:

  • 如果没有return语句,或者存在的语句returns没有返回值(例如return;),则lambda被假定为return void
  • 如果只有一条return语句并且它有一个返回表达式,则返回类型将是对表达式求值的结果。
  • 如果有多个return语句,并且所有语句都具有一个返回表达式,该返回表达式的值完全相同,则该类型将为lambda的返回类型。
  • 如果以上都不是,编译器将放弃,然后您必须明确声明返回类型。

一些例子:

这声明了一个lambda,它只返回void

auto f1 = [](){};  // could be just []{}

在这里,return表达式的计算结果为int,因此lambda的返回类型将是相同的类型:

auto f3 = [](int n) { return n; };

表达式中的返回类型的计算结果为bool,因此它将是返回值的类型:

auto f4 = [](int n) { return n > 5; };

在以下示例中,有两个返回表达式,但两个都是bool。由于没有歧义,因此返回类型为bool

auto f5 = [](int n) {
  if (n == 5) {
      return true;
  } else {
      return false;
  }
};

在这种情况下,有两个return语句,其表达式的计算结果为不同的类型(boolint)。在这种情况下,程序员需要明确声明返回类型,因为编译器无法决定返回类型应为:

auto f6 = [](int n) -> bool {                
  if (n < 0) {
      return false;
  } else {
      return n; /* returns true if n > 0 */
  }
};

在这种情况下,我们声明返回值以强制返回类型为a bool而不是an int

auto f7 = [](const std::vector& n) -> bool { return v.size(); };

捕获清单

Lambda的捕获列表是Lambda 包围范围内的变量名称的列表,必须对其进行捕获才能在Lambda中进行访问。

在lambda表达式中,捕获列表写在方括号之间[]。它不是可选的,即使捕获列表为空,也必须使用方括号。

捕获会在lambda主体范围内创建一个变量,该变量与要捕获的变量具有相同的名称,并且可以(取决于捕获的方式)是外部变量的副本,也可以是对其的引用

生成外部变量副本的捕获称为按副本捕获,而生成对外部变量的引用的捕获称为按引用捕获。

  • 通过拷贝捕获创建是变量的副本拉姆达的范围内的变量被捕获,具有相同的值的后面有在拉姆达被创建的时间。按拷贝捕获是只读的,除非lambda是可变的(稍后会详细介绍)。
  • 按引用捕获存储到变量的引用被捕获,其可以被用来读取或拉姆达的寿命期间在任何时候更新所述外部变量的值。

为了以示例为基础定义这些定义,请看下面的代码片段。在其中定义了两个局部变量,然后定义了捕获它们的lambda函数。

int foo = 33;
int bar = 22;
auto within_range = [foo, &bar](int n) {
  return foo + bar;
}

lambda表达式中的捕获列表捕获两个变量:foo按副本bar捕获,而按引用捕获。

重要的是要意识到,虽然捕获的变量的名称与它们所镜像的外部变量的名称相同,但是它们是不同的变量。也就是说foolambda体内的变量与foo外部的变量不同。

正如您可能从示例中猜到的那样,捕获模式是通过在按引用捕获之前加上来声明的&。另一方面,按副本捕获根本没有前缀。

另外,有两种默认的捕获模式,它们使我们可以捕获主体中使用的但捕获列表中未明确提及的每个变量:

  • 裸露者&将通过引用捕获任何使用但未明确捕获的副本。
  • 裸露者=将按拷贝捕获所有使用但未被显式捕获的引用。

在典型的捕获列表中,您会发现默认捕获模式与命名捕获的混合。但是,对于这种混合有一些规则。

  • 默认模式=&不能同时存在。
  • 如果存在默认模式,则它必须位于列表的前面。
  • 如果存在=默认捕获,则后面的任何命名捕获都必须是按引用的。
  • 如果存在&默认捕获,则随后的任何命名捕获都必须是副本。如果您考虑一下这些规则就很有意义,因此不必太担心它们。

这些是典型捕获列表的一些示例:

  • []空的捕获列表,不会捕获任何内容。
  • [foo]foo按副本捕获。
  • [&bar]bar通过引用捕获。
  • [foo,&bar]foo按副本和bar按引用捕获。
  • [=]从包含的作用域中按副本捕获任何命名的内容。
  • [&]通过引用捕获从范围内命名的任何内容。
  • [&,FOO,巴]通过参考从封闭范围命名捕获任何东西,除了foobar必须由拷贝被捕获。
  • [=,&foo]通过拷贝捕获从范围内命名的任何东西,除非foo必须按引用捕获。

可变λ

默认情况下,按拷贝捕获是不可写的,因此以下片段是错误的:

int value;
auto bad_lambda = [value]() { value += 10; };

如果将lambda声明为,则可以使按副本捕获可写mutable。这使Lambda成为有状态的:您对按副本捕获所做的任何更改都将被带到下一个执行相同Lambda的操作。

例如,在此示例中,lambda将记住对捕获initial_value变量的值的任何更新。但是,外部变量的值将保持不变,因为lambda会更新副本。

int initial_value{5};

auto counter_lambda = [initial_value]() mutable {
    std::cout << initial_value++ << std::endl;
};

// each call will increment the internal copy
// stored within the lambda, and change carry over to 
// the next call.
counter_lambda(); // will print 5
counter_lambda(); // will print 6
counter_lambda(); // will print 7

// the original variable outside of the lambda is unchanged
std::cout << initial_value << std::endl;

另一方面,无论lambda是否存在,都可以读取和写入by-reference捕获mutable

int total{0};

auto accumulate = [&total](int n) { total += n; };

// each call updates the value of the references variable
accumulate(1);
accumulate(2);
accumulate(3);

// print the accumulated value, 6
std::cout << total << std::endl;

通用捕获(C ++ 14及更高版本)

到目前为止,所有讨论都与捕获有关,因为捕获是在C ++ 11发布时引入的。

这些捕获效果很好,可以做很多事情,但是过了一会儿,您会发现它们有两种不足之处:

  • 捕获始终与捕获的变量具有相同的名称。当然,这没什么大不了的,但是有时候您希望能够使用其他名称。
  • 为了捕获一个值,它需要预先存储在一个变量中。无法捕获表达式的结果。
  • 您不能在捕获中使用移动语义。捕获的对象需要可复制;如果不是,则需要按引用捕获它们,这可能会导致所有权问题,或者您需要执行其他一些技巧。如果您经常使用,这可能会特别烦人unique_ptr

为了解决这个问题,C ++ 14升级了lambda generalized lambda captures

  • 允许您随意命名捕获的内部名称。
  • 不仅可以捕获变量,还可以捕获表达式的结果(仅按副本)。
  • 更重要的是,允许您捕获仅移动变量,例如unique_ptr实例。

这些可喜的改进所要付出的代价是,与常规捕获相比,广义捕获更加冗长,因为您需要说明要捕获的变量的名称以及在lambda中创建的捕获变量的名称。语法为:

  • internal_name=expression 用于按副本捕获。
  • &internal_name=external_name 用于按引用捕获。

例如,此示例使用广义捕获counter通过引用捕获(cnt在lambda中命名),并且还捕获3 * mean_level通过复制捕获的结果(limit在lambda中命名结果)。

int mean_level{5};
int counter{0};

auto f = [&cnt = counter, limit = 3 * mean_level]() {
  if (cnt < limit) cnt++;
};

要捕获仅移动的对象,只需确保按副本分配的右侧是一个右值,可以通过提供一个临时值或使用来完成std::move

auto adapter = std::make_unique<Adapter>();

auto runner = [adapter = std::move(adapter)]() { adapter->run(); }

考虑到甚至不需要付费,广义lambda捕获的额外冗长代价是很小的:您仍然可以使用更适合自己的常规C ++ 11捕获,并将广义捕获和常规捕获混合使用充分利用每个:

auto f = [&counter, limit = 3 * mean_level]() {
  if (counter < limit) counter++;
};

什么可以和不能被捕获

之前我说过,只能捕获lambda的局部局部范围内的变量。我只是顺便提到了它,当我说它时,它可能飞入了雷达。

但是,这不是次要的细节或技术性,并且看看为什么让我们看到无法捕获的内容。

  • 无法捕获全局范围变量和静态数据成员。
  • 非静态的班级成员无法直接捕获。

如果您想到它,第一个可能会很明显,因为可以从任何函数中访问全局变量,并且lambda是类似函数的(“可调用”)对象,因此没有理由将它们作为例外。

不过,您应该记住,必须像按引用捕获那样在lambda中考虑全局变量。这可能在多线程程序中具有重要意义。

静态类成员只是伪装的全局变量,因此就lambda而言,它们具有完全相同的限制也就不足为奇了。

非静态的类成员也不能被捕获,但是这里的事实更加细微:实际上,它们可以,但是,不能以捕获常规局部变量的工作的相同意义来捕获它们。

但是,在深入探讨这一点之前,我们需要绕过一小段时间来讨论指针this

捕获和this指针

在执行期间,每个非静态类方法都可以访问隐式创建的this指针,该指针引用在其上调用该方法的实例。这就是使方法可以访问类的非静态数据成员的原因。

通常,您不需要显式地取消引用该指针,因为编译器将为您完成该操作,但是如果想要更显式的话,则可以。例如,在这个片段中

class Value {
 public:
  void set(const int x) { x_ = x; }
  int get() const { return x_; }
 private:
  int x_;
};

我们可以this通过将两个方法重写为

void set(const int x) { this->x_ = x; }
int get() const { return this->x_; }

重要的是要注意,这不会改变代码的编译方式,只会使编译器在背后做清楚的事情。

this之所以说这条弯路(是出于双关语),是因为现在我们已经揭露了对数据成员的访问方式的工作原理,因此更容易理解非静态数据成员捕获的工作方式的细微差别

无法捕获非静态类成员,因为它们不在lambda周围的本地范围内,而是在类范围内。

但是,this可以捕获指针,因为它是在类的非静态方法中创建的lambda的直接范围内的变量!通过捕获thislambda,可以访问所有非静态数据成员以及实例方法。

为了捕获它,您只需将其添加到捕获列表中:

auto is_empty = [this]() { return queue_.empty(); } 

一定要注意,this它不是常规变量,这很重要,它的性质对捕获过程施加了限制:this只能按拷贝捕获;尝试像in [&this]中那样捕获指针是语法错误。默认捕获模式也将捕获this,但是请注意,即使this&默认捕获模式捕获,指针仍将被复制。

现在,抓住了这个问题:请记住,这this不是实例,this而是指向实例的指针。您不是通过复制捕获实例,而是仅复制指向它的指针。

这真的很重要,因为这意味着对实例成员的任何访问仍然像引用一样:通过取消引用thislambda来访问原始外部变量,而不是它们的副本!

这就是某些资料将捕获解释[this]通过引用捕获对象的原因。这不是完全正确,但是足够接近。

现在,这个细节可能使您难以忍受,特别是如果您误以为这[=]意味着“所有内容都被副本捕获”,如以下示例所示:

#include <iostream>
#include <functional>
#include <string>

using namespace std;
using Filter = std::function<bool(const std::string &)>;

class FilterFactory {
 public:
  Filter buildFilter(const string &name) {
    name_ = name;
    return [=](const std::string &name) { 
        return (name == name_);
    };
  }
 private:
  std::string name_;
};

int main() {
    FilterFactory factory;
    auto filter_adam = factory.buildFilter("adam");
    auto filter_eva  = factory.buildFilter("eva");
    cout << filter_adam("adam") << endl; // should have returned true, but returns false
    cout << filter_adam("eva") << endl;  // should have returned false, but returns true
    cout << filter_eva("adam") << endl;
    cout << filter_eva("eva") << endl;
}

在这里,毫无戒心的程序员可能希望[=]通过副本捕获所有内容,包括类成员。那将使每个lambda完全独立且独立于原始工厂实例。

但是,实际上,默认捕获模式根本不捕获name_。就是this被捕获了,name_实际上是通过进行访问this->name_

代码会生成,但是行为不是编码人员所期望的。出于所有实际目的name_,可以通过引用来访问它们,从而允许lambda“查看”对创建lambda之后所做的变量值的更改。

而且,情况变得更糟:如果factory变量在lambda之前被销毁,则this存储在lambda中的指针将变为无效,并且对其所指向的实例的数据成员的任何访问都将变为未定义的行为。

封闭类的直觉

在结束本文之前,我将先介绍一下闭包类。这个想法不是很严格,而只是为读者提供了一个直观的信息,说明如何在后台通过编译器实现lambda。

首先要说明的是,没有单个闭包类。编译器会为每个lambda表达式自动创建一个自定义的关闭类。

这些编译器生成的类无法看到或更改,因为只有编译器知道它们的外观。这就是为什么经常说闭包对象具有匿名类型的原因。

为了深入了解,我们可以根据lambda表达式中的描述实现自己的闭包类。在不失一般性的前提下,可以说我们被要求像下面的片段中那样编译一个片段:

int foo;
bool bar;

auto lf = [foo, &bar](int factor) { return foo * bar * factor; };

从表达式中我们可以推断出,在构造时,lambda需要捕获两个变量,一个通过复制(一个整数),另一个通过引用(一个布尔值)。从表达式实例化的la​​mbda对象必须可以使用单个输入参数(整数)进行调用,并且必须在执行后返回一个值(另一个整数)。

在上面的示例中,lambda的闭包类型的可能实现是以下一种:

class ClosureType {
 public:
    ClosureType(int foo, double &bar) : foo_{foo}, bar_{bar} {}

    double operator()(int factor) const {
        return foo_ * bar_ * factor;
    }
  private:
   int foo_;
   double &bar_;
};

在哪里可以看到:

  • 捕获成为闭包类的构造函数参数。
  • 像这样的按副本捕获foo存储在每个实例的成员变量中。
  • 像这样的按引用捕获&bar不会自己存储,但是对它们的引用存储在类中。
  • 通过重载operator()由类产生的闭包对象,可调用对象。
  • lambda表达式的代码主体,返回类型和参数列表成为operator()重载的主体,返回类型和参数列表。
  • 在这种情况下,operator()重载是一种const方法,因为lambda不是mutable

在上面的闭包示例中,很容易看到捕获的变量在构造时被读取,而lambda参数在执行时被传递给lambda。

尽管不是很严格,但上面的示例足以让您了解闭包类的外观,以及表达式的每个部分如何影响lambda的实现。

闭包类示例显示捕获的数量和方式如何影响从中实例化的闭包对象的大小:lambda不仅仅是代码(就像函数一样),它们还带有上下文,并且大小上下文的大小取决于捕获的大小和类型。

引用捕获很轻巧,仅添加了一个指向lambda对象大小的指针,但它们不保证捕获的对象至少会存在,只要lambda存在。另一方面,按副本捕获确实可以保证生命周期,但是由于复制操作,它们很容易移动。到处移动lambda至少会和它们拥有的最昂贵的按拷贝捕获一样昂贵。

实际上,编译器可以使用各种lambda实现,具体取决于哪种情况更适合每种情况。特别是,如果lambda不能捕获任何变量,则使用简单的匿名函数而不是匿名类来实现它通常会更便宜;在这种情况下,闭包对象只是指向该匿名函数的指针。

这就是为什么可以将不捕获的lambda分配给正确的指针到函数类型变量的变量,但是不能将捕获变量的lambda分配给它们的原因。

int factor = 2;
auto no_capture_lambda = [](int n) { return 2 * n; }; 
auto with_capture_lambda = [factor](int n) { return factor * n; }; 

int (*f_ptr_1)(int) = no_capture_lambda;   // this is ok
int (*f_ptr_2)(int) = with_capture_lambda; // this fails to build

免责声明:
1. 本站资源转自互联网,源码资源分享仅供交流学习,下载后切勿用于商业用途,否则开发者追究责任与本站无关!
2. 本站使用「署名 4.0 国际」创作协议,可自由转载、引用,但需署名原版权作者且注明文章出处
3. 未登录无法下载,登录使用金币下载所有资源。
IT小站 » C ++ lambdas函数,适合初学者

常见问题FAQ

没有金币/金币不足 怎么办?
本站已开通每日签到送金币,每日签到赠送五枚金币,金币可累积。
所有资源普通会员都能下载吗?
本站所有资源普通会员都可以下载,需要消耗金币下载的白金会员资源,通过每日签到,即可获取免费金币,金币可累积使用。

发表评论