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可能是有用的。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章