Lambdas in C++

1. Function Pointers

我们将函数视为对黑匣子内部操作的抽象封装。函数隐藏了内部细节,只向外界暴露其提供的接口。而在内存中,这个黑匣子需要占用存储空间(.text 段)。因此,函数是有地址的。函数地址就是指向内存中‘黑匣子’所存放的起始地址,而函数指针则是指向函数地址的指针。

因为函数可以取地址,所以我们可以将函数指针作为参数去传递。我们下面举例:

#include <iostream>
void hello(){
	std::cout << "Hello" << std::endl;
}
void test(void(*ptr)()){
    ptr();
}
int main(){
	auto funcPtr = hello; // same as `void(*funcPtr)() = hello;`
	test(funcPtr); // passing function pointer as a parameter
    funcPtr(); // call hello() using function pointer
}

例二:

#include <iostream>
#include <vector>

void PrintValue(int value){
	std::cout << "Value: " << value << std::endl;
}
void ForEach(const std::vector<int>& values, void(*print)(int)){
	for(int value : values)
		print(value);
}

int main(){
	std::vector<int> values = {1, 2, 3, 5, 9};
	ForEach(values, PrintValue);
	return 0;
}

例二使用模板和lambda表达式后,我们能够优雅地遍历打印基本类型的vector。从这里我们就能够感受到lambda表达式的便利性,因为你不需要单独定义一个函数了,简化了代码。

#include <iostream>
#include <vector>

template<typename T>
void ForEach(const std::vector<T>& values, void(*funcPtr)(T))
{
    for (const T& value : values)
        print(value);
}
int main()
{
    std::vector<int> values = {1, 2, 3, 5, 9};
    ForEach(values, [](int value){std::cout << "Value: " << value << std::endl;});
    return 0;
}

2. Functors() : Functions with State

2.1 Functor

仿函数,也叫函数对象(function object),是可以像函数一样被调用的对象,常常用重载运算符 operator() 实现。我们用一个例子来说明为什么被称为仿函数:

#include <iostream>
struct Increment
{
    int number;
    Increment(int n) : number(n) {}
    int operator()(int x) {
        number += x;
        return number;
    }
};

int main() {
    Increment inc(5);
    std::cout << inc(15) << std::endl; // call functor just like functions
    return 0;
}

由于重载了 operator(),仿函数的调用能够像调用函数一样方便自然。而且通过类内的变量 number 能够保留每次被调用后的状态信息,这时函数所不能够做到的。仿函数结合了类的状态管理和函数的调用方式,提供了比普通函数更高的灵活性和功能。

2.2 Functor with std::for_each

#include <iostream>
#include <vector>
#include <algorithm>

class Print {
public:
    void operator()(int x) const {
        std::cout << "Value: " << x << std::endl;
    }
};
int main() {
    std::vector<int> values = {1, 2, 3, 4, 5};
    std::for_each(values.begin(), values.end(), Print());
    return 0;
}

3. Lambda Expressions : Unnamed Function Objects

如果刚刚接触Lambdas,你一定会困惑于如此多的括号。在Lambda中,我们有()[]{}。如果带有参数模板你甚至还会看到<>。我们先看看这些Lambda表达式。

Lambda Expressions
[ captures ] ( params ) specs requires(optional) { body }
[ captures ] { body }
[ captures ] specs { body }
[ captures ] < tparams > requires(optional) ( params ) specs requires(optional) { body }
[ captures ] < tparams > requires(optional) { body }
[ captures ] < tparams > requires(optional) specs { body }

3.1 Lambda: Closures without Garbage Collection

Lambda constructs a closure : an unnamed function object capable of capturing variables in scope.

Lambda表达式构造了一个闭包,在3.2节中我们将会学到(其中[]叫做捕获列表)。通过Lambda表达式,我们实际上能够定义一个具有捕获作用域内变量能力的匿名函数对象。什么意思呢?即在仿函数(函数对象)的基础上,还加入了捕获当前栈帧中局部变量的能力。

虽然匿名函数对象虽然是匿名的,但我们仍然可以让一个函数指针指向我们的 Lambda 表达式,从而达到 Store the Lambda 的目的。在下面的例子中,Lambda 表达式被转换为函数指针 Print,并且我们可以通过这个指针调用 Lambda 表达式,实现存储和调用。

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
	int i = 5;
	double j = 6;
	char c = 's';
    //auto Print = [=](int v){
    //    std::cout << "value:" << v << std::endl;
    //};
    std::vector<int> values = {1, 2, 3, 4, 5};
    std::for_each(values.begin(), values.end(), 
					[=](int v){
					    std::cout << "value:" << v << std::endl;
					}); 
    // std::for_each(values.begin(), values.end(), Print); 
    return 0;
}

