Effective Modern C++读书笔记(四)

lambda 表达式

首先来明确一下三个概念:

  • lambda 表达式只是一个表达式。它是源码的一部分。

  • closure 是由lambda创造的运行期对象。根据捕获的模式,closure 持有被捕获对象的副本或者引用。

  • closure class 是给 closure 实例化的。每个 lambda 会让编译器生成一个独一无二的 closure classlambda 内的声明将变成它 closure class 内的成员函数的可执行指令。


Item 31

  • 在C++11里有两种默认的捕获模式: by-referenceby-value 。默认的 by-reference 捕获可以导致空悬引用。比如说这个 closure (或者它的副本)比捕获的引用的对象的声明周期长。 (这也应该是处理引用本就应该小心的地方)。应该不使用默认捕获,捕获的时候添上引用的对象。这样可以让程序员注意到引用的对象,从而对它的生命周期有了考量。或者直接就通过传值来捕获。但如果传值的对象是 raw pointer ,那又得考虑是否会出现空悬指针的问题。

  • 有种情况 by-value 捕获会造成意想不到的悬垂指针。看个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    std::vector<std::function<bool(int)>> filters;

    class Widget
    {
    ...
    int divisor;
    }
    void Widget::addFilter() const
    {
    //
    filters.emplace_back(
    [=](int value){ return value % divisor} == 0; }
    );
    }

    这里看似 by-value 捕获了divisor,但是却并非如此。首先,divisor是类内 non-static 数据成员 ,是不能被捕获的。但是这段代码却可以编译过。原因就在与 this 指针。类的 non-static 函数体中隐含了一个 this 指针,它是个纯右值(prvalue)表达式,表示调用这个函数的对象的地址。所以其实捕获的是 this 指针,而lambda表达式中的 divisor 其实也是 this->divisor

    所以既然是 by-value 捕获的 this 指针,那么就有可能出现空悬指针。为了避免这种情况,还是老实地在 addFilters 函数体内用一个 local 变量作为 divisor 的副本,然后 by-value 捕获这个副本。

  • 还有就是要注意,静态存储周期的对象都是可以直接在lambda 表达式中使用的,而且不能捕获 。所以在lambda表达式中用默认 by-value 捕获,就会让人产生错觉。但实际上什么都没捕获。


Item 32

  • C++14 提供了 init capture 这种新的捕获方式。具体来说就是在捕获列表中可以通过 = 的方式,等号左边是从 lambda 表达式生成的闭包类的数据成员,右边是表达式,用于初始化左边。可以明显看出,=左边和右边的作用域也不同。所以,通过这种语法,让“移动”捕获成为了可能,而非 C++11 那样只能传值和传引用捕获。

  • 不过在C++11中也是可以模拟出“移动”捕获的。

    • lambda 表达式可以看成定义一个函数类并构造它的对象。所以没有什么是直接定义一个类不能解决的。类中自然可以通过移动的方式初始化数据成员。

    • 通过 std::bind 来模拟。可以看个例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      std::vector<double> data;
      // In C++14
      auto func = [data = std::move(data)](){ /*uses of data*/ };
      // 以下是等价的版本
      // In C++11
      auto func = std::bind(
      [](const std::vector<double>& data)
      { /*uses of data*/ },
      std::move(data)
      );

      std::bind构造的对象持有所有参数的副本。当参数是左值,就拷贝构造;当参数是右值,就移动构造。所以参数data通过移动构造传进了 bind 对象内(姑且称之为d),然后每次调用 funcoperator() 的时候,都会调用那个 lambda 表达式的 operator() 。而参数就是 bind 对象内的data对象的副本。由于 lambda 没有加 multable,闭包类内的 operator()const 限定的的。而 bind 对象的data的副本却不是const的。为了防止它被修改,所以参数加了const。但其实这样也可以:

      1
      2
      3
      4
      5
      6
      auto func =
      std::bind(
      [](std::vector<double>& data) mutable
      { /* uses of data */ },
      std::move(data)
      );

Item 33

C++14 引入了 generic lambdas 的特性。具体来说就是 lambda 表达式的参数列表的类型是 auto 。这个特性的实现也很直观:生成的闭包类的 operator() 函数是个模板。所以,随着来的 universal reference 和完美转发都可以实现了。完美转发的关键还是在对引用折叠和 std::forward 函数实现的理解。

1
2
3
4
5
6
7
8
9
auto f = [](auto&& param)
{
func(std::forward<decltype(param)>(param));
};

auto f = [](auto&&... params)
{
func(std::forward<decltype(param)>(params)...);
};

Item 34

lambda 表达式与 std::bind 相比有些优势,如下:

  • lambda 表达式比较清晰。(这里书中给的代码有误,最后再详细讲)

  • lambda 表达式内可以调用重载的函数,但是std::bind第一个参数是重载函数的话得明确调用的版本(通过static_cast)。而且由于使用的指针,还会减少它被内联优化的可能性。

  • std::bind 默认是将参数拷贝的。想要保存参数的引用,必须要用std::ref。比如:

    1
    auto compressRateB = std::bind(compress, std::ref(w), _1);       // compressRateB acts as if it holds a reference to w, rather than a copy

    这点就很不如 lambda 表达式直观。

在 C++14 中,lambda 表达式已经十分优秀,再也没有使用 std::bind 的理由了。但是在 C++11 中,std::bind 在以下情况下还是很有用的:

  • Move capture 。C++11 lambda 表达式没有提供通过移动捕获的方式,但是能通过 std::bind 模拟出来。但 C++14 开始, lambda 表达式通过 init capture 已经能够实现这个。
  • Polymorphic function objectsstd::bind绑定一个模板函数的时候,它能完美转发任何参数。但是 C++14 开始,lambda 表达式可以通过 auto 实现泛型。

ps:这章里的代码竟然还有错误的地方,琢磨半天= =。 最后在 StackOverflow 上查到了正确的写法

先还原一下问题的引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders; // needed for use of "_1"
auto setSoundL = [](Sound s) {
setAlarm(steady_clock::now()) + 1h,
s,
30s);
};

auto setSoundB = std::bind(setAlarm,
steady_clock::now() + 1h,
_1,
30s);

可以看到 setSoundLsetSoundB 看似完成相同的功能,但是实际上却并非如此。因为 在调用 setSoundL 时会调用setAlarm函数。显然,此时才会now 函数。而对于 setSoundB 来说,它在生成的时候就已经被调用了 now() 函数。然后每次调用它的时候,并不会再对 now() 调用。所以可以看到,在语义上双方已经不同了。解决方法也很简单,将后者对 now 函数的调用推迟到它自身被调用的时候。

书上给出的修改是这样的:

1
2
3
4
// 省略了一系列的 using
auto setSoundB = std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h), // 在 C++14 中,标准操作模板的模板参数声明通常可以被省略。注意, C++ 11 并不支持这么写。
30s);

但这显然也不能达成目标,now 函数还是会在 setSoundB 生成的时候就调用。不难想到,std::bind第一个参数,即可调用对象,是不会提前被调用的。所以应该这么改:

1
2
3
4
// 省略了一系列的 using
auto setSoundB = std::bind(setAlarm,
std::bind(std::plus<>(), std::bind(steady_clock::now()), 1h),
30s);