您的位置:首页 > 编程语言 > C语言/C++

Effective Modern C++ 条款34 比起std::bind更偏向使用lambda

2016-09-10 15:42 691 查看

比起std::bind更偏向使用lambda

C++11的std::bind是C++98的std::bind1ststd::bind2nd的继承人,但是,通俗的说,std::bind在2005年的时候已经是标准库的一部分了,那个时候标准委员会采用了名为TR1的文档,里面就包含std::bind的说明。(在TR1中,bind在不同的命名空间,所以它是std::tr1::bind,而不是std::bind,接口和现在有点不同。)这个历史意味着一些开发者对std::bind已经有了十年的或者更多的开发经验了,如果你是他们中的一员,你可能不愿意放弃这个工作得好好的工具。这是可以理解的,但是在如今的情况下,作出改变是好的,因为在C++11,比起使用std::bind,lambda几乎总是更好的选择。到了C++14,lambda在这种情况中不只是变强了,它还披上了装甲。

该条款假设你熟悉std::bind,如果你不熟悉,那么在继续看下去之前,你要对它有基本的认识。这种的认识在某些情况下是值得的,因为你不会知道,在哪一个时间点,看代码或者维护代码时遇到std::bind

就像条款32所说,我std::bind返回的函数对象称为绑定对象(bind object)。

比起std::bind更偏爱lambda的最主要原因是lambda的具有更好的可读性。举个例子,假设我们有个函数用来设置警报:

// 声明一个时间点的类型别名
using Time = std::chrono::steady_clock::time_point;

// 关于"enum class"看条款10
enum class Sound {Beep, Siren, Whistle };

// 声明一个时间长度的类型别名
using Duration = std::chrono::steady_cloak::duration;

// 在时间点t,发出警报声s,持续时间为d
void setAlarm(Time t, Sound s, Duration d);


进一步假设,在程序的某些地方,我们想要设置在一个小时之后发出警报,持续30秒。但是呢,警报的类型,依然是未决定的。我们可以写一个修改了
setAlarm
接口的lambda,从而只需要指定警报类型:

// setSoundL("L"指lambda)是一个允许指定警报类型的函数对象
// 警报在一个小时后触发,持续30秒
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;

setAlarm(steady_clock::now() + hours(1),
s,
seconds(30));
};


注意看lambda里的
setAlarm
,这是一个正常的函数调用,就算只有一点lambda经验的读者都可以看出传递给lambda的参数会作为
setAlarm
的一个实参。

我们可以使用C++14对于秒(s),毫秒(ms),时(h)等标准后缀来简化代码,那是基于C++11的支持而照字面意思定义的。这些后缀在std::literals命名空间里实现,所以上面的代码可以写成这样:

auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals;     // 为了得到C++14的后缀

setAlram(steady_clock::now() + 1h,   // C++14, 不过意思和上面相同
s,
30s);
};


我们第一次尝试写出对应的std::bind调用,代码在下面。我们在注释中说明它有个错误,但是正确的代码复杂得多,而这个简化的版本可以让我们看到重要的问题:

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);


这份代码的读者简单地知道在
setSoundB
里,std::bind会用指定时间点和持续时间来调用
setAlarm
。对于缺少经验的读者,占位符“_1”简直是个魔术,为了理解
setSoundB
的第一个实参会传递给
setAlarm
的第二个参数,读者需要聪明地把std::bind参数列表上占位符的数字和它的位置进行映射。这个实参的类型在std::bind没有说明,所以读者还需要去咨询
setAlarm
的声明,来决定传递给
setSoundB
的参数类型。

但是,如我所说,这代码不完全正确。在lambda中,很明显表达式“steady_clock::now() + 1h”是
setAlarm
的一个实参,当
setAlarm
调用时,表达式会被求值。那是行得通的:我们想要在调用了
setAlarm
后的一个小时触发警报。但在std::bind的调用中,“steady_clock::now() + 1h”作为实参传递给std::bind,而不是
setAlarm
,那意味着表达式在调用std::bind的时候已经被求值,那么表达式的结果时间会被存储在产生的绑定对象中。最终,警报会在调用了std::bind后的一个小时触发,而不是调用
setAlarm
后的一个小时!

解决这个问题需要告知std::bind推迟表达式的求值,直到
setAlarm
被调用,而这种办法需要在原来的std::bind内嵌入一个新的std::bind

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


如果你熟悉来自C++98的std::plus,你可能会对这份代码感到惊奇,因为在两个方括号之间没有指定类型,即代码含有
std::plus<>
,而不是
std::plus<type>
。在C++14,标准操作符模板的模板类型参数可以被省略,所以这里提供类型给它。C++11没有提供这种特性,所以在C++11中对于lambda的std::bind等同物是这样的:

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

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


如果,在现在这个时刻,lambda的实现看起来没有吸引力的话,你可能需要去检查一下视力了。

setAlarm
被重载,会出现一个新的问题。假如有个重载接受第四个参数来指定警报的音量:

enum class Volume { Normal, Loud, LoudPlusPlus };

void setAlarm(Time t, Sound s, Duration d, Volume v);


之前那个lambda还会工作得很好,因为重载决策会选择带有三个参数的
setAlarm
版本:

auto setSoundL =                  // 和以前
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals;

setAlarm(steady_clock::now + 1h,    // 正确,调用
s,                         // 3参数版本的
30s);                      // setAlarm
};


另一方面,std::bind的调用,现在会编译失败:

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


问题在于编译器没有办法决定哪个
setAlarm
应该被传递给std::bind,它拥有的只是一个函数名,而这单独的函数名是有歧义的。

