条款34:优先选用lambda式,而非std::bind

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返回的函数对象为绑定对象。

之所以说优先选用lambda式,而非std::bind,最主要原因是lambda式具备更高的可读性。举个例子,假设我们有个函数用来设置声音警报:

//表示时刻的型别typedef
using Time = std::chrono::steady_clock::time_point;

//关于 enum class
enum class Sound {Beep, Siren, Whistle};

//表示时长的型别typedef
using Duration = std::chrono::steady_clock::duration;

//在时刻t,发出声音s,持续时长d
void setAlarm(Time t, Sound s, Duration d);

进一步假设,在程序的某处我们想要设置在一小时之后发出警报并持续30秒。警报的具体声音,却尚未决定。这么一来,我们可以撰写一个lambda式,修改setAlarm的接口,这个新的接口只需指定声音即可:

//setSoundL("L"表示"lambda")是个函数对象,
//它接受指定一个声音
//该声音将在设定后1小时发出,并持续30秒
auto setSoundL = [](Sound s)
{
    //使std::chrono组件不加限定饰词即可用using namespace std::chrono;
    setAlarm(steady_clock::now() + hours(1),  //警报发出时刻为1小时后
             s,
             seconds(30));                    //持续30秒
};

我将lambda式里对setAlarm的调用突显了出来,这是个观感无奇的函数调用,就算没有什么lambda经验的读者都能看的出来,传递给lambda的形参会作为实参传递给setAlarm.

我们可以利用C++14所提供的秒(s),毫秒(ms)和小时(h)等标准后缀来简化上述代码,这一点建立在C++11用户定义字面量这项能力的支持之上。这些后缀在std::literals名字空间里实现,所以上面的代码可以重写如下:

//setSoundL("L"表示"lambda")是个函数对象,
//它接受指定一个声音
//该声音将在设定后1小时发出,并持续30秒
auto setSoundL = [](Sound s)
{
    using namespace std::chrono;
    using namespace std::literals;      //汇入C++14实现的后缀

    setAlarm(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 = 
    std::bind(setAlarm, steady_clock::now() + 1h,
              _1,
              30s);

我也想和在lambda式里一样地把setAlarm的调用在这里突显出来,可惜这里没有调用可供我标出突显。在读这段代码时,只需了解,在调用setSoundB时,会使用在调用std::bind时指定的时刻和时长来唤起setAlarm.固然对于初学者而言,占位符"_1"简直好比天书,但即使是行家也需要脑补出从占位符中数字到它在std::bind形参列表位置的映射关系,才能理解在调用setSoundB时传入的第一个实参,会作为第二个实参传递给setAlarm.该实参的型别在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”.在C++14中,标准运算符模板型别实参大多数情况下可以省略不写,所以此处也就没有必要提供了。而C++11中则没有这样的特性,所以在C++11中,欲使用std::bind撰写与lambda式等价的代码,就只能像下面这样:

using namespace std::chrono;             //同前
using namespace std::placeholders;

auto setSoundB = std::bind(setAlarm,
                           std::bind(std::plus(),             
                                    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;
        setAlarm(steady_clock::now() + 1h,  //没问题,调用的是
                 s,                         //三形参版本的
                 30s);                      //setAlarm
    };

不过,对std::bind的调用,现在可就无法通过编译了:

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

问题在于,编译器无法确定应该将哪个setAlarm版本传递给std::bind。它拿到的所有信息就只有一个函数名,而仅函数名本身是多义的。

为使得std::bind的调用能够通过编译,setAlarm必须强制转型到适当的函数指针型别:

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

auto setSoundB =
    std::bind(static_cast(setAlarm),
              std::bind(std::plus<>(),
                        steady_clock::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)
      { return lowVal <= val && val <= hihgVal; };

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的调用会长成这样:

using namespace std::placeholders;  //同前

auto betweenB =
     std::bind(std::logical_and(),             //C++11
                 std::bind(std::less_equal(), lowVal, _1),
                 std::bind(std::less_equal(), _1, highVal));

当然了,如果使用了C++11,就不能在Lambda式中使用auto型别的形参,所以也必须固化到一个型别才行:

auto betweenL =
      [lowVal, highVal]
      (int val)
      { reutnr lowVal <= val && val <= highVal; };

不管是用C++11还是C++14,我希望大家都能认同,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调用时使用,它存储的位置是在对象compressRateB内,但它以哪一种方式存储的:按值,还是按引用呢?这两者是泾渭分明的,因为如果w在对std::bind的调用动作与对compressRateB调用动作之间被修改了,如果采用按引用方式存储,那么存储起来的w的值也会随之修改,而如果采用按值存储,则存储起来的w值不会改变。

答案揭晓,w是按值存储的。可是,了解答案的唯一途径,就是牢记std::bind的工作原理。在std::bind调用中,答案是无迹可寻的。对比之下,采用lambda式的途径,w无论是按值或按引用捕获,在代码中都是显明的:

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

同样显明的还有形参的传递方式。在这里,形参lev清清楚楚地是按值传递的。因此:

compressRateL(CompLevel::High);  //实参是按值传递

但在std::bind返回的结果对象里,形参的传递方式又是什么呢?

compressRateB(CompLevel::High);    //实参是采用什么方式传递的?

还是那句话,欲知答案,唯一的途径是牢记std::bind的工作原理(答案是绑定对象的所有实参都是按引用传递的,因为此种对象的函数调用运算符利用了完美转发)。

总而言之,比起lambda式,使用std::bind的代码可读性更差、表达力更低,运行效率也可能更糟。在C++14中,根本没有使用std::bind的适当用例。而在C++11中,std::bind仅在两个受限的场合还算有着使用的理由:

  • 移动捕获。C++11的lambda式没有提供移动捕获特性,但可以通过结合std::bind和lambda式来模拟移动捕获。欲知详情。参见条款32,统一条款还解释了c++14提供了初始化捕获的语言特性,从而消除了如此进行模拟的必要性。
  • 多态函数对象。因为绑定对象的函数调用运算符利用了完美转发,它就可以接受任何型别的实参(除了在条款30讲过的那些完美转发的限制情况)这个特点在你想要绑定的对象具有一个函数调用运算符模板时,是有利用价值的。例如,给定一个类:
class PolyWidget
{
public:
    template
    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型别形参的lambda式就可以轻而易举地达成同样的效果:

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

这些都是边缘用例,而即使这些边缘用例也会转瞬即逝,因为支持C++14的lambda式的编译器已经日渐普及。

当2005年bind被非正式地加入C++时,比其它在1998年的前身已经有了长足的进度,而C++11中加入的lambda式则是的std::bind相形见绌。到了C++14的阶段,std::bind已经彻底失去了用武之地。

要点速记:

  • lambda式比起使用std::bind而言,可读性更好、表达力更强,可能运行效率也更高。
  • 仅在C++11中,std::bind在实现移动捕获,或是绑定到具备模板化的函数调用运算符的对象的场合中,可能尚有余热可以发挥。

 注:

std::bind总是复制其实参,但调用方却可以通过对某实参实施std::ref的手法达成按引用存储之的效果,下述语句:

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

结果就是compressRateB的行为如同持有的是个指涉到w的引用,而非其副本。

你可能感兴趣的:(Effective,Modern,C++,c++)