深入理解 C++ 的 lambda 表达式

Table of Contents

C++ lambda 表达式 出现10多年了,还是有人不甚了解。这就是 C++ 最垃圾的地方:你需要知道编译器是怎么做的,才能真正理解它的语法。本文是对 C++ lambda 表达式的一个简述,争取做到深入浅出彻底理解。注意我们不讲 Lambda 演算。

1 Lambda 表达式是什么

如果我们要对一个结构体列表按照其中一个成员来排序,在没有 lambda 表达式的情况下,我们大概会这么写:

#include <algorithm>
#include <string>
#include <vector>

using Date = std::string;

struct Person {
  std::string name;
  Date birthday;
};

std::vector<Person> peoples{{"Marie Curie", "1867-11-7"},
                            {"Albert Einstein", "1879-3-14"},
                            {"Johann Carl Friedrich Gauß", "1777-4-30"}};

void sortPeopleNoLambda() {
  // 按照姓名排序
  struct ByName {
    bool operator()(const Person& a, const Person& b) {
      return a.name < b.name;
    }
  };
  std::sort(peoples.begin(), peoples.end(), ByName{});
}

使用 lambda 表达式,代码会简单一些:

void sortPeopleWithLambda() {
  std::sort(peoples.begin(), peoples.end(), [](const Person& a, const Person& b){
    return a.name < b.name;
  });
}

实际上,根据C++ 标准,编译器会把 lambda 表达式转化成类似 struct ByName{} 的形式。

// lambda 表达式代码
[](const Person& a, const Person& b){
  return a.name < b.name;
}

// 编译器转成
struct __lambda_1 /* 不要在意名字 */ {
  inline bool operator(const Person& a, const Person& b) const {
    a.name < b.name;
  }

  __lambda_1() = delete; // 没有默认构造函数
  __lambda_1& operator=(const __lambda_1&) = delete; // 不能赋值
};
__lambda_1(); // 对象实例编译器自动创建的,所以不会报错

这就是最基本的 lambda 表达式的样子。

2 lambda 表达式作为函数指针

有些老代码接收函数指针,但可以传 lambda 表达式进去,怎么做到的呢?

例如:

void c_style_call(int(*f)(int)) {
  std::print("return value of f(7) is {}", f(7));
}

int main() {
  // implicit convertion to function point
  c_style_call([](int i) {
    return i * i;
  });
}

根据 C++标准,编译器会添加一个类型转换函数,例如上一节结构体排序的例子, lambda 会添加一个转换函数,调用是执行隐式转换,就像下面这样:

struct __lambda_1 /* 不要在意名字 */ {
  inline bool operator(const Person& a, const Person& b) const {
    a.name < b.name;
  }

  __lambda_1() = delete; // 没有默认构造函数
  __lambda_1& operator=(const __lambda_1&) = delete; // 不能赋值

  // 转换为函数指针
  using __func_type = bool(*)(const Person& a, const Person& b);
  inline operator __func_type() const noexcept {
    return &__invoke;
  }

private:
  static inline bool __invoke(const Persion& a, const Person& b) {
    return a.name < b.name;
  }
};

__lambda_1(); // 对象实例编译器自动创建的,所以不会报错

如果要显示转换,可以用 static_cast ,或者一个肮脏的小技巧:

auto *fp_compile_error = [](int i) { return i * i; }; // compile error!

auto *fptr = static_cast<int(*)(int)>([](int i) {return i*i;}); // ok

// 肮脏的小技巧:
auto *fptr2 = +[](int i) { return i * i; } // ok

我觉得你知道为什么使用一个 + 就可以转换成函数指针了。

3 变量捕获

Lambda 表达式可以使用上下文的变量,例如:

int i = 0;
int j = 0;

auto f = [=] {
  // i 和 j 是拷贝过来的
  return i == j;
}

[=] 是默认的,通常说是传值捕获。编译器会把上面的lambda表达四代码转为:

struct __lambda_2 {
  __lambda_2(int i, int j): __i(i), __j(j) {}

  inline bool operator()() const {
    return __i == __j;
  }

private:
  int __i;
  int __j;
};

expr.prim.lambda#capture

如果是引用捕获:

int i = 0;
int j = 0;

auto f = [&] {
  // i 和 j 是引用
  return i == j;
}

lambda 表达式会转为:

struct __lambda_2 {
  __lambda_2(int& i, int& j): __i(i), __j(j) {}

  inline bool operator()() const {
    return __i == __j;
  }

private:
  int& __i;
  int& __j;
};

expr.prim.lambda#capture-12

如果是 this 捕获:

struct X {
  void printAsync() {
    callAsync([this] {
      // 可以使用 X 类里的成员
      std::print("X::i={}", i);
    });
  }

private:
  int i{42};
};

编译器把 lambda 转为:

struct X {
  void printAsync() {
    struct __lambda_3 {
      __lambda_3(X* _this): __this(_this) {}
      void operator()() const {
        std::print("X::i={}", __this->i);
      }
    private:
      X* __this;
    };
  }

  callAsync(__lambda_3(this));

private:
  int i{42};
};

4 "茴"字有几种写法之 —— lambda 捕获

下面函数会输出什么?42 还是 43?

#include <print> // C++23

int main() {
  static int i{42};

  auto f = [=]{ i++; };
  f();

  std::print("{}", i); // 43!
}

只能捕获局部变量 , 不能捕获全局变量。因为 istatic ,跟全局一样,不是局部变量,所以不会被捕获,因此 i++ 也就是将 static 变量 ++ ,而不是其副本。

同理:

#include <print> // C++23

int i{42};

int main() {
  auto f = []{ i++; };
  f();

  std::print("{}", i); // 43!
}

简单地说,如果不是 odr-use 就可以不用捕获。

int main() {
  constexpr int i = 42;
  auto f = []{ std::print("{}", i); }; // ok, i is not odr-used
  f();

  auto f2 = []{ std::print("{}", &i); }; // Error! i is odr-used
  f2();

  auto f3 = [&] { std::print("{}", &i); }; // ok, print 42
  f3();

  const int j = 42;
  auto fj = []{ std::print("{}", j); }; // ok, i implicit constexpr
  fj();

  const float fp = 42.0f;
  auto ff = [] { std::print("{}", i); }; // Error! float is not constexpr
  ff();
}

5 直接调用函数表达式 (IIFE)

lambda 表达式可以直接调用,不需赋值:

int main() {
  []{ std::print("Hello world!"); } ();
}

这他娘的有啥用?那几个花哨的括号都去掉,只留下 std::print() 不好吗?

lambda 表达式像函数一样可以有复杂的结构也有返回值,但可以把逻辑体写在本地:

5.1 简化逻辑

这里本来要编写一个没多大用的函数,但也可以直接在本地写个 lambda 表达式把逻辑直接在原地写好。

int main() {
  // ...

  std::vector<Foo> foos;

  foos.emplace_back([&]{
    if (hasDatabase) {
      return getFooFromDB();
    }
    return getFooFromElsewhere();
  }());
}

5.2 Call Once

某些逻辑整个程序过程中只需要运行一次,后续不再运行,这可以用 lambda 直接调用来做到:

struct X {
  X() {
    static auto _ = []{ std::print("call once!"); return 0; }();
  }
};

X(); // "call once!"
X(); // nothing
X(); // nothing
X(); // nothing

6 泛型 lambda

lambda 表达式参数可以用 auto

std::map<int, std::string> httpStatus {
  // ...
  {400, "Bad Request"},
  {401, "Unauthorized"},
  {404, "Not Found"}
  // ...
};

std::for_each(httpStatus.begin(), httpStatus.end(), [](auto &item) {
  std::print("{}:{}", item.first, item.second);
});

