回到顶部 暗色模式

EffectiveModernCpp(5):Lambda

        Lambda可以做的事情都可以通过其他方式完成,它的作用是简化我们的编写。对于标准库中的许多 $if$ 算法 ( $std::find_-if$ 、$std::remove_-if$ 等 ) ,它们通常需要繁琐的谓词。但是通过lambda表达式,这些算法的使用就变得非常方便。
        闭包 ( $closure$ ) 是lambda创建的运行时对象,根据捕获模式,闭包会持有捕获数据的副本或者引用。闭包类 ( $closure$ $lcass$ ) 是从闭包中实例化出的类。每个lambda都会生成唯一的闭包类。lambda中的语句是闭包类成员函数中的指令。

1. 捕获模式

        C++11有两种默认的捕获模式:引用捕获和值捕获。引用捕获会带来悬垂引用问题,值捕获可能会让你觉得能解决悬垂引用问题 ( 但是并不能 ),还会让你觉得你的闭包是独立的 ( 实际上也不是 )。
        引用捕获会导致闭包中包含了对局部变量或者某个形参的引用,如果该lambda的生命周期超过了局部变量的生命周期,那么闭包中将出现悬垂引用。

using FilterContainer =
  std::vector<std::function<bool (int)>>;
FilterContainer filters;

void addDivisorFilter() {
  auto calc1 = computeSomeValue1();
  auto calc2 = computeSomeValue2();
  auto divisor = computeDivisor(calc1, calc2);
  filters.emplace_back(
    [&](int value) { return value % divisor == 0; }
  );
}

        这个代码是一个定时炸弹。$divisor$ 的生命周期在 $addDivisorFilter$ 返回时就结束了,这会导致 $filters$ 出现未定义行为。
        一个解决问题的办法是值捕获。

filters.emplace_back(
  [=](int value) { return value % divisor == 0; }
);

        这可以解决上面例子的问题。但是要注意,通常情况下,值捕获并不能完全解决悬垂引用的问题。假设你捕获的是指针,随着函数结束,指针指向对象被删除,这还是会导致指针悬垂。

class Widget {
public:
  // ...
  void addFilter() const;

private:
  int divisor;
};

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

        这个代码看起来好像正确,lambda成功捕获了 $divisor$ 。我们换种写法再来看看:

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

        出错了?可能因为我们没有捕获吧,再来一次:

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

        还是错的?明明已经显式捕获了 $divisor$ 了,为什么还是错了?因为 $divisor$ 并不是一个局部变量,而是一个成员变量。闭包只会捕获在它创建时的作用域的非静态局部变量,而在这个作用域里,$divisor$ 并不是一个局部变量。那为什么第一个版本的代码可以运行呢?因为这里隐式地捕获了 $this$ 指针,并且编译器将lambda内部的 $divisor$ 替换成了 $this$->$divisor$ 。也就是说,在编译器看来,实际上是这样的:

void Widget::addFilter() const {
  auto currentObjectPtr = this;
  filters.emplace_back(
    [currentObjectPtr](int value) {
      return value % currentObjectPtr->divisor == 0;
  });
}

        但是这种隐式的捕获 $this$ 也存在问题,如果 $Widget$ 先于 $filters$ 被删除了怎么办。一种办法是显式拷贝一份数据:

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

        在C++14中,我们可以使用更好的捕获方式。

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

        使用默认的值捕获还会带来一个问题,它让你觉得闭包是独立的。但是这是错误的,lambda并不会独立于局部变量和参数。一个声明为 $static$ 的类内或全局变量也能在lambda中使用,但是无法被捕获。值捕获可能会诱导你误以为捕获了这些变量。