为了让std::bind可以通过编译,
setAlarm
必须转换为合适的函数指针类型:

using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);

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


但这又引出了lambda和std::bind的另一个不同之处。在
setSoundL
的函数调用操作符内(即,lambda的闭包类的函数调用操作符),是以普通函数调用的方式调用
setAlarm
,这可以被编译器以通用的方式内联:

setSoundL(Sound::Siren);   // setAlarm的函数体可能在这里内联


不过,在std::bind的调用中,传递了一个指向
setAlarm
的函数指针,而那意味着在
setSoundB
的函数调用操作符内(即,绑定对象的函数调用操作符),是以函数指针的方式调用
setAlarm
,而那意味着通过
setSoundB
调用的
setAlarm
,比通过
setSoundL
调用的
setAlarm
进行内联的可能性更低:

setSoundB(Sound::Siren);   // setAlarm的函数体在这里内联的可能性较低


因此,使用lambda生成的代码可能会比使用std::bind的快。

setAlarm
那个例子只是简单地调用了一个函数,如果你想做一些更复杂的事情,使用lambda的好处会更加明显。例如,思考这个C++14的lambda,返回它的实参是否在最小值(lowVal)和最大值(highVal)之间,
lowVal
highVal
都是局部变量:

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


std::bind也可以表达同样的东西,不过它为了保证工作正常运行而让代码变得晦涩:

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));


在C++11,你还必须指定要比较的类型,所以std::bind的调用看起来是这样的:

auto betweenB =               // c++11版本
std::bind(std::logical_and<bool>(),
std::bind(std::less_equal<int>(), lowVal, _1),
std::bind(std::less_equal<int>(), _1, highVal));


当然,在C++11中,lambda不能使用auto形参,所以它也必须指定类型:

auto betweenL =           C++11版本
[lowVal, highVal]
(int val)
{ return lowVal <= val && val <= highVal; };


不管怎样,我希望我们能认同lambda的版本不仅代码更短,还具有更好的可读性和可维护性。

在早些时候,我提起过对于那些对std::bind没有经验的程序员,占位符(例如,_1,_2等)跟是魔术一样。不过,占位符的行为不是完全密封的。假设我们有一个用来精简拷贝Widget的函数,

enum class CompLevel { low, Normal, High };  // 精简等级

Widget compress(const Widget& w, CompLevel lev); // 对w进行精简拷贝


然后我们想要创建一个函数对象,它允许我们指定
Widget w
的精简级别,这是用std::bind创建的函数对象:

Widget w;

using namespace std::placeholders;

auto compressRateB = std::bind(compress, w, _1);


当我们把
w
传递给std::bind时,为了以后的
compress
调用,
w
会被存储起来,它存储在对象
compressRateB
中,但它是如何存储的呢——通过值还是引用呢?这是会导致不一样的结果,因为如果
w
在调用std::bind和调用
compressRateB
之间被修改,通过引用存储的
w
也会随之改变,而通过值存储就不会改变。

答案是通过值存储,你想知道答案的唯一办法就是知道std::bind是如何工作的;但在std::bind中没有任何迹象。对比使用lambda方法,
w
通过值捕获或通过引用捕获都是显式的:

auto compressRateL =
[w](CompLevel lev)     // w以值捕获,lev以值传递
{ return compress(w, lev); };


参数以何种方式传递也是显示的。在这里,很清楚地知道参数
lev
是以值传递的。因此:

CompressRateL(CompLevel::High);   // 参数以值传递


但在绑定对象里,参数是以什么方式传递的呢?

compressRateB(ConpLevel::High);    // 参数传递方式?


再次说明,想知答案的唯一办法是记住std::bind是怎样工作的。(答案是传递给绑定对象的所有参数都是通过引用的方式,因为绑定对象的函数调用操作符使用了完美转发。)

那么,对比lambda,使用std::bind的代码可读性不足、表达能力不足,还可能效率低。在C++14,没有理由使用std::bind。而在C++11,std::bind可以使用在受限的两个场合:

移动捕获。C++11的lambda没有提供移动捕获,但可以结合std::bindlambda来效仿移动捕获。具体细节看条款32,那里也解释了C++11效仿C++14的lambda提供的初始化捕获的情况。

多态函数对象。因为绑定对象的函数调用操作符会使用完美转发,它可以接受任何类型的实参(条款30讲述了完美转发的限制)。这在你想要绑定一个函数调用操作符模板时有用。例如,给定这个类:

class PolyWidget {
public:
template<typename T>
void operator() (const T& param);
...
};


std::bind可以绑定
polyWidget
对象:

PolyWidget pw;

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


然后
boundPW
可以绑定任何类型的实参:

boundPW(1930);      // 传递int到PolyWidget::operator()

boundPW(nullptr);   // 传递nullptr到PolyWidget::operator()

boundPW("Rosebud");   // 传递字符串到PolyWidget::operator()


这在C++11的lambda里无法做到,但是在C++14,使用auto形参就很容易做到了:

auto boundPW = [pw](const auto& param)
{ pw(param); }


当然,这些都是边缘情况,而且这种边缘情况会转瞬即逝,因为支持C++14的编译器已经越来越普遍。

2005年,bind非官方地加入了C++,比起它的前身有了很多的进步。而在C++11,lambda几乎要淘汰std::bind,而在C++14,std::bind已经没有需要使用的场合了。

总结

需要记住的2点:

比起使用std::bind,lambda有更好的可读性,更强的表达能力,可能还有更高的效率。

在C++11,只有在实现移动捕获或者绑定函数调用操作符模板时,std::bind可能是有用的。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  c++
相关文章推荐