编译器会为 auto lambda 表达式类,添加一个模板。例如

// lambda
[](auto i) { std::print("{}", i); };

// 编译器会把它改成:

struct __lambda_6 {
  template<typename T>
  void operator()(T i) const {
    std::print("{}", i);
  }

  template<typename T>
  using __func_type = void(*)(T i);

  template<typename T>
  inline operator __func_type<T>() const noexcept {
    return &__invoke<T>;
  }

private:
  template<typename T>
  static void __invoke(T i) {
    std::print("{}", i);
  }
};
__lambda_6();

auto&& 右值引用同理:

// lambda
std::vector<std::string> v;
[&v](auto&& item) {
  v.push_back(std::forward<decltype(item)>(item));
};

// 编译器会为其构造下面的代码:

struct __lambda_7 {
  __lambda_7(std::vector<std::string>& _v): __v(v) {}

  template<typename T>
  void operator()(T&& item) const {
    __v.push_back(std::forward<decltype(item)>(item));
  }
  // ...
private:
  std::vector<std::string>& __v;
};
__lambda_6();

7 模板 lambda

lambda 表达式可以是个 template:

template<typename T>
constexpr auto c_cast = [](auto x) {
  return (T)x;
}

c_cast<int>(3.14159); // => 3

编译器会为这个 lambda 产生下面的代码:

template<typename T>
struct __lambda_9 {
  template<typename U>
  inline auto operator()(U x) const {
    return (T)x;
  }
};
template<typename T>
auto c_cast = __lambda_9<T>();

我们可以推测:

// C++20
decltype([]{}) f1;
decltype([]{}) f2;

f1f2 拥有不同的类型。

8 初始化捕获

expr.prim.lambda#nt:init-capture

这是 C++14 的功能,捕获列表可以是个初始化参数了。

int x = 4;
auto y = [&r = x, x = x+1]()->int {
  r += 2;
  return x+2;
 }();                               // Updates ​::​x to 6, and initializes y to 7.

auto z = [a = 42](int a) { return 1; };     // error: parameter and conceptual local variable have the same name
auto counter = [i=0]() mutable -> decltype(i) {     // OK, returns int
  return i++;
};

9 constexpr lambda

C++17 可以给 lambda 设定 constexpr 标记了。

expr.prim.lambda#closure-5

auto f = []() constexpr { return sizeof(void*) };
std::array<int, f()> arr{};

10 lambda 重载表

C++17 可以用 lambda 构造一个重载表:

#include <variant>
#include <cstdio>
#include <vector>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; // (1)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;  // (2)

using var_t = std::variant<int, const char*>;

int main() {
  std::vector<var_t> vars = {1, 2, "Hello, World!"};

  for (auto& v : vars) {
    std::visit(overloaded {  // (3)
      [](int i) { printf("%d\n", i); },
      [](const char* str) { puts(str); }
    }, v);
  }

  return 0;
}

11 C++20 lambda template

C++20 添加了 concept,lambda 也可以用

expr.prim.lambda#closure-7

auto f = []<typename T1, C1 T2> requires C2<sizeof(T1) + sizeof(T2)>
         (T1 a1, T1 b1, T2 a2, auto a3, auto a4) requires C3<decltype(a4), T2> {
  // T2 is constrained by a type-constraint.
  // T1 and T2 are constrained by a requires-clause, and
  // T2 and the type of a4 are constrained by a trailing requires-clause.
};

12 lambda 递归

lambda 递归还是有些麻烦。

auto fact = [](this auto self, int n) -> int { // OK, explicit object parameter
  return (n <= 1) ? 1 : n * self(n-1);
};
std::cout << fact(5); // OK, outputs 120

13 总结

C++ 把 lambda 表达式搞得很复杂,即时理解了它的本质,也记不住它的语法和细节。如果你不是 C++ 律师的话,建议躺平吧,遇到了再去查标准文档。


By .