void addDivisorFilter() {
  static auto calc1 = computeSomeValue1();
  static auto calc2 = computeSomeValue2();
  static auto divisor = computeDivisor(calc1, calc2);
  filters.emplace_back(
    [=](int value) { return value % divisor == 0; }
  );
  ++divisor;
}

        上面的例子就表现了闭包不独立的一面。虽然它使用了值捕获模式,但是它并没有捕获任何值,并且我们在最后修改了 $divisor$ ,也是会影响到lambda中的 $divisor$ 。简单地来讲,对于这些变量,我们不是值捕获,而是引用捕获。
        值捕获会给人带来种种错觉,这也是为什么我们不建议使用值捕获。

2. 初始化捕获

        在某些场景下,值捕获和引用捕获都不能满足我们的需求。假设现在有一个只能被移动的对象 ( $std::unique_-ptr$ 或 $std::future$ ),你想要在闭包内使用它。这个问题是C++11无法实现的,但是如果是C++14,它支持闭包的移动捕获,从而可以解决这个问题。
        C++11的一个缺点就是缺少移动捕获。一种解决措施是直接添加该特性,但是C++14选择了另一种方法,它们引入了一种新的捕获机制,它非常灵活,可以实现包括移动捕获在内的许多功能,称为初始化捕获。使用初始化捕获可以让你指定:

  1. 闭包内的数据成员名称;
  2. 初始化闭包内数据成员的表达式。
class Widget {
public:
  // ...
  bool isValidated() const;
  bool isProcessed() const;
  bool isArchived() const;
};

auto pw = std::make_unique<Widget>();
auto func =
  [pw = std::move(pw)] {
    return pw->isValidated() && pw->isArchived();
  };

        上面的例子展示了初始化捕获的使用,$=$ 左侧是闭包类中数据成员的名称,右侧是初始化表达式。左右两侧的作用域不同,前者作用于闭包类内,后者作用于闭包上方的声明对象。
        如果你一定要在C++11中使用,那么可以这样实现:

auto func = std::bind(
  [](std::unique_ptr<Widget> &pw) {
    return pw->isValidated() && pw->isArchived();
  },
  std::move(pw)
);

        $std::bind$ 产生函数对象,对象中含有传递给 $std::bind$ 的所有参数的副本,如果是以左值方式传递的,那么就是拷贝构造的,如果是以右值方式传递的,就是移动构造的。当调用生成的函数对象时,对象内的成员将传递给调用对象。以这种形式构造的绑定对象的生命周期与闭包对象相同,因此可以将绑定对象中的成员视为闭包对象成员。
        默认情况下,从lambda生成的闭包类中的 $operator(\ )$ 函数为 $const$ ,但是 $std::bind$ 生成的不一定是 $const$ 。如果将lambda声明为 $mutable$ ,那么 $operator(\ )$ 就是非 $const$ 的。

auto func = std::bind(
  [](std::unique_ptr<Widget> &pw) mutable {
    return pw->isValidated() && pw->isArchived();
  },
  std::move(pw)
);

        另一种模拟实现方式是不使用lambda

class IsValAndArch {
public:
  using DataType = std::unique_ptr<Widget>;
  explicit IsValAndArch(DataType &&ptr)
    : pw(std::move(ptr)) {}
  bool operator()() const {
    return pw->isValidated() && pw->isArchived();
  }

private:
  DataType pw;
};

auto func = IsValAndArch(std::make_unique<Widget>());

        当然,这种方式比lambda表达式复杂的多。

3. auto参数

        C++14中的lambda表达式参数可以使用 $auto$ 关键字。假设存在这么个lambda

auto f = [](auto x) { return func(normalize(x)); }

        对应的闭包类就会类似这样:

class SomeCompilerGeneratedClassName {
  template<typename T>
  auto operator()(T x) const {
    return func(normalize(x));
  }
};

        在这个例子中,虽然函数既可以接收左值也可以接收右值,但是转发出去的永远是个左值。解决这个问题的方法是使用完美转发。但是,新的问题又来了,$std::forward$ 的类型是什么?
        一般来讲,使用完美转发需要知道转发对象的类型。在模版函数中,我们通过 $T$ 表示,但是lambda表达式中没有 $T$ 。我们可以通过 $decltype$ 解决这个问题:

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

        同样,对于多参数情况,可以这样解决:

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

