《EffectiveModernC-》第六章-Lambda表达式

第六章 lambda表达式

Item31:避免使用默认捕获模式

lambda一大作用就是简化了谓词繁琐的创建

1
std::find_if(container.begin(), container.end(),[](int val){ return 0 < val && val < 10; });

​ 标题提到: 避免使用默认捕获模式,lambda中有两种默认捕获模式,按引用捕获和按值捕获。

​ 其中按引用捕获很容易造成悬空引用的问题,例如:

1
2
3
4
5
6
7
8
9
10
11
12
using FilterContainer = vector<function<bool(int)>>;
FilterContainer filters;

void AddFilterToContainer()
{
auto Divisor = GetDivsor();
filters.emplace_back(
[&](int value){
return value % Divisor == 0;
}
);
}

​ Divisor会在函数AddFilterToContainer()结束后生命周期结束,如果后面再使用到filters里的这个闭包,此时默认捕获的Divisor会造成悬空引用。

​ 从长期来看,显式列出lambda依赖的局部变量和形参,是更加符合软件工程规范的做法。

额外提一下,C++14支持了在lambda中使用auto来声明变量

1
2
3
if (std::all_of(begin(container), end(container),
[&](const auto& value) // C++14
{ return value % divisor == 0; }))

假设在一个Widget类,可以实现向过滤器的容器添加条目:

1
2
3
4
5
6
7
class Widget {
public:
//构造函数等
void addFilter() const; //向filters添加条目
private:
int divisor; //在Widget的过滤器使用
};

这是Widget::addFilter的定义:

下面代码做法看起来是安全的代码。lambda依赖于divisor,但默认的按值捕获确保divisor被拷贝进了lambda对应的所有闭包中,但却有很大问题。

1
2
3
4
5
6
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}

实际上上面的用法是错的,因为 捕获只能应用于lambda被创建时所在作用域里的non-static局部变量(包括形参)。在Widget::addFilter的视线里,divisor并不是一个局部变量,而是Widget类的一个成员变量。它不能被捕获。

而如果默认捕获模式被删除,代码就不能编译了:

1
2
3
4
5
6
void Widget::addFilter() const
{
filters.emplace_back( //错误!
[](int value) { return value % divisor == 0; } //divisor不可用
);
}

另外,如果尝试去显式地捕获divisor变量(或者按引用或者按值——这不重要),也一样会编译失败,因为divisor不是一个局部变量或者形参。

1
2
3
4
5
6
7
void Widget::addFilter() const
{
filters.emplace_back(
[divisor](int value) //错误!没有名为divisor局部变量可捕获
{ return value % divisor == 0; }
);
}

所以如果默认按值捕获不能捕获divisor,而不用默认按值捕获代码就不能编译,这是为什么?

在默认按值捕获的Widget::addFilter版本中,

1
2
3
4
5
6
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}

真正被捕获的是Widgetthis指针,而不是divisor编译器会将上面的代码看成以下的写法:

