Chapter6 lambda表达式扩展

C++11引入的lambda和C++14引入的泛型lambda是一个很大的成功。 它允许我们将函数作为参数传递,这让我们能更轻易的指明一种行为。

C++17扩展了lambda表达式的应用场景:

  • 在常量表达式中使用(也就是在编译期间使用)
  • 在需要当前对象的拷贝时使用(例如,当在不同的线程中调用lambda时)

6.1 constexpr lambda

自从C++17起,lambda表达式会尽可能的隐式声明constexpr。 也就是说,任何只使用有效的编译期上下文 (例如,只有字面量,没有静态变量,没有虚函数,没有try/catch, 没有new/delete的上下文)的lambda都可以被用于编译期。

例如,你可以使用一个lambda表达式计算参数的平方,并将计算结果用作std::array<>的大小, 即使这是一个编译期的参数:

  1. auto squared = [](auto val) { // 自从C++17起隐式constexpr
  2. return val*val;
  3. };
  4. std::array<int, squared(5)> a; // 自从C++17起OK => std::array<int, 25>

使用编译期上下文中不允许的特性将会使lambda失去成为constexpr的能力, 不过你仍然可以在运行时上下文中使用lambda:

  1. auto squared2 = [](auto val) { // 自从C++17起隐式constexpr
  2. static int calls = 0; // OK,但会使该lambda不能成为constexpr
  3. ...
  4. return val*val;
  5. };
  6. std::array<int, squared2(5)> a; // ERROR:在编译期上下文中使用了静态变量
  7. std::cout << squared2(5) << '\n'; // OK

为了确定一个lambda是否能用于编译期,你可以将它声明为constexpr

  1. auto squared3 = [](auto val) constexpr { // 自从C++17起OK
  2. return val*val;
  3. };

如果指明返回类型的话,语法看起来像下面这样:

  1. auto squared3i = [](int val) constexpr -> int { // 自从C++17起OK
  2. return val*val;
  3. };

关于constexpr函数的规则也适用于lambda:如果一个lambda在运行时上下文中使用, 那么相应的函数体也会在运行时才会执行。

然而,如果在声明了constexpr的lambda内使用了编译期上下文中不允许的特性 将会导致编译错误:

  1. auto squared4 = [](auto val) constexpr {
  2. static int calls = 0; // ERROR:在编译期上下文中使用了静态变量
  3. ...
  4. return val*val;
  5. };

一个隐式或显式的constexpr lambda的函数调用符也是constexpr。 也就是说,如下定义:

  1. auto squared = [](auto val) { // 从C++17起隐式constexpr
  2. return val*val;
  3. };

将会被转换为如下 闭包类型(closure type)

  1. class CompilerSpecificName {
  2. public:
  3. ...
  4. template<typename T>
  5. constexpr auto operator() (T val) const {
  6. return val*val;
  7. }
  8. };

注意,这里生成的闭包类型的函数调用运算符自动声明为constexpr。 自从C++17起,如果lambda被显式或隐式地定义为constexpr, 那么生成的函数调用运算符将自动是constexpr

注意如下定义:

  1. auto squared1 = [](auto val) constexpr { // 编译期lambda调用
  2. return val*val;
  3. };

和如下定义:

  1. constexpr auto squared2 = [](auto val) { // 编译期初始化squared2
  2. return val*val;
  3. };

是不同的。

第一个例子中如果(只有)lambda是constexpr那么它可以被用于编译期, 但是squared1可能直到运行期才会被初始化, 这意味着如果静态初始化顺序很重要那么可能导致问题 (例如,可能会导致 static initialization order fiasco )。 如果用lambda初始化的闭包对象是constexpr,那么该对象将在程序开始时就初始化, 但lambda可能还是只能在运行时使用。因此,可以考虑使用如下定义:

  1. constexpr auto squared = [](auto val) constexpr {
  2. return val*val;
  3. };

6.1.1 使用constexpr lambda

这里有一个使用constexpr lambda的例子。 假设我们有一个字符序列的哈希函数,这个函数迭代字符串的每一个字符反复更新哈希值:

  1. auto hashed = [](const char* str) {
  2. std::size_t hash = 5381; // 初始化哈希值
  3. while (*str != '\0') {
  4. hash = hash * 33 ^ *str++; // 根据下一个字符更新哈希值
  5. }
  6. return hash;
  7. };

使用这个lambda,我们可以在编译期初始化不同字符串的哈希值,并定义为枚举:

  1. enum Hashed { beer = hashed("beer"),
  2. wine = hashed("wine"),
  3. water = hashed("water"), ... }; // OK,编译期哈希

我们也可以在编译期计算case标签:

  1. switch (hashed(argv[1])) { // 运行时哈希
  2. case hashed("beer"): // OK,编译期哈希
  3. ...
  4. break;
  5. case hashed("wine"):
  6. ...
  7. break;
  8. ...
  9. }

注意,这里我们将在编译期调用case标签里的hashed, 而在运行期间调用switch表达式里的hashed