如果我们想看看 Lambda 背后的仿函数真身是什么,接着上面的例子,我们会看到编译器实际上生成了一个仿函数,捕获的成员变量会作为仿函数类中的私有成员变量存储。编译器生成的代码大概是这样的:

#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
  int i = 5;
  double j = 6;
  char c = 's';
  std::vector<int, std::allocator<int> > values = std::vector<int, std::allocator<int> >{std::initializer_list<int>{1, 2, 3, 4, 5}, std::allocator<int>()};
    
  class __lambda_14_6
  {
    public: 
    __lambda_14_6(int i, double j, char c) : i(i), j(j), c(c) {}
    
    inline /*constexpr */ void operator()(int v) const
    {
      std::operator<<(std::cout, "value:").operator<<(v).operator<<(std::endl);
    }
    
    private:
    int i;
    double j;
    char c;
  };
  
  std::for_each(values.begin(), values.end(), __lambda_14_6(i, j, c));
  return 0;
}

3.1.1 How Does Lambda Work

所以,Lambda 表达式是如何实现的?我们现在能明白的是编译器会将 Lambda 表达式转换成一个匿名类,而且 Lambda 是由仿函数实现的,捕获的变量会作为仿函数类中的成员变量。那从 Lambda 表达式到仿函数这一过程中会发生什么?

首先会进行语法解析和捕获[] 叫做 CAPCHA method,它会按照既定方式捕获作用域内的局部变量(按值或按引用)虽然我们[=]自动按值捕获所有的局部变量,但由于一个都没有用,所有匿名类中也是不会对这些局部变量进行构造的。之后会生成匿名类,类中包括我们所捕获的变量并完成对运算符operator()的重载。

Lambda 中 CAPCHA method 中捕获的变量就会在生成匿名类时对这些变量进行构造,当然,这些变量会被作为类内的 private 变量存在。下面,我们来看看 Lambda 的捕获规则。

3.2 The Capture Rules

我们用捕获std::string对象作为例子来展示不同类型的捕获类型。我们下面会介绍按值捕获按引用捕获按右值捕获

3.2.1 Captured by Value(Copy)

首先,我们来看看当捕获列表中按值捕获一个对象时会发生什么。最开始,我们用 std::string str = "Hello!"; 定义了一个 std::string 类型的对象。这时,str 指针在栈上,而字符串内容 "Hello!" 实际上存储在堆上。

然后,auto capFunc = [newStr = str]() mutable {} 这行代码将 lambda 表达式的地址赋给了 capFunc。在 lambda 表达式中,[newStr = str] 相当于auto newStr = str;。在构造匿名类时,会在匿名类的 private 域中增加一个 std::string newStr; 成员,并生成相应的复制构造函数。

由于 std::string 类中对拷贝赋值运算符进行了重写,拷贝时进行深拷贝。所以按值捕获后,我们在栈上有两个不同的指针指向两个堆上字符串。我们对拷贝的字符串进行修改,打印出来。

#include <iostream>
#include <string>

int main()
{
    std::string str = "Hello!";
    auto capFunc = [newStr = str]() mutable
    {
        std::cout << "In lambda: " << newStr << std::endl;
        if (!(newStr).empty())
        {
            newStr = "Changed!";
            std::cout << "In lambda: " << newStr << std::endl;
        }
    };
    capFunc();
    std::cout << str << std::endl;
	return 0;
}

按值拷贝的对象改变并不会影响原来的被拷贝对象。

$ ./lambda
In lambda: Hello!
In lambda: Changed!
Hello!

使用 mutable 关键字的原因是,在生成匿名类时,它会去掉重载运算符后的 const 限定符,这样我们就可以在 lambda 中修改按值捕获的变量。如下所示,常方法不允许对类内对象进行修改,所以加入 mutable 关键字,使得修改按值捕获的副本成为可能。

inline /*constexpr */ void operator()() const {} // const by default

3.2.2 Captured by Pointer and Reference

当我们像下面这样按指针或是用引用捕获时,我们其实不太需要考虑 mutable 了。反而,我们这时应当开始关注指针悬挂引用悬挂的问题。(按指针捕获可以看作是按值捕获)

int i = 5;
std::string str0 = "Hello ";
std::string str1 = "World!";
	// captured by pointer
    auto capFunc = [_i = &i, _str0 = &str0, _str1 = &str1]() 
    {
    // any dereference operations will change original object
    }

    // captured by reference
    auto capFunc = [&_i = i, &_str0 = str0, &_str1 = str1]() 
    {
    // any operations will change original object
    }

