C++ lambda表达式教程

最近修改时间: 2020年1月10日 | 创建时间: 2019年3月25日

C++11引入了lambda表达式,而之后的语言版本不断的对lambda表达式进行改善。lambda表达式表示一个匿名函数。如今,lambda表达式已成为C++语言的核心组成部分,而这篇博文就是给还不了解lambda表达式的C++程序员讲解它的使用方法以及原理。

基本用法

编程中一个常见的任务是将函数作为参数传递给另外的函数。举一个例子,C++的标准算法库中很多的算法都应用给定的函数到一个范围。不幸的是,在C++11之前,可调用的对象在C++中只有函数指针与函数对象。它们的使用都比较复杂,这种状况甚至在很大程度上阻止了了标准算法库的普及使用。

与此同时,非常多的编程语言支持“匿名函数”功能。在C++11之前,C++使用元编程来模拟这个特性。例如Boost库就包含了boost.lambda库。因为当时语言自身的局限性,这些元编程的技术都存在各种问题,例如缓慢的编译速度和古怪的语法。因此,C++11在核心语言中加入了lambda表达式。C++标准中有一个把lambda表达式使用在sort算法上的例子:1

#include <algorithm>
#include <cmath>

void abssort(float* x, unsigned n) {
    std::sort(x, x + n,
        [](double a, double b) {
            return (std::abs(a) < std::abs(b));
        });
}

在函数abssort中,我们把一个lambda表达式作为二元比较函数传给了std::sort。我们也可以使用一个普通的函数来实现同一个目的:

#include <algorithm>
#include <cmath>

bool abs_less(double a, double b) {
    return (std::abs(a) < std::abs(b));
}

void abssort(float* x, unsigned n) {
    std::sort(x, x + n, abs_less);
}

我们仍然不知道奇怪的[]语法是用来干什么的,而这是我们接下来的话题。

捕获 (capture)

上面的示例展示了lambda表达式的基本用法,但是lambda表达式可以做更多的事情。lambda表达式和普通函数之间的主要区别是它可以捕获状态,然后我们可以在lambda体中使用捕获的变量。在如下的例子中,我们把大于某个阈值的元素从原来的vector复制给新的vector:

// Get a new vector<int> with element above a certain number in the old vector
std::vector<int> filter_above(const std::vector<int>& v, int threshold) {
    std::vector<int> result;
    std::copy_if(
      std::begin(v), std::end(v),
      std::back_insert_iterator(result),
      [threshold](int x){return x > threshold;});
    return result;
}

// filter_above(std::vector<int>{0, 1, 2, 4, 8, 16, 32}, 5) == std::vector<int>{8, 16, 32}

如上的代码复制捕获了threshold变量。lambda表达式支持两种不同的捕获符,分别是复制捕获以及引用捕获([&])。举个例子,[x, &y]复制捕获了变量x并且引用捕获了y。你也可以默认捕获所有在作用域(scope)中的变量,[=]复制捕获所有的变量而[&]引用捕获所有的变量。

我们把如同lambda表达式这样保存了相应自由变量环境的函数叫做闭包,而几乎所有现有的编程语言都对闭包有着一定的支持。不同的是,除了C++以外所有我知道的语言都是隐性地捕获所有在当前环境中的变量。

我们可以通过默认引用捕获([&])来模仿这些语言的行为。 但是,默认引用捕获在C++中不是一个好主意。 如果lambda的生存期长于捕获的对象,则可能导致悬空引用(dangling)的问题。 例如,我们可以将回调传递给一个异步函数:

auto greeter() {
    std::string name{"Lesley"};

    return std::async([&](){
        std::cout << "Hello " << name << '\n';
    });
}

如上的代码产生了未定义行为(undefined behavior)因为在异步函数被运行时name可能已经被销毁了。只有在lambda的生存期很短时使用默认引用捕获才不会产生问题。将lambda传递给STL算法就是一个很好的可以使用默认引用捕获例子。

在有垃圾回收的语言中这种默认捕获自然不会造成任何的问题。Rust语言使用借用检查器(borrow checker)来避免这些问题。反之,C++的lambda表达式需要程序员显式表达被捕获对象的所有权,这种办法比其他的语言更加的灵活,但反过来也需要程序员考虑更多的东西。

Lambda的原理

到目前为止,我们已经讨论了lambda表达式的很多用法。 但是,好奇的读者可能会开始怀疑,C++的lambda表达式到底是什么?它是像函数语言中的闭包一样的原生语言构造吗?不过,在讨论lambda表达式的实现原理之前,我们首先来讨论C++98时代就有的函数对象。

函数对象 (Function Object)