1
2
3
4
5
6
7
8
9
void Widget::addFilter() const
{
auto currentObjectPtr = this;

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

也就是说使用=这种值捕获this,其实还是拿到了对象的指针来做操作,而往往你会认为你这是在对值捕获的对象做操作,这种误用可能会让你不小心修改了对象的成员而自己却不知道。

我们不想用引用的divisor,如何解决上面那个问题呢?其实拷贝出来一份副本就好了:

1
2
3
4
5
6
7
8
9
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[divisorCopy](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}

在C++14的广义捕获(generalized lambda capture)可以更简单的来写成:

1
2
3
4
5
6
7
8
9
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[=](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}

关于更多的广义捕获可以看 https://chillstepp.github.io/2022/05/07/Lambda%E5%87%BD%E6%95%B0-%E7%94%A8%E6%B3%95-%E5%AE%9E%E7%8E%B0-%E5%B9%BF%E4%B9%89%E6%8D%95%E8%8E%B7/

我们再次强调 :lambda可能会依赖静态存储生命周期(static storage duration)的对象。这些对象定义在全局空间或者命名空间,或者在类、函数、文件中声明为static这些对象也能在lambda里使用,但它们不能被捕获。

一些初学者会认为能使用static或者全局变量是因为[=]值捕获到了,但其实你还是引用的这些全局变量,即使你不写=,什么都不捕获依然可以使用这些静态存储生命周期的对象,如下面这份代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void addDivisorFilter()
{
static auto calc1 = computeSomeValue1(); //现在是static
static auto calc2 = computeSomeValue2(); //现在是static
static auto divisor = //现在是static
computeDivisor(calc1, calc2);

filters.emplace_back(
[=](int value) //什么也没捕获到!
{ return value % divisor == 0; } //引用上面的static
);

++divisor; //调整divisor
}

​ 这个lambda没有使用任何的non-static局部变量,所以它没有捕获任何东西。然而lambda的代码引用了static变量divisor,在每次调用addDivisorFilter的结尾,divisor都会递增,通过这个函数添加到filters的所有lambda都展示新的行为(分别对应新的divisor值)。这个lambda是通过引用捕获divisor,这和默认的按值捕获表示的含义有着直接的矛盾。如果你一开始就避免使用默认的按值捕获模式,你就能解除代码的风险。

说了这么多规则,可以发现,你写默认捕获的代价是很大的,你需要仔细检查你到底犯没犯这些错误,那不如直接少用按值捕获模式或是按引用捕获模式。

请记住:

  • 默认的按引用捕获可能会导致悬空引用。
  • 默认的按值捕获对于悬空指针很敏感(尤其是this指针),并且它会误导人产生lambda是独立的想法。

Item32:使用初始化捕获来移动对象到闭包中(C++14后)

这一节要讲的是 初始化捕获/广义捕获(generalized lambda capture)

考虑这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {                          //一些有用的类型
public:

bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private:

};

auto pw = std::make_unique<Widget>(); //创建Widget;使用std::make_unique
//的有关信息参见条款21

//设置*pw

auto func = [pw = std::move(pw)] //使用std::move(pw)初始化闭包数据成员
{ return pw->isValidated()
&& pw->isArchived(); };

里面使用了pw = std::move(pw)初始化闭包数据成员,这个就称为Generalized lambda capture

对于初学者来说,可能会认为 等号左右边的pw是一个东西,但其实并不,“=”左侧的名称pw表示闭包类中的数据成员,而右侧的名称pw表示在lambda上方声明的对象,即由调用std::make_unique去初始化的变量。因此,“pw = std::move(pw)”的意思是“在闭包中创建一个数据成员pw,并使用将std::move应用于局部变量pw的结果来初始化该数据成员”。

再简单点来说 , 更直观来看, 这么写一样:

1
2
3
auto func = [pw_moved = std::move(pw)]        //使用std::move(pw)初始化闭包数据成员
{ return pw_moved->isValidated()
&& pw_moved->isArchived(); };

题目中也提到了广义捕获/初始化捕获在C++14后才推出,如果低于这个版本怎么使用呢?其实有很多种思路

  • 一种是扔到花括号里再用move转成右值即可

  • 另一种是: lambda本质就是个仿函数(C++insight这个网站可以试一下),因此自己写一下即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class IsValAndArch {                            //“is validated and archived”
public:
using DataType = std::unique_ptr<Widget>;

explicit IsValAndArch(DataType&& ptr) //条款25解释了std::move的使用
: pw(std::move(ptr)) {}

bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }

private:
DataType pw;
};

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

除了上面两种,我们还可以使用std::bind:

1
2
3
4
5
6
7
8
9
10
std::vector<double> data;               //同上

//同上

auto func =
std::bind( //C++11模拟初始化捕获
[](const std::vector<double>& data) //译者注:本行高亮
{ /*使用data*/ },
std::move(data) //译者注:本行高亮
);

​ 如lambda表达式一样,std::bind产生函数对象。我将由std::bind返回的函数对象称为bind对象bind objects)。std::bind的第一个实参是可调用对象,后续实参表示要传递给该对象的值。