由于我们用指针解引用和引用修改源对象的方式实际上修改的是指针指向的值或引用原对象的值,并不会修改匿名类内的对象,所以我们并不需要去添加mutable关键字。然而,我们需要注意,假如lambda返回了相关的指针或是引用,而原对象在使用返回值之前释放掉了,就会引发指针悬挂或引用悬挂的问题

3.2.3 Captured by std::move

除了常见的按值捕获、按引用捕获和按指针捕获,我们还可以在此引入移动语义从而实现按右值捕获。通过 std::movestr 转移到 _str ,使得 _str 拥有原 str 的资源(即所有权转移了)。对象的所有权一旦转移,str 就不再可用,想要之后继续用到同一个 string 对象,我们可用返回该对象的引用,甚至返回其右值,转移所有权。

#include <iostream>
#include <string>
#include <utility> // std::move

int main() {
    std::string str = "Hello World!";
    auto capFunc = [_str = std::move(str)]() mutable -> std::string& {
        std::cout << "In lambda: " << _str << std::endl;
        if (!_str.empty()) {
            _str = "Changed!";
            std::cout << "In lambda: " << _str << std::endl;
        }
        return _str;
    };
    std::string& refStr = capFunc();
    // str is moved, thus it's now unusable
    std::cout << "Original string after move: " << str << std::endl;
    // 
    std::cout << "Original string after move: " << refStr << std::endl;
    return 0;
}

同样,我们能这么做是因为 std::string 中对The Rule of Five in C++有完整的实现。

3.3 Capture this

当我们在类成员函数中使用 lambda 表达式时,我们可能想要在 lambda 中输出类成员变量的值。如下面代码中所示:

#include <iostream>

class myClass {
private:
    int counter{0};
public:
    void printCounter() {
        auto lambda = []() {
            std::cout << "counter: " << counter << std::endl;
        };
        lambda();
    }
};

int main() {
    myClass obj;
    obj.printCounter();
    return 0;
}

因为我们的捕获列表中并没有捕获任何东西,所以编译器会报错。我们学过 this 关键字,知道每个实例化的类都会有一个 this 指针指向实例的数据成员(相当于指向 C-style struct)。由于 this 指针是隐性提供的,所以对 this 的捕获是特殊的。

class myClass {
private:
    int counter{0};
public:
    void printCounter() {
        auto lambda = [this](){}; // capture `this` by reference
        auto lambda = [*this](){}; // capture `this` by value(since c++17)
    }
};

我们可以按值捕获或者按引用捕获 this 指针。

x. How Do You Call Lambda Outside of a Scope

Lambda 可以在作用域外调用么?我以为不行,但实际上可以。

这是一道面试题,在被问到这个问题时,脑袋空空。虽然我也明白 lambda 会生成一个匿名类,但我此刻脑中想的是:“既然 lambda 是可调用对象。我先将他看成一个作用域中的调用函数。” 如:

void outsideFunction(){
	void insideFunction(){
	}
}
int main(){
	insideFunction(); // Is this legal?
}

我脑子里面想到的是上面这样的情景。在 main() 里面, insideFunction 肯定无法调用啊,它又不是全局函数,没有自己的符号,在链接地址重定位时怎么知道它在哪里?我觉得我分析的没有问题,但是确实是一个知识盲区。随即斩钉截铁地回答:“我认为是不行的,对“。

但是函数和类对象还是不一样的。Lambda表达式是闭包对象,而函数是函数符号。函数需要编译期的函数符号来调用,而类对象是类在运行中的实例。虽然 Lambda 的行为类似于函数,但是它遵循对象的作用域规则。所以你可以用一个全局的函数对象来存储作用域内的 Lambda 。

我们先举一个非常简单的例子:

#include <iostream>
int insideInteger;

void outsideFunction() {
    int value = 42;
    insideInteger = value;
}

int main() {
	outsideFunction();
	std::cout << insideInteger << std::endl;
    return 0;
}

在上面的例子中,我们通过一个全局变量来获取函数内部的一个 int 型的变量。同样的,我们可以通过一个全局的 std::function 来获取 lambda 类对象变量。如下:

#include <iostream>
#include <functional>

std::function<void()> insideLambda;

void outsideFunction() {
    int value = 42;
    insideLambda = [value]() { std::cout << value; };
}

int main() {
    outsideFunction();
    insideLambda();
    return 0;
}