C++ 11 perfect forwarding 到底是在 forward 什麼?

前言

Perfect forwarding 是 C++ 11 出現後,頗常出現的一個字彙,但是說到底,perfect
forwarding 到底是在 forward 什麼?

先來說說 const

爲了正確解釋 perfect forwarding,我們必須把需要的元件一一建好,第一項就先從 const 這個東西開始!下面是我用來解說的 Foo class:

#include <iostream>

struct Foo final {
    void doThis() { std::cout << "doThis()" << std::endl; }
    void doThis() const { std::cout << "doThis() const" << std::endl; }
};

在這個實作中,會隨着 Foo 是否爲 const 變數而決定究竟要呼叫哪一個 doThis()。 在有了上面這個型別之後,我們很自然會遇到類似的使用情境:把 Foo 實體丟入一個函式中,並由該函式呼叫 Foo 上面提供的 method,像是:

void
callDoThis(const Foo& foo) {
    // Do something like logging.
    // ...

    foo.doThis();
}

嗯,好像哪裏怪怪的...

沒錯,在上面版本的 callDoThis 傳入的參數是 const Foo&,這樣導致了幾項問題:

  • 因爲寫死的型別,即便我們傳入的參數爲 Foo,依然會呼叫到 doThis() const
  • 爲了 Foo 我們必須再 overload 一份非 const 版本的 callDoThis
// This is the non-const version of callDoThis.
void
callDoThis(Foo& foo) {
    foo.doThis();
}

太好了,現在開始每有一個新物件,一個新 method 仰賴類似的模式,我們都要手寫一份幾乎一模一樣的程式碼!嗯,當然不,偷懶 有效率且優美解決問題是工程師的美學,面對這樣的問題 C++ 中自然有一套解決的方法:template

template<typename T>
void
callGetThis(T& t) {
    t.getThis();
}

首先要知道的事情是,這裏的 template parameter T 是可以包含型別的 cv (const & volatile) 修飾詞,也就是說:

auto foo = Foo{};
const auto constFoo = Foo{};

// T is `Foo`.
// This would call Foo::doThis().
callGetThis(foo);

// T is `const Foo`.
// This would call Foo::doThis() const.
callGetThis(constFoo);

// We could also invoke Foo::doThis() const on non-const Foo instance.
callGetThis<const Foo>(foo);
callGetThis(static_cast<const Foo>(foo));

const auto& fooConstRef = foo;
callGetThis(fooConstRef);

搭配 C++ template 的協助,我們解決了必須手寫兩種版本實作的問題。誒,所以說,那個 perfect forwarding 呢?是啊,我已經開始解釋了。在上面的例子,我們正在把 某變數的型別資訊 (const or non-const) 正確地傳導到其他函式內

想必讀到這裏,各位已經可以看出 perfect forwarding 的端倪了。forwarding 並不是什麼新把戲,即使在 C++98 上面提出的程式碼也一樣可以編譯 (啊,等等,我用了 auto :p) 也能夠讓 template function 正確地依照傳入參數的型別呼叫正確版本的 method。

也就是說,所謂的 perfect forwarding,其實就是 C++11 中新加入了些 const 之外,能夠決定到底要呼叫那種版本的 method 的因素,而必須正確撰寫 template function 才能將這些資訊 完美地 傳入 method (或是另一層 function) 中。

啊,說起來似乎有些繞口,不如我們直接用例子來解說吧!

rvalue 和 lvalue

我並沒有打算鉅細靡遺地解說 rvalue 跟 lvalue 的百分之百正確定義,如果下面提到的定義或是解釋太過簡略,還請各位讀者提醒,我會再做修改。

Ok,我知道這是個有些嚇人的標題,像是筆者也花了不少時間才正式瞭解到這兩種物件類型的特性。這邊我並不打算細細解釋他們,而是想介紹那些會隨着物件實體是 rvalue 還是 lvalue 而產生變化的函式呼叫,以及我們要如何正確的將這些資訊傳入下一層函式中。

首先不免,我們還是要概略講一下 lvalue 跟 rvalue 定義跟產生方式。非常簡略地說,能夠提取出 記憶體位置 的物件就是 lvalue,反之則爲 rvalue。從我們的使用狀況來看,其實並不難理解這樣的邏輯跟命名:

// 該函式回傳一個 Foo 的暫時物件,如果呼叫者沒有使用 = 運算子去接它的話,這份實體就會消失在數據之海.
Foo
createFoo() { return Foo{}; }