​ 一个bind对象包含了传递给std::bind的所有实参的副本。对于每个左值实参,bind对象中的对应对象都是复制构造的。对于每个右值,它都是移动构造的。 在上面的代码中,我们用了std::move(data),因此将data移动构造到Bind对象中去,这其实就是移动捕获,做到了和generalized lambda capture一样的功能。意味着当调用func(bind对象)时,func中所移动构造的data副本将作为实参传递给std::bind中的lambda

​ 另外要提示的一点是:默认情况下,从lambda生成的闭包类中的operator()成员函数为const。这具有在lambda主体内把闭包中的所有数据成员渲染为const的效果。但是,bind对象内部的移动构造的data副本不是const,因此,为了防止在lambda内修改该data副本,lambda的形参应声明为reference-to-const。 这就是我们班在上面的代码中const std::vector<double>& data是const的原因。

请记住:

  • 使用C++14的初始化捕获将对象移动到闭包中。
  • 在C++11中,通过手写类或std::bind的方式来模拟初始化捕获。

Item33:对auto&&形参使用decltype以std::forward它们

从泛型lambda说起:

泛型lambda(generic lambdas)是C++14中最值得期待的特性之一——因为在lambda的形参中可以使用auto关键字。这个特性的实现是非常直截了当的:即在闭包类中的operator()函数是一个函数模版。例如存在这么一个lambda

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

对应的闭包类中的函数调用操作符看来就变成这样:

1
2
3
4
5
6
7
class SomeCompilerGeneratedClassName {
public:
template<typename T> //auto返回类型见条款3
auto operator()(T x) const
{ return func(normalize(x)); }
//其他闭包类功能
};

​ 那么这里有个问题就是: 如果函数normalize对待左值右值的方式不一样,这个lambda的实现方式就不大合适了,因为即使传递到lambda的实参是一个右值,lambda传递进normalize的总是一个左值(形参x)。

​ 可以看出泛型lambda固然方便,但是要记得完美转发哦。

改成这样:

1
2
3
auto f = [](auto&& x){ 
return func(normalize(std::forward<???>(x)));
};

这里还是有个疑问点,???填写什么类型呢?

​ 一般来说,当你在使用完美转发时,从上面的仿函数类来看,你是在一个接受类型参数为T的模版函数里,所以你可以写std::forward<T>。但在泛型lambda中,没有可用的类型参数T,auto替代了T。

Item28解释过如果一个左值实参被传给通用引用的形参,那么形参类型会变成左值引用。传递的是右值,形参就会变成右值引用。这意味着在这个lambda中,可以通过检查形参x的类型来确定传递进来的实参是一个左值还是右值,decltype就可以实现这样的效果(看item3)。

​ 也就是说传递给lambda的是一个左值,decltype(x)就能产生一个左值引用;如果传递的是一个右值,decltype(x)就会产生右值引用。

​ 上次我们提到了forward的实现原理如下:

1
2
3
4
5
template<typename T>                        //在std命名空间
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}

当你要转发的是一个右值,产生以下的函数:

1
2
3
4
5
6
7
8
9
10
11
Widget&& && forward(Widget& param)          //当T是Widget&&时的std::forward实例
{ //(引用折叠之前)
return static_cast<Widget&& &&>(param);
}

//应用了引用折叠之后(右值引用的右值引用变成单个右值引用),代码会变成:

Widget&& forward(Widget& param) //当T是Widget&&时的std::forward实例
{ //(引用折叠之后)
return static_cast<Widget&&>(param);
}

左值同理:

1
2
3
4
5
6
7
8
9
10
11
Widget& && forward(Widget& param)          //当T是Widget&时的std::forward实例
{ //(引用折叠之前)
return static_cast<Widget& &&>(param);
}

//应用了引用折叠之后(右值引用的右值引用变成单个右值引用),代码会变成:

Widget& forward(Widget& param) //当T是Widget&时的std::forward实例
{ //(引用折叠之后)
return static_cast<Widget&>(param);
}

对比这个实例和用Widget设置T去实例化产生的结果,它们完全相同。话句话说:用右值引用类型和用非引用类型去初始化std::forward产生的相同的结果。这是个很重要的等价定理。

现在我们这么写即可:

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

如果你害怕上面的代码可读性不高,给同事解释起来比较麻烦,不妨这样做:

其实一般来说,我们常会在forward<T>中的T希填写为非引用类型,因此除了上面的做法我们也可以直接remove_refernce拿到非引用类型即可:

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

请记住:

  • auto&&形参使用decltypestd::forward它们。

Item34:考虑lambda而非std::bind

​ 在C++11中,lambda几乎总是比std::bind更好的选择。 从C++14开始,lambda的作用不仅强大,而且是完全值得使用的。但是我们仍要学习std::bind, 原因很简单:别人可能会使用,而你有可能要阅读/维护别人代码,所以需要了解。下面我们说明为何lambda优于std::bind

​ 考虑我们给一个闹钟添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//一个时间点的类型定义(语法见条款9)
using Time = std::chrono::steady_clock::time_point;
//“enum class”见条款10
enum class Sound { Beep, Siren, Whistle };
//时间段的类型定义
using Duration = std::chrono::steady_clock::duration;
//在时间t,使用s声音响铃时长d
void setAlarm(Time t, Sound s, Duration d);

//setSoundL(“L”指代“lambda”)是个函数对象,允许指定一小时后响30秒的警报器的声音
auto setSoundL =
[](Sound s)
{
//使std::chrono部件在不指定限定的情况下可用
using namespace std::chrono;

setAlarm(steady_clock::now() + hours(1), //一小时后响30秒的闹钟
s, //译注:setAlarm三行高亮
seconds(30)); //30s也可以,C++后支持了用户自定义字面量
};

由于C++11后支持了用户自定义字面量,我们通过使用标准后缀如秒(s),毫秒(ms)和小时(h)等简化在C++14中的代码,其中标准后缀基于C++11对用户自定义常量的支持。

因此hours(1)seconds(30)也可以写成1h,30s:

1
2
3
4
5
6
7
8
9
10
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals; //对于C++14后缀

setAlarm(steady_clock::now() + 1h, //C++14写法,但是含义同上
s,
30s);
};

考虑写一个bind的版本:

1
2
3
4
5
6
7
8
9
using namespace std::chrono;                //同上
using namespace std::literals;
using namespace std::placeholders; //“_1”使用需要

auto setSoundB = //“B”代表“bind”
std::bind(setAlarm,
steady_clock::now() + 1h, //不正确!见下
_1,
30s);

​ 代码并不完全正确。在lambda中,表达式steady_clock::now() + 1h显然是setAlarm的实参。调用setAlarm时将对其进行计算。可以理解:我们希望在调用setAlarm后一小时响铃。但是,在std::bind调用中,将steady_clock::now() + 1h作为实参传递给了std::bind,而不是setAlarm。这意味着将在调用std::bind时对表达式进行求值,并且该表达式产生的时间将存储在产生的bind对象中。结果,警报器将被设置为在调用std::bind后一小时发出声音,而不是在调用setAlarm一小时后发出。

​ 所以我们希望std::bind推迟对表达式的求值,直到调用setAlarm为止,而这样做的方法是将对std::bind的第二个调用嵌套在第一个调用中:

1
2
3
4
5
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(), std::bind(steady_clock::now), 1h),//c++14
_1,
30s);

就是把这个计算式再用bind嵌套一下,我们同时发现了:

尖括号之间未指定任何类型,即该代码包含“std::plus<>”,而不是“std::plus<type>”。 在C++14中,通常可以省略标准运算符模板的模板类型实参,因此无需在此处提供。

到现在我们应该很容易看出来bind很是繁琐且易错,lambda是更好的选择,因此bind在表达式推迟计算上很容易造成错误,不如lambda好用。

现在考虑第二段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum class Volume { Normal, Loud, LoudPlusPlus };
void setAlarm(Time t, Sound s, Duration d);
void setAlarm(Time t, Sound s, Duration d, Volume v);//新加的重载函数

auto setSoundL = //和之前一样
[](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h, //可以,调用三实参版本的setAlarm
s,
30s);
};

auto setSoundB = //错误!哪个setAlarm?
std::bind(setAlarm,
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);