4. 使用lambda代替bind

        C++11的 $std::bind$ 是C++98的 $std::bind1st$ 和 $std::bind2nd$ 的后续实现,但是它在 $2005$ 年时就进入了TR1文档,成为了标准库的一部分。这意味着人们使用 $bind$ 的经验会比lambda更多。在C++11中,lambda并不能完全代替 $bind$ ,但是在C++14中,这种情况改变了,lambda具有了更强大功能。
        优先使用lambda而不是 $std::bind$ 的一个重要原因是lambda更易于理解:

using Time = std::chrono::steady_clock::time_point;
enum class Sound { Beep, Siren, Whistle };
using Duration = std::chrono::steady_clock::duration;

auto setSoundL = [](Sound s) {
  using namespace std::chrono;
  using namespace std::literals;  // C++14 suffixes
  setAlarm(steady_clock::now() + 1h,  // alaram to go off
           s,  // in an hour for
           30s);  // 30 seconds
};

        上面的代码很容易就让人看懂了。而如果我们使用 $bind$ :

using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;
auto setSoundB =
  std::bind(setAlaram,
            std::bind(std::plus<>(), steady_clock::now(), 1h),
            _1,
            30s);

        代码没有那么直观,因为需要读者将占位符 $_-1$ 映射到生成函数中的位置;其次 $bind$ 并没有标识该占位符的类型,读者还必须查阅 $setAlaram$ 的声明。你还可以发现这里我们使用了两个 $bind$ ,第二个 $bind$ 负责在外层 $bind$ 生成的函数对象被调用时动态生成时间。如果不使用第二个 $bind$ ,时间会在第一个 $bind$ 被调用时就确定下来,不符合我们的要求。
        如果是C++11,那么情况会更糟糕,因为模版参数不能省略:

using namespace std::chrono;
using namespace std::placeholders;

auto setSoundB =
  std::bind(
    setAlaram,
    std::bind(std::plus<steady_clock::time_point>(),
              steady_clock::now(),
              hours(1)),
    _1,
    seconds(30);
  );

        还不够糟糕吗?让我们再重载一下 $setAlaram$ :

enum class Volume { Normal, Loud, LoudPlusPlus };
void setAlaram(Time t, Sound s, Duration d, Volume v);

        现在有两个版本的 $setAlaram$ 了。幸运的是,lambda版本依然可以使用。但是 $bind$ 版本会失败,因为编译器无法确定它将要使用哪个版本。为了解决这个问题,我们必须进行类型转换:

using SetAlaram3ParamType = void (*)(Time t, Sound s, Duration d);
auto setSoundB =
  std::bind(
    static_cast<SetAlaram3ParamType>(setAlaram),
    std::bind(std::plus<>(), steady_clock::now(), 1h),
    _1,
    30s);

        在这里 $setAlaram$ 是通过函数指针传递的,编译器不太可能会对函数指针进行内联。这意味着,与lambda版本相比,$bind$ 版本还可能存在效率问题。
        对于没有使用过 $bind$ 的人来说,他对其实现一无所知。

enum class CompLevel { Low, Normal, High };
Widget compress(const Widget &w, CompLevel lev);

        如果我们想创建一个函数,压缩特定的 $Widget$ 对象,那么使用 $bind$ 的版本看起来会是这样:

Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);

        问题来了,我们将 $w$ 传递给了 $bind$ ,那么它在 $bind$ 函数对象中是怎么存储的呢?答案是值方式存储。这与lambda不同,$bind$ 永远只能以值方式存储。相比起来,lambda就明显得多了:

auto compressRateL = [w](CompLevel lev) {
  return compress(w, lev);
};

        很明显这里的 $w$ 是值方式存储的。

EffectiveModernCpp(5):Lambda