// 我們在 **左邊 (left)** 接收了一個從 **右邊 (right)** 傳來的暫時物件.
// 在這行操作中,foo 是 lvalue,而 createFoo() 傳回的暫時變數則是 rvalue.
// 我們可以對 foo 取址,但是卻無法從 createFoo() 傳回的暫時物件找到對應的地址.
const auto foo = createFoo();

// 物件自己的建構子也有相同的特性.
// foo2 是 lvalue,而 Foo{} 呼叫則會回傳一份爲 rvalue 的暫時變數
const auto foo2 = Foo{};

// std::move 是 C++11 中新增的一個函式,簡言之,它可以將傳入的參數暫時轉爲 rvalue.
// 這裏 foo3 還是 lvalue,然後 std::move() 回傳的 **東西** 則是 rvalue.
const auto foo3 = std::move(foo2);

雖然說 rvalue 跟 value 的名字很有可能來自於常出現在 = 的左右側,不過建議還是要理解正確的特性跟定義,畢竟 C++... 有很多 特別 的設計嘛。

上面提到的是幾項非常常見,產生 lvalue 跟 rvalue 的手段,至於爲什麼要弄個 rvalue 出來... 這個以後有空再來說吧。接下來我們會有兩段的討論,第一段是關於 method 的,第二段則是關於函式。

ref-qualifier

首先我們先來繼續擴充 Foo 型別:

struct Foo final {
    void doThis() { std::cout << "doThis()" << std::endl; }
    void doThis() const { std::cout << "doThis() const" << std::endl; }

    // NEW!
    void doThat() & { std::cout << "doThat() &" << std::endl; }
    void doThat() && { std::cout << "doThat() &&" << std::endl; }
};

在物件的成員函式中加入 ref-qualifier 後,會產生類似 const-qualifier 的效果。當我們在 lvalue 的實體上呼叫 doThat(),我們會呼叫到 & 的版本,在 rvalue 上呼叫則會是 && 的版本。下面是使用範例:

auto foo = Foo{};

// Would invoke `doThat() &`
foo.doThat();

// Would invoke `doThat() &&`
Foo{}.doThat();

好的,接着我們又會到原本的函式傳入問題,假設我們有個函式:

// This won't work perfectly.
template<typename T>
void
callDoThatA(T& t) {
    t.doThat();
}

// This won't work perfectly as well.
template<typename T>
void
callDoThatB(T t) {
    t.doThat();
}

{
    auto foo = Foo{};

    // foo is lvalue, we invoke `doThat() &`.
    callDoThatA(foo);

    // Can't even compiled.
    // callDoThatA(Foo{});

    // foo is lvalue, we invoke `doThat() &`.
    callDoThatB(foo);

    // foo is rvalue, we invoke `doThat() &`, which is not what we want.
    callDoThatB(Foo{});
}

啊,不管那種實作都沒辦法把 Foo{} 的暫時回傳值是 rvalue 這件事順利的傳達下去呢。所以,C++11 中爲了解決這件事情,加入了一個新的型別描述:

template<typename T>
void
callDoThat(T&& t) {
    t.doThat();
}

{
    auto foo = Foo{};

    // T is Foo&, and we invoke `doThat() &`
    callDoThat(foo);

    // T is Foo, and we invoke `doThat() &`... What!?
    callDoThat(Foo{});
}

上面我們看到的 T&&,某個比較流行的名稱是 universal referrenceT&& 的行爲簡單來說,就是當傳入 lvalue 時,t 的型別會被推導爲 Foo&,當傳入的是 rvalue 時,t 的型別則會是 Foo。這樣完全符合我們的預期,傳入已經存在的變數,就用參考來接,如果傳入的是暫時變數,就用 rvalue 的參考 來接 (我知道有些人可能已經頭昏了,但是,就暫且把它視爲一個 rvalue 物件吧),這樣的實作除了分辨 rvalue 和 lvalue 外,最前面提到的 const 屬性也依然能更正確的傳入進去!

故事到這裏還沒有結束,還記得我們要做什麼嗎?傳入 rvalue 並且呼叫 doThat() &&。但是現在的狀況,雖然我們成功傳入 lvalue 的 Foo 並呼叫了 doThat(),呼叫到的卻依然是 doThat() & 而不是 rvalue 的 doThat() &&。問題的原因寫成程式碼大概是這樣:

// Both function are generated by compiler from our template function.

// t is `lvalue` to Foo!
void
callDoThat(Foo t) {
    t.doThat();
}

// t is `lvalue` to referrence of Foo.
void
callDoThat(Foo& t) {
    t.doThat();
}

沒錯,我們是傳入的 rvalue 的 Foo,但是傳入 template function 後它就不知道 t 曾經是個 rvalue,單純把它當作一般的 lvaue 呼叫,自然也就不會呼叫到 doThat() &&。還記得剛剛有稍微提到的 std::move() 嗎? 那是個可以把 lvalue 變成 rvalue 的一個函式呼叫,聽起來相當符合我們現在的需求,讓我們加到 callDoThat() 裏面試試看:

template<typename T>
void
callDoThat(T&& t) {
    std::move(t).doThat();
}

{
    auto foo = Foo{};

    // T is Foo&, and we invoke `doThat() &&`... What!?
    callDoThat(foo);

    // T is Foo, and we invoke `doThat() &&`.
    callDoThat(Foo{});
}

啊,std::move 把我們傳入的 Foo& 也變成 rvalue 了...

好好好,我們再重新列一下我們的需求:

  • 一個 template function,可以根據傳入參數的是 rvalue 還是 lvalue 推導出正確的型別 (完成了)
  • 需要一個函式
    • 當傳入參數是參考型別 (Foo&) 時,回傳他的 lvalue (也就是什麼都不做,傳回 Foo&)
    • 當傳入參數是物件實體 (Foo) 時,回傳他的 rvalue
    • 很明顯,std::move 做不到,因爲它會無條件把東西轉換成 rvalue

爲了達成我們提出的需求,C++ 11 提供了一個如此的函式,std::forward<T>,加入 std::forward 後我們的函式可以改寫成以下的樣子:

template<typename T>
void
callDoThat(T&& t) {
    std::forward<T>(t).doThat();
}

{
    auto foo = Foo{};

    // T is Foo&, and we invoke `doThat() &`, neat!
    callDoThat(foo);

    // T is Foo, and we invoke `doThat() &&`, correct!
    callDoThat(Foo{});
}

呼,加入 std::forward 後,我們撰寫的 callDoThat 已經能夠:

到這邊,我們就完成了所謂的 perfect forwarding 了!不僅僅是 const 與否的資訊,我們也讓 template function 有能力依照傳入傳輸是 lvalue 還是 rvalue 作出正確的函式呼叫。

用途

使用 universal reference 實際除了正確傳遞變數資訊外,另一個常見的用途是減少無謂的複製和物件建立的開銷。一個簡單直覺的例子就是 std::vector 提供的 push_back()emplace_back()

push_back() 呼叫傳入的是 std::vector 中 T 的 實體,而 emplace_back() 傳入的則是 T 的 材料。在 emplace_back 的實作中,我們除了將使用者給的材料傳送給 T 的建構子外,我們什麼事也不需要做,自然使用 forward 的方式能夠保有最大效率。什麼也不做,只是把變數的 rvalue reference 傳到建構子裏面,過程中沒有複製,彷彿 emplace_back 不存在似的...

總而言之,一切都是爲了效能考量所產生出來的東西。

結語

雖然 C++11 已經問世了快七年了,但是加入的東西真的是多到不可思議,不單單是增加了函式的界面或是新函式,同時也是導入許多新機制的一次更新,即使到今日也應該花時間瞭解,畢竟說是說 C++ 11,但這些特性也依然在現代 C++ 中繼續被使用而且擴增。正如 JOJO 第七部耶穌所說,心存迷惘就不要擊發,避免使用不懂的技術的代價就是獲得不到他們帶來的好處,那何不瞭解然後運用他們呢?

嗯,其實在討論 perfect forwarding 之前應該是要討論 move assignment operation,那才是出現 rvalue 這個東西的最大用途,不過還是有空再討論吧!

參考

referrenceCollapse: http://en.cppreference.com/w/cpp/language/reference
valueCategories: http://en.cppreference.com/w/cpp/language/value_category
moveAssignmentOperation: http://en.cppreference.com/w/cpp/language/move_assignment
universalReferrence: https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
vectorPushBack: http://en.cppreference.com/w/cpp/container/vector/push_back
vectorEmplaceBack: http://en.cppreference.com/w/cpp/container/vector/emplace_back

Comments

comments powered by Disqus