如果我们使用编译期lambda初始化一个容器,那么编译器优化时很可能在编译期就计算出容器的初始值 (这里使用了std::array的类模板参数推导):

  1. std::array arr{ hashed("beer"),
  2. hashed("wine"),
  3. hashed("water") };

你甚至可以在hashed函数里联合使用另一个constexpr lambda。 设想我们把hashed里根据当前哈希值和下一个字符值更新哈希值的逻辑定义为一个参数:

  1. auto hashed = [](const char* str, auto combine) {
  2. std::size_t hash = 5381; // 初始化哈希值
  3. while (*str != '\0') {
  4. hash = combine(hash, *str++); // 用下一个字符更新哈希值
  5. }
  6. return hash;
  7. };

这个lambda可以像下面这样使用:

  1. constexpr std::size_t hv1{hashed("wine", [](auto h, char c) {return h*33 + c;})};
  2. constexpr std::size_t hv2{hashed("wine", [](auto h, char c) {return h*33 ^ c;})};

这里,我们在编译期通过改变更新逻辑初始化了两个不同的"wine"的哈希值。 两个hashed都是在编译期调用。

6.2 向lambda传递this的拷贝

当在非静态成员函数里使用lambda时,你不能隐式获取对该对象成员的使用权。 也就是说,如果你不捕获this的话你将不能在lambda里使用该对象的任何成员 (即使你用this->来访问也不行):

  1. class C {
  2. private:
  3. std::string name;
  4. public:
  5. ...
  6. void foo() {
  7. auto l1 = [] { std::cout << name << '\n'; }; // ERROR
  8. auto l2 = [] { std::cout << this->name << '\n'; }; // ERROR
  9. ...
  10. }
  11. };

在C++11和C++14里,你可以通过值或引用捕获this

  1. class C {
  2. private:
  3. std::string name;
  4. public:
  5. ...
  6. void foo() {
  7. auto l1 = [this] { std::cout << name << '\n'; }; // OK
  8. auto l2 = [=] { std::cout << name << '\n'; }; // OK
  9. auto l3 = [&] { std::cout << name << '\n'; }; // OK
  10. ...
  11. }
  12. };

然而,问题是即使是用拷贝的方式捕获this实质上获得的也是引用 (因为只会拷贝this 指针 )。当lambda的生命周期比该对象的生命周期更长的时候, 调用这样的函数就可能导致问题。比如一个极端的例子是在lambda中开启一个新的线程来完成某些任务, 调用新线程时正确的做法是传递整个对象的拷贝来避免并发和生存周期的问题,而不是传递该对象的引用。 另外有时候你可能只是简单的想向lambda传递当前对象的拷贝。

自从C++14起有了一个解决方案,但可读性和实际效果都比较差:

  1. class C {
  2. private:
  3. std::string name;
  4. public:
  5. ...
  6. void foo() {
  7. auto l1 = [thisCopy=*this] { std::cout << thisCopy.name << '\n'; };
  8. ...
  9. }
  10. };

例如,当使用了=或者&捕获了其他对象的时候你可能会在不经意间使用this

  1. auto l1 = [&, thisCopy=*this] {
  2. thisCopy.name = "new name";
  3. std::cout << name << '\n'; // OOPS:仍然使用了原来的name
  4. };

自从C++17起,你可以通过*this显式地捕获当前对象的拷贝:

  1. class C {
  2. private:
  3. std::string name;
  4. public:
  5. ...
  6. void foo() {
  7. auto l1 = [*this] { std::cout << name << '\n'; };
  8. ...
  9. }
  10. };

这里,捕获*this意味着该lambda生成的闭包将存储当前对象的一份 拷贝

你仍然可以在捕获*this的同时捕获其他对象,只要没有多个this的矛盾:

  1. auto l2 = [&, *this] { ... }; // OK
  2. auto l3 = [this, *this] { ... }; // ERROR

这里有一个完整的例子:

  1. #include <iostream>
  2. #include <string>
  3. #include <thread>
  4. class Data {
  5. private:
  6. std::string name;
  7. public:
  8. Data(const std::string& s) : name(s) {
  9. }
  10. auto startThreadWithCopyOfThis() const {
  11. // 开启并返回新线程,新线程将在3秒后使用this:
  12. using namespace std::literals;
  13. std::thread t([*this] {
  14. std::this_thread::sleep_for(3s);
  15. std::cout << name << '\n';
  16. });
  17. return t;
  18. }
  19. };
  20. int main()
  21. {
  22. std::thread t;
  23. {
  24. Data d{"c1"};
  25. t = d.startThreadWithCopyOfThis();
  26. } // d不再有效
  27. t.join();
  28. }

lambda里捕获了*this,所以传递进lambda的是一份拷贝。 因此,即使在d被销毁之后使用捕获的对象也没有问题。

如果我们使用[this][=]或者[&]捕获this, 那么新线程将会陷入未定义行为,因为当线程中打印name的时候将会使用一个已经销毁的 对象的成员。

6.3 以常量引用捕获

通过使用一个新的库工具,现在也可以以常量引用捕获this