当一个对象可以像是一个函数一样被调用时,我们把它叫做函数对象。重载类中的operator()操作符是实现函数对象对象的方法。比如说,如下是我们abs_less例子的函数对象版本:

#include <algorithm>
#include <cmath>
class abs_less {
  bool operator()(double a, double b) {
    return (std::abs(a) < std::abs(b));
  }
};

void abssort(float* x, unsigned n) {
    std::sort(x, x + n, abs_less{});
}

函数对象比普通的函数更灵活,因为它们可以像一般的对象一样存储数据。 让我们用函数对象实现之前的filter_above例子:

template <typename T>
class GreaterThan {
public:
  GreaterThan(T threshold): threshold_{threshold} {
  }

  bool operator()(const T& other) noexcept {
    return other > threshold_;
  }

private:
  T threshold_;
};

std::vector<int> filter_above(const std::vector<int>& v, int threshold) {
    std::vector<int> result;
    std::copy_if(std::begin(v), std::end(v), std::back_insert_iterator(result), GreaterThan{threshold});
    return result;
}

从函数对象到lambda表达式

在C++中,lambda表达式是函数对象的语法糖。换言之,编译器会把lambda表达式转译为函数对象。你可以使用C++ Insights网站来看到我们的abssort被编译器翻译成的代码:

#include <algorithm>
#include <cmath>

void abssort(float * x, unsigned int n)
{

  class __lambda_6_9
  {
    public: inline /*constexpr */ bool operator()(float a, float b) const
    {
      return (std::abs(a) < std::abs(b));
    }

    ...
  };

  std::sort(x, x + n, __lambda_6_9{});
}

我们可以看到lambda表达式被翻译为一个默认构造的局部类,因此C++lambda表达式可以实现一些在别的语言中无法拥有的功能。例如我们可以把lambda表达式作为基类来继承或者给lambda表达式可变状态,虽然我并没有发现这些功能有太多的实际应用。

C++编译器会生成lambda表达式的类型,但是这些类型的名字是不会暴露给程序员的。尽管如此,对于这些类型,类型推论(type inference)以及模板仍然工作如常。我们也可以通过decltype来直接使用这些类型。如下是一个在cppreference上的例子:

auto f = [](int a, int b) -> int
    {
        return a * b;
    };

decltype(f) g = f;

在C++与D语言的社群中,这种无名类型有时候会被戏称为“伏地魔类型”(Voldemort's types)因为我们没有办法直接说出这些类型的名字但是仍然可以间接使用它们。

带有初始化器的捕获符

现在你理解了lambda表达式的本质不过是一个函数对象,你也许会期望lambda表达式能够储存任意的值而不仅仅是被捕获的本地变量。幸运的是C++14加入了通用捕获功能3。你在C++14中可以使用初始化器来为lambda表达式引入新的变量。

[x = 1]{ return x; // 1 }

捕获仅可移动的类型

Rust语言的lambdas表达式可以夺取被捕获对象的所有权(ownership)。C++ lambda对这种“移动捕获”没有特殊的支持,但是C++14中的通用捕获可以用来实现这种功能:

// unique_ptr是move only的类型
auto u = make_unique<some_type>(
  some, parameters
);
// 将unique_ptr的所有权移入lambda
go.run([u=move(u)] {
  do_something_with( u );
});

立即调用lambda (Immediately Invoked Lambda)

你可以在构建lambda表达式的同时调用它们。

[]() { std::puts("Hello world!"); }();

立即调用函数表达式(IIFE)在Javascript程序中非常的常见,Javascript程序员经常使用它们来引入新的作用域。在C++中我们不需要使用Lambda来引用作用域,因此立即调用lambda在C++程序中就不是那么的常见。

然而如果你希望遵守尽量让变量成为const的最佳实践,立即调用lambda在C++任然有自己的妙用。许多对象有一个复杂的初始化过程,而在初始化之后就不会再被改变了。如果这种初始化的过程有被封装的价值,你也许会考虑写一个工厂函数(factory function)或者使用建造者模式(Builder pattern)。然而在当这种初始化只被使用一次的时候,很多的人会停止使用const。假设你需要从stdin读取数行到一个vector,你可能会将代码写为如下:

std::vector<std::string> lines;
for (std::string line;
     std::getline(std::cin, line);) {
    lines.push_back(line);
}

由于我们要在循环中对其进行修改,所有似乎没有办法让lines成为不变量。立即调用lambda解决了这一难题。 有了它,你既可以拥有constlines又不需要引入复杂的设计模式:

const auto lines = []{
    std::vector<std::string> lines;
    for (std::string line;
         std::getline(std::cin, line);) {
        lines.push_back(line);
    }
    return lines;
}();