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
选择了另一种方法,它们引入了一种新的捕获机制,它非常灵活,可以实现包括移动捕获在内的许多功能,称为初始化捕获。使用初始化捕获可以让你指定:
- 闭包内的数据成员名称;
- 初始化闭包内数据成员的表达式。
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 &¶m) {
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$ 是值方式存储的。