比起std::bind更偏向使用lambda
C++11的std::bind是C++98的std::bind1st和std::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::bind和lambda来效仿移动捕获。具体细节看条款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可能是有用的。