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

比起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)之间,lowValhighVal都是局部变量:

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可能是有用的。
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页