​ bind版本出错的原因是编译器无法确定应将两个setAlarm函数中的哪一个传递给std::bind。 它们仅有的是一个函数名称,而这个单一个函数名称是有歧义的。

要使对std::bind的调用能编译,必须将setAlarm强制转换为适当的函数指针类型:

1
2
3
4
5
6
7
8
9
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);

auto setSoundB = //现在可以了
std::bind(static_cast<SetAlarm3ParamType>(setAlarm),
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);

在这个case上造成bind和lambda的区别的原因是:

  • setSoundL的函数调用操作符(即lambda的闭包类对应的函数调用操作符)内部,对setAlarm的调用是正常的函数调用,编译器可以按常规方式进行内联。好比在调用处那行代码内联成:setSoundL(Sound::Siren);

  • 但是,对std::bind的调用是将函数指针传递给setAlarm,这意味着在setSoundB的函数调用操作符(即绑定对象的函数调用操作符)内部,对setAlarm的调用是通过一个函数指针。 编译器不太可能通过函数指针内联函数。因此,使用lambda还可能会比使用std::bind能生成更快的代码

setAlarm示例仅涉及一个简单的函数调用。如果你想做更复杂的事情,使用lambda会更有利。 例如,考虑以下C++14的lambda使用,它返回其实参是否在最小值(lowVal)和最大值(highVal)之间的结果,其中lowValhighVal是局部变量:

1
2
3
4
auto betweenL =
[lowVal, highVal]
(const auto& val) //C++14
{ return lowVal <= val && val <= highVal; };

使用std::bind可以表达相同的内容,但是该构造是一个通过晦涩难懂的代码来保证工作安全性的示例:

1
2
3
4
5
using namespace std::placeholders;              //同上
auto betweenB =
std::bind(std::logical_and<>(), //C++14
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal));

再考虑到lambda还有初始化捕获/广义捕获,泛型lambda等,lambda确实是更好的选择。

1
2
3
4
5
6
7
8
enum class CompLevel { Low, Normal, High }; //压缩等级

Widget compress(const Widget& w, //制作w的压缩副本
CompLevel lev);

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

当我们将w传递给std::bind时,必须将其存储起来,以便以后进行压缩。它存储在对象compressRateB中,但是它是如何被存储的呢?答案是bind中都是按值捕获的,他总会拷贝他的实参。如果想用引用捕获需要一个std::ref的东西auto compressRateB = std::bind(compress, std::ref(w), _1);即可。

std::ref的原理是什么呢?看了下源码发现返回的就是一个`reference_wrapperreference_wrapper不妨简单理解成里面装着指针的一个引用包装。所以bind拷贝实参的时候会拷贝reference_wrapper,即拷贝了变量的指针。

image-20230225231751899

只有在C++11下,那是lambda的初始化捕获和泛型lambda还没有出现时,可以用bind来替代:

初始化捕获:

1
2
3
4
5
6
7
8
std::vector<double> data;      

auto func =
std::bind( //C++11模拟初始化捕获
[](const std::vector<double>& data) //译者注:本行高亮
{ /*使用data*/ },
std::move(data) //译者注:本行高亮
);

泛型lambda:

1
2
3
4
5
6
class PolyWidget {
public:
template<typename T>
void operator()(const T& param);

};

std::bind可以如下绑定一个PolyWidget对象:

1
2
PolyWidget pw;
auto boundPW = std::bind(pw, _1);

boundPW可以接受任意类型的对象了:

1
2
3
boundPW(1930);              //传int给PolyWidget::operator()
boundPW(nullptr); //传nullptr给PolyWidget::operator()
boundPW("Rosebud"); //传字面值给PolyWidget::operator()

这一点无法使用C++11的lambda做到。 但是,在C++14中,可以通过带有auto形参的lambda轻松实现:

1
2
auto boundPW = [pw](const auto& param)  //C++14 
{ pw(param); };

话说回来谁还只用c++11编译器呢?lambda几乎总是更好的选择。

请记住:

  • 与使用std::bind相比,lambda更易读,更具表达力并且可能更高效。
  • 只有在C++11中,std::bind可能对实现移动捕获或绑定带有模板化函数调用运算符的对象时会很有用。