C++ 11 引入的移動語意或許是 C++ 11 最成功的特性, 它使得 C++ 有能力在處理類別的時候, 和處理內建型別那樣簡便, 極大地提升了運作效率. 同樣地, 這篇文章需要你熟悉 C++ 的移動語意、通用參考和完美轉遞, 這並不是給新人看的, 至少你需要會用這些. 如果你不太熟悉的話, 請先閱讀《C++ 學習筆記》. 文章是作深入介紹, 所有的基礎知識在文章《C++ 學習筆記》中全都已經詳細介紹過了

1. 移動

當你僅僅實作移動建構子或者移動指派運算子中的其中一個的時候, 就是在告訴編碼器, 這個類別的移動操作和編碼器自己生成的移動操作有些不同, 那麼編碼器就會阻止另外一個移動操作生成

當你僅僅實作複製建構子或者複製指派運算子, 但是並未實作移動建構子和移動指派運算子的時候, 那麼編碼器也會阻止移動建構子和移動指派運算子的生成. 反之, 當你僅僅實作移動建構子或者移動指派運算子的時候, 編碼器會阻止移動建構子和移動指派運算子生成. 這倒是也說得過去, 因為某一操作的自訂就代表著編碼器生成的操作不太合適, 由此編碼器就會推斷另一個操作也不適合生成, 於是乾脆設定為被刪除的函式. 需要說明的是, 複製建構子和複製指派運算子的生成是相互獨立的, 其中一個操作被實作並不代表著另外一個操作會被編碼器拒絕生成, 從 C++ 98 開始就是這樣. 不過, 這個特性從 C++ 11 開始被遺棄, 也就是說, 在 C++ 11 之後, 在某個操作存在的情況下, 編碼器很可能不會幫你合成另外一個操作

我們已經知道, 存在自訂解構子的情況下, 編碼器會拒絕生成移動操作, 但是仍然會生成複製操作. 這個特性從 C++ 11 開始也被遺棄

對於類別

class Foo {
public:
    template <typename T>
    Foo(const T &);
    template <typename T>
    Foo(T &&);
    template <typename T>
    Foo &operator=(const T &);
    template <typename T>
    Foo &operator=(T &&);
};

即使樣板參數 T 被具現化為 Foo, 在需要的情況下, 編碼器仍然為 Foo 生成複製建構子、複製指派運算子、移動建構子和移動指派運算子, 還有解構子

對於下列程式碼, 有些程式設計師可能感到不適 :

#include <string>

using namespace std;
string f(const string &str) {
    string s;
    //...
    return s;
}

在學習完移動之後, 他們迫切地想要對函式 f 的回傳值進行 "優化" :

#include <iostream>

using namespace std;
string f(const string &str) {
    string s;
    //...
    return move(s);
}

但是這個行為是幾乎錯誤的, 目前比較好的 IDE 也沒有對這些行為給出一些警告, 編碼器也不會預設地對這樣的行為給出警告. 這是因為, 自 C++ 98 標準發布以來, C++ 中就存在著回傳值優化 (Return Value Optimization, 簡稱 RVO), 這是由編碼器來完成的. 要向一個回傳值發生 RVO, 必須滿足下面兩個條件 :

  • 局域變數的型別於函式的回傳型別完全相同
  • 回傳的是局域變數本身, 而非其參考或者其它

那麼也就是說, 任何遵循 C++ 標準的編碼器都會對函式 f 回傳的 string 物件進行優化, 使得其在回傳時無須進行複製. 這是因為編碼器直接把回傳值放到了回傳值對應的存儲空間中, 然後針對這個存儲空間中的值, 運作函式 f 剩餘的陳述式. 如果不進行優化的話, 後果就是在某一個存儲空間中建構一個 string 物件, 當函式運作終結的時候, 從這個存儲空間中複製 string 物件到回傳值對應的存儲空間中. 因此經過 "優化" 的函式 f 想法是好的, 但是它阻止了 RVO. 因為經過移動之後, 局域變數 s 的型別從 string 變成了 string &&, 這和回傳型別並不相符, 不符合 RVO 發生的條件, 結果適得其反 : 編碼器無法直接將局域變數 s 放在回傳值對應的存儲空間中, 只能另外為其開闢一段存儲空間, 只不過最後使用的是移動將 s 移動到了回傳值對應的存儲空間中罷了. 這樣, 不但多了一次移動, 還多了一次解構

但是 C++ 的程式碼是千變萬化的, 你可能會寫出下面這段程式碼反駁我的言論, 你認為下面這段程式碼 RVO 很難發生 :

#include <string>

using namespace std;
string f(int id) {
    string s("Hello, ");
    switch(id) {
        case 0:
            return {};
        case 1:
            s = to_string(id);
            [[fallthrough]];
        case 2:
            s.insert(s.cend(), 10, id);
            return s;
        default:
            break;
    }
    return s;
}

上面的回傳值, 都是滿足 RVO 進行的條件的, 但是在函式引數 id 無法確認的情況下, 編碼器如何進行 RVO 呢? 回傳的物件都不一定是同一個! 沒錯, 在引數 id 無法確認的情況下, 編碼器是無法對回傳的物件進行 RVO, 或者說想要進行 RVO 有較大的困難. 這時, 你可能會嘗試盡可能為每一個回傳的局域變數添加一個移動操作. 但是, 這也沒什麼必要. 因為 C++ 標準還規範了如若回傳值滿足 RVO 發生的條件, 但是編碼器不進行 RVO, 那麼就必須把回傳值當作右值來處理. 這麼一來, 相當於 C++ 標準已經規範了, 當 RVO 的前提被滿足的時候, 編碼器只有兩種選擇 :

  1. 對回傳值進行優化, 避免不必要的複製和解構操作發生
  2. 隱含地為用戶將回傳值轉型為右值, 相當於編碼器為我們隱含地進行了移動

但是如果 RVO 的前提不被滿足的情況下, 考慮下面程式碼 :

#include <string>

using namespace std;
string f(string &str) {
    string &s {str};
    switch(str[0]) {
        case 'A':
            return {};
        case 'B':
            s = to_string('0');
            [[fallthrough]];
        case 'C':
            s.insert(s.cend(), 10, '1');
            return s;
        default:
            break;
    }
    return s;
}

這個時候, 如果你想要進行優化的話, 就必須為回傳值手動地添加移動操作 :

#include <iostream>

using namespace std;
string f(string &str) {
    string &s {str};
    switch(str[0]) {
        case 'A':
            return {};
        case 'B':
            s = to_string('0');
            [[fallthrough]];
        case 'C':
            s.insert(s.cend(), 10, '1');
            return move(s);
        default:
            break;
    }
    return move(s);
}

這就是為什麼我之前說為回傳值添加移動操作幾乎是錯誤的, 而並非完全錯誤的原因

2. 通用參考

C++ 對於通用參考的限定非常嚴格, 要想成為通用參考, 必須是以 T && 的形式出現, 而且 T 必須由編碼器推導而來 :

#include <vector>

using namespace std;
template <typename T>
void f(vector<T> &&v);      //非通用參考, 不具備 T && 的形式
template <typename T>
void f(const T &&);     //非通用參考, 帶有 const 限定, 左值不能繫結到 const T && 上
template <typename T>
class Foo {
public:
    void func(T &&);        //非通用參考, T 是類別樣板的引數
                            //即使 C++ 17 引入樣板引數推導, 但它仍然是類別 Foo 的一部分. 如果沒有 Foo 就沒有 T &&
                            //而通用參考要求可以獨立存在, 不能依賴於其它型別或者條件
};

除了借助樣板產生通用參考, 還可以借助 auto, 即 auto && 的形式也是通用參考. 但是它不經常使用, 一般用於泛型 lambda 表達式的參數中 :

auto lambda {[](auto &&...) noexcept -> void {
    //...
}};

使用 using 或者 typedef 宣告一個型別別名也可能產生通用參考 :

template <typename T>
using universal_reference = T &&;

使用 decltype 也可能產生通用參考 :

#include <string>

using namespace std;
template <typename T>
void f(T &&t) {
    using reference_t = decltype(t) &&;
    reference_t a {forward<T>(t)};
    cout << is_same_v<decltype(a), remove_reference_t<T>>;
    cout << is_same_v<decltype(a), remove_reference_t<T> &>;
    cout << is_same_v<decltype(a), remove_reference_t<T> &&> << endl;
}
int main(int argc, char *argv[]) {
    f(0);       //001
    auto a {0};
    f(a);       //010
    f(move(a));     //001
}

有一個不太準確的斷言 : 可以發生參考折疊的即是通用參考

通用參考非常好用, 但是我們如果誤用的話, 也會導致程式的行為和我們預期的不太一致 : 若某個無回傳值的函式 f 接受任何型別的引數, 那麼我們通常會使用通用參考來實作 :

template <typename T>
void f(T &&);

如果我們有希望函式 f 對內建的有號數整型型別有不同的行為, 那麼有些人會一步到位, 寫出這樣一個多載 :

template <typename T>
void f(T &&);
void f(long long);

沒錯, 他們希望任意一個有號數整型型別由編碼器自動地提升到 long long 型別, 從而匹配到函式 void f(long long); 上. 但是這個想法在存在通用參考的多載函式的情況下, 絕對錯誤. 考慮有一個 int 型別的變數 i, 用它來呼叫函式 f : f(i). 它會使得通用參考的多載函式版本具現化出這樣的實體 :

template <>
void f(int &);

然後產生了精確匹配, 導致函式呼叫 f(i) 無法匹配到 void f(long long); 上. 這種錯誤的匹配在建構子上顯得尤為嚴重 :

class Foo {
public:
    Foo();
    template <typename T>
    Foo(T &&);
    Foo(const Foo &);
    //...
};
int main(int argc, char *argv[]) {
    Foo f;
    Foo f2(f);      //通用參考的多載函式版本具現化出 template <> Foo::Foo(Foo &); 從而匹配到具現化出的那個實體上, 而並非使用複製建構子
    const Foo f3;
    Foo f4(f3);     //使用複製建構子
}

為了解決這樣的困擾, 最容易想到的就是放棄函式多載或者乾脆使用 C++ 98 式的方法 :

class Foo {
public:
    template <typename T>
    Foo(const T &);
    //...
};

當然, 也可以乾脆直接傳遞值, 不過有時候, 直接傳值而導致的複製代價太高. 另外, 還有兩種方法不太常見, 不過確實解決這個問題的好方法. 一種是利用委託的形式, 另外寫一個函式多載 :

#include <type_traits>

using namespace std;
template <typename T>
void f_aux(T &&, true_type);
template <typename T>
void f_aux(T &&, false_type);
template <typename T>
void f(T &&value) {
    f_aux(forward<T>(value), is_integral<T>());
}

std::is_integral 是來自於標頭檔 <type_traits> 的一個超函式, 用於判定給定的型別是否為一個整型型別. 其建構的結果可以向 std::true_type 或者 std::false_type 轉型. 當給定的型別是整型的時候, 它是從 std::true_type 繼承的; 否則, 它是從 std::false_type 繼承的. 因此, 如果給定的 T 是整型型別, 那麼將會匹配到 template <typename T> void f_aux(T &&, std::true_type); 上; 否則, 將會匹配到 template <typename T> void f_aux(T &&, std::false_type); 上. 不過, 還有一種情況需要考慮, 就是 T 可能帶有參考限定, 這個時候需要借助 std::remove_reference 來移除參考限定, 因此正確的實作應該是 :

#include <type_traits>

using namespace std;
template <typename T>
void f_aux(T &&, true_type);
template <typename T>
void f_aux(T &&, false_type);
template <typename T>
void f(T &&value) {
    f_aux(forward<T>(value), is_integral<typename remove_reference<T>::type>());
}

還有一種方法是利用 SFINAE, 如果你已經學習過我寫的關於 SFINAE 的文章或者你已經提前知道 SFINAE, 那麼你可以學習這個方法. 否則, 我建議你先去學習 SFINAE, 可以參見我的欄目《Template Meta Programming》 :

#include <type_traits>

using namespace std;
template <typename T, typename = typename enable_if<is_integral_v<typename remove_reference<T>::type, void>>::type>
void f(T &&);

此時, 若且唯若第二個預設的樣板引數推導成功的時候, 這個函式才會啟用. 而第二個樣板引數只有當給定的 T 是整型型別才會推斷成功

使用 SFINAE 可以同樣可以解決類別 Foo 遇到的問題 :

#include <type_traits>

using namespace std;
class Foo {
public:
    template <typename T, typename = typename enable_if<is_same_v<Foo, typename decay<T>::type>>::type>
    Foo(T &&);
    //...
};

在上面, 我省略了 std::enable_if 需要給定的第二個樣板引數, 因為它被預設為 void. 但是, 這個解決方法考慮地不夠全面. 你可能覺得不可思議, 因為我們這裡使用了 std::decay, 而 std::decay 會將所有遇到的 constvolatile 以及參考限定通通去掉, 比 std::remove_reference 還要狠. 考慮下面這種情形 :

#include <type_traits>

using namespace std;
class Foo {
public:
    template <typename T, typename = typename enable_if<is_same_v<Foo, typename decay<T>::type>>::type>
    Foo(T &&);
    //...
};
class Bar : public Foo {
public:
    Bar(const Bar &rhs) : Foo(rhs) {}
    Bar(Bar &&rhs) : Foo(rhs) {}
    //...
};

這樣, 我們的本意是 Bar 在複製建構或者移動建構的時候, 使用 Foo 的複製建構子或者移動建構子, 但是它卻被匹配到了那個帶有通用參考的建構子上, 因為第二個樣板引數 typename enable_if<is_same_v<Foo, typename decay<T>::type>>::type 推導成功了, 它成功地被 void 所替換. 所以我才說, 我們考慮地還不夠全面, 還要考慮繼承的情況. 標頭檔 <type_traits> 中還有一個超函式用於判定一個類別是否為另外一個類別的基礎類別 : std::is_base_of. 如果給定相同的型別, 也就是 std::is_base_of<T, T>, 其回傳值為 true, 因為任何一個變數都可以認為從自己衍生. 因此, 就有了下面這個實作的版本 :

#include <type_traits>

using namespace std;
class Foo {
public:
    template <typename T, typename = typename enable_if<is_base_of_v<Foo, typename decay<T>::type>>::type>
    Foo(T &&);
    //...
};
class Bar : public Foo {
public:
    Bar(const Bar &rhs) : Foo(rhs) {}
    Bar(Bar &&rhs) : Foo(rhs) {}
    //...
};

還沒完! 為了防止任何引數都可以向 Foo 型別轉型, 我們還要為 Foo 型別中帶有通用參考的建構子加上 explicit 限定才算完. 當然, 如果你有著這樣的需求, 那麼可以不需要加上 explicit 限定

通過上面的實例, 我們得出結論 : 通用參考在性能方面確實具備一定的優勢, 但是在易用性方面有著一定的劣勢. 但是易用性並不只是體現在上面那些實例中, 有時候還體現在編碼錯誤的信息中 :

#include <iostream>

using namespace std;
template <typename T>
void f(T &&t) {
    string s(t);
    //...
}
int main(int argc, char *argv[]) {
    f(L"Hello");        //Error. string 並沒有辦法去處理引數為 const wchar_t [6] 型別的情況
}

真正錯誤的原因就在註解中, 也就那麼一句話, 解決方案是使用 std::wstring. 但是編碼器不這麼想, 它想要盡可能給你完整的信息, 於是在 Clang 上就有了這樣的編碼錯誤提示 :

Untitled.cpp:6:12: error: no matching constructor for initialization of 'std::__1::string' (aka 'basic_string<char, char_traits<char>, allocator<char> >')
string s(t);
^ ~
Untitled.cpp:10:5: note: in instantiation of function template specialization 'f<wchar_t const (&)[6]>' requested here
f(L"Hello");
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:792:40: note: candidate constructor not viable: no known conversion from 'wchar_t const[6]' to 'const std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::allocator_type' (aka 'const std::__1::allocator<char>') for 1st argument
_LIBCPP_INLINE_VISIBILITY explicit basic_string(const allocator_type& __a)
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:799:5: note: candidate constructor not viable: no known conversion from 'wchar_t const[6]' to 'const std::__1::basic_string<char>' for 1st argument
basic_string(const basic_string& __str);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:804:5: note: candidate constructor not viable: no known conversion from 'wchar_t const[6]' to 'std::__1::basic_string<char>' for 1st argument
basic_string(basic_string&& __str)
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:817:5: note: candidate constructor template not viable: no known conversion from 'wchar_t const[6]' to 'const char *' for 1st argument
basic_string(const _CharT* __s) {
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:867:5: note: candidate constructor not viable: no known conversion from 'wchar_t const[6]' to 'initializer_list<char>' for 1st argument
basic_string(initializer_list<_CharT> __il);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:853:18: note: candidate template ignored: requirement '__can_be_converted_to_string_view<char, std::__1::char_traits<char>, wchar_t [6]>::value' was not satisfied [with _Tp = wchar_t [6]]
explicit basic_string(const _Tp& __t);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:827:9: note: candidate constructor template not viable: requires 2 arguments, but 1 was provided
basic_string(const _CharT* __s, const _Allocator& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:838:9: note: candidate constructor template not viable: requires 3 arguments, but 1 was provided
basic_string(size_type __n, _CharT __c, const _Allocator& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:848:9: note: candidate constructor template not viable: requires at least 3 arguments, but 1 was provided
basic_string(const _Tp& __t, size_type __pos, size_type __n,
    ^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:857:18: note: candidate constructor template not viable: requires 2 arguments, but 1 was provided
explicit basic_string(const _Tp& __t, const allocator_type& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:861:9: note: candidate constructor template not viable: requires 2 arguments, but 1 was provided
basic_string(_InputIterator __first, _InputIterator __last);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:864:9: note: candidate constructor template not viable: requires 3 arguments, but 1 was provided
basic_string(_InputIterator __first, _InputIterator __last, const allocator_type& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:789:31: note: candidate constructor not viable: requires 0 arguments, but 1 was provided
_LIBCPP_INLINE_VISIBILITY basic_string()
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:800:5: note: candidate constructor not viable: requires 2 arguments, but 1 was provided
basic_string(const basic_string& __str, const allocator_type& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:812:5: note: candidate constructor not viable: requires 2 arguments, but 1 was provided
basic_string(basic_string&& __str, const allocator_type& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:830:5: note: candidate constructor not viable: requires 2 arguments, but 1 was provided
basic_string(const _CharT* __s, size_type __n);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:834:5: note: candidate constructor not viable: requires 2 arguments, but 1 was provided
basic_string(size_type __n, _CharT __c);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:869:5: note: candidate constructor not viable: requires 2 arguments, but 1 was provided
basic_string(initializer_list<_CharT> __il, const _Allocator& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:832:5: note: candidate constructor not viable: requires 3 arguments, but 1 was provided
basic_string(const _CharT* __s, size_type __n, const _Allocator& __a);
^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:843:5: note: candidate constructor not viable: requires at least 2 arguments, but 1 was provided
basic_string(const basic_string& __str, size_type __pos,
    ^
/Library/Developer/CommandLineTools/usr/bin/../include/c++/v1/string:840:5: note: candidate constructor not viable: requires at least 3 arguments, but 1 was provided
basic_string(const basic_string& __str, size_type __pos, size_type __n,
    ^
1 error generated.

對於剛學習 C++ 的新人來說, 難以排查錯誤點的哪裡. 當然, 我們可以使用一些技巧讓編碼器的錯誤提示更容易看懂 :

#include <iostream>

using namespace std;
template <typename T>
void f(T &&t) {
    static_assert(is_constructible_v<string, T>, "Cannot use T to construct string");
    string s(t);
    //...
}

std::is_constructible 同樣來自標頭檔 <type_traits>, 它用來檢查給定的型別或者一系列型別 (它可以接受任意多個樣板引數) 能否建構第一個型別. 此時就會提示我們 "Cannot use T to construct string". 但是我們自己給定的信息並不一定出現在最前面, 有時候會出現在中間或者最後面. 比如函式 f 最開始呼叫了另外一個函式 f2, 它裡面也使用引數 t 去建構一個 string, 這樣即使我們寫了 static_assert, 但是給出的信息並不是在最前面, 可能的情況是出現在中間

最後我們來說一下, 四種會發生參考折疊的情形, 它們的不同之處. 實際上, 不同之處只有一個, 如果通用參考位於函式參數當中, 在其被推導為右值參考的時候, 會去掉右值參考限定; 但是對於使用 auto、型別別名和 decltype 獲得通用參考, 在進行型別推導的時候, 右值參考會保留下右值參考限定 :

#include <iostream>

using namespace std;
template <typename T>
void f(T &&t) {
    using remove_ref_t = typename remove_reference<T>::type;
    cout << is_same_v<T, remove_ref_t>;
    cout << is_same_v<T, remove_ref_t &>;
    cout << is_same_v<T, remove_ref_t &&> << endl;
}
int main(int argc, char *argv[]) {
    int a {0};
    int &&b {0};
    f(a);       //010
    f(b);       //010
    f(move(b));     //100
    f(0);       //100
}

這裡僅僅給出使用 auto && 推導的情形, 對於型別別名和使用 decltype 推導的結果和 auto && 推導的結果是一樣的 :

#include <iostream>

using namespace std;
template <typename T>
void f(T &&t) {
    using remove_ref_t = typename remove_reference<T>::type;
    auto &&value {forward<T>(t)};
    cout << is_same_v<decltype(value), remove_ref_t>;
    cout << is_same_v<decltype(value), remove_ref_t &>;
    cout << is_same_v<decltype(value), remove_ref_t &&> << endl;
}
int main(int argc, char *argv[]) {
    int a {0};
    int &&b {0};
    f(a);       //010
    f(b);       //010
    f(move(b));     //001
    f(0);       //001
}

不過在之前的實例中, 我們也給出過使用 delctype 進行推導的結果

3. 完美轉遞

一說到完美轉遞, 大家就可以立馬想起通用參考. 除了通用參考之外, 完美轉遞還需要 std::forward 的配合. 我們曾在文章《【C++ 11】物件移動》中, 詳細地講述了移動的一個坑, 然後順便提到了 std::forward. 當時, 我只是說, std::forward 可以保持右值的型別, 並沒有深究. 現在, 我寫出和 C++ 標準樣板程式庫提供的 std::forward 非常相似的 forward 函式 :

#include <type_traits>

template <typename T>
inline constexpr T &&forward(typename std::remove_reference<T>::type &value) noexcept {
    return static_cast<T &&>(value);
}
template <typename T>
inline constexpr T &&forward(typename std::remove_reference<T>::type &&value) noexcept {
    static_assert(!std::is_lvalue_reference<T>::value, "Cannot forward an rvalue as an lvalue");
    return static_cast<T &&>(value);
}

我說這個 forward 函式樣板和 C++ 標準樣板程式庫中提供的 std::forward 非常相似是因為它們的功能完全沒有區別, 只是名稱或者 static_assert 中的提示有些不同罷了. 我實作的 forward 函式既可以繫結到左值參考也可以繫結到右值參考上. 對於通用參考, 其參數的結果只有左值參考和右值參考. 當 T 被推斷為左值參考的時候, 會匹配到一個函式上. 於是, 第一個函式會具現化出這樣的實體 :

template <typename T>
inline constexpr T & &&forward<T &>(typename std::remove_reference<T>::type &value) noexcept {
    return static_cast<T & &&>(value);
}

通過參考折疊, 實體會發生這樣的變化 :

template <typename T>
inline constexpr T &forward<T &>(T &value) noexcept {
    return static_cast<T &>(value);
}

此時, forward 就和不存在一樣, 因為從一個左值轉型為左值沒有任何意義, 編碼器優化過後, 就和沒有呼叫過函式 forward 一樣. 但是對於右值參考, forward 的行為則完全不同 :

template <typename T>
inline constexpr T && &&forward<T &&>(typename std::remove_reference<T>::type &&value) noexcept {
    static_assert(!std::is_lvalue_reference<T && &&>::value, "Cannot forward an rvalue as an lvalue");
    return static_cast<T && &&>(value);
}

經過參考折疊, 就有了 :

template <typename T>
inline constexpr T &&forward<T &&>(T &&value) noexcept {
    static_assert(!std::is_lvalue_reference<T &&>::value, "Cannot forward an rvalue as an lvalue");
    return static_cast<T &&>(value);
}

於是, forward 的函式功能就變成了移動右值. 對於那個 static_assert, 這只是為了防止一些人手賤, 寫出這樣的程式碼 :

#include <type_traits>

template <typename T>
inline constexpr T &&forward(typename std::remove_reference<T>::type &value) noexcept {
    return static_cast<T &&>(value);
}
template <typename T>
inline constexpr T &&forward(typename std::remove_reference<T>::type &&value) noexcept {
    static_assert(!std::is_lvalue_reference<T>::value, "Cannot forward an rvalue as an lvalue");
    return static_cast<T &&>(value);
}
int main(int argc, char *argv[]) {
    forward<int &>(0);      //Error : Cannot forward an rvalue as an lvalue
}

但是 forward 在對待非通用參考的時候, 表現得和移動相似 :

#include <iostream>

using std::cout;
using std::endl;

template <typename T>
inline constexpr T &&forward(typename std::remove_reference<T>::type &value) noexcept {
    cout << "lvalue" << endl;
    return static_cast<T &&>(value);
}
template <typename T>
inline constexpr T &&forward(typename std::remove_reference<T>::type &&value) noexcept {
    cout << "rvalue" << endl;
    static_assert(!std::is_lvalue_reference<T>::value, "Cannot forward an rvalue as an lvalue");
    return static_cast<T &&>(value);
}
class Foo {
public:
    Foo() {}
    Foo(const Foo &) {
        cout << "copy" << endl;
    }
    Foo(Foo &&) {
        cout << "move" << endl;
    }
};
void func(Foo) {}
int main(int argc, char *argv[]) {
    Foo value;
    func(forward<Foo>(value));      //輸出 : lvalue + move
    func(forward<Foo>(Foo()));      //輸出 : rvalue + move
}

也就是說, 若且唯若當 forward 接受的是通用參考的時候, 它才表現出轉遞的特性; 當 forward 對待普通的參考的時候, 會表現出移動的特性

這也就是說, 我們可以使用 std::forward 替換 std::move 是嗎? 在語法層面, 這個說法確實沒有錯誤. 但是, 這會導致我們在閱讀程式碼的時候, 產生錯誤的理解. 因為在我們學習的時候, 所有人都告訴我們, std::move 用於物件的移動, std::forward 用於通用參考物件的完美轉遞. 當我們看到 forward 的時候, 就預設地認定它進行的是完美轉遞, 而非移動. 所以, 在移動的地方使用 std::move, 在轉遞的地方使用 std::forward, 才是語法上和理解上都沒有錯的方式. 另外, std::move 是無條件地轉型到右值進行移動, 但是 std::forward 是當非通用參考的時候才進行移動. 而且如果使用 std::forward 必須明確樣板引數, 而 std::move 不需要, move 要打的字也比 forward 要少. 不論我怎麼說, 該遵守的人一定會遵守, 而不遵守的人總會有自己的理由. 我還是想把說過的話再說一遍 : 在移動的時候使用 std::move, 只有在轉遞的時候才使用 std::forward

對於完美轉遞, 除了之前因為手賤寫出的程式碼之外, 絕大多數時候確實完美. 沒錯, 只是絕大多數時候, 有時候它也沒有我們想像中的那麼完美. 在某些情況之下, 它也存在失敗的情形. 對於完美轉遞失敗的定義, 並非編碼器擲出編碼錯誤那麼簡單

定義 : 給定轉遞函式 f_with_args :

#include <utility>

template <typename F, typename ...Ts>
constexpr inline void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}

對於某個函式 f 與一些引數 values, 若函式呼叫 f(values)f_with_args(f, values) 的結果不同, 則稱轉遞函式 f_with_args 完美轉遞失敗

1. 初始化列表

設函式 f 的宣告如下 :

#include <vector>

void f(const std::vector<int> &);

那麼函式呼叫 f({1, 2, 3}) 沒有任何問題, 但是函式呼叫 f_with_args(f, {1, 2, 3}) 則會產生編碼錯誤

2. 將 0 或者 NULL 用作空指標

設函式 f 的宣告如下 :

void f(void *);

那麼函式呼叫 f(0) 或者 f(NULL) 沒有任何問題, 但是函式呼叫 f_with_args(f, 0) 或者 f_with_args(f, NULL) 則會產生編碼錯誤

3. 類別內僅宣告的帶有 const 限定的整型型別靜態成員變數

設函式 f 的宣告如下 :

void f(int);

設有類別 Foo :

struct Foo {
    static const int i {0};
};

函式呼叫 f(Foo::i) 沒有任何問題, 但是函式呼叫 f_with_args(f, Foo::i) 會產生編碼錯誤

4. 多載函式

設函式 f 的宣告如下 :

void f(int (*)(int));       //等價於 void f(int (int));

設有多載函式 get_id :

int get_id(int);
int get_id(int, int);

函式呼叫 f(get_id) 沒有任何問題, 編碼器知道該如何選擇, 但是函式呼叫 f_with_args(f, get_id) 會產生編碼錯誤

5. 函式樣板

設函式 f 的宣告如下 :

void f(int (*)(int));

設有函式樣板 get_id :

template <typename T>
T get_id(T);

函式呼叫 f(get_id) 沒有任何問題, 但函式呼叫 f_with_args(f, get_id) 會產生編碼錯誤

6. 位元欄位

設函式 f 的宣告如下 :

void f(unsigned long);

設有一使用了位元欄位的類別 Foo (此處不妨設 sizeof(unsigned long) = 8) 及其物件 v:

struct Foo {
    unsigned long start : 4, info : 16, verify : 8, end : 4;
} v;

函式呼叫 f(v.info) 沒有任何問題, 但是函式呼叫 f_with_args(f, v.info) 則會產生編碼錯誤

上面六種情形幾乎涵蓋了所有完美轉遞失敗的情形, 為了導出這些完美轉遞失敗情形的解決方法, 我們首先要了解這些錯誤產生的原因 :

  1. 編碼器無法推導出轉遞函式所接受引數對應的型別
  2. 編碼器在推導轉遞函式所接受引數的時候, 給出了與預期型別不符的結果
  3. 轉遞函式的通用參考無法繫結到某一變數上, 或者在發生繫結的時候出現了 C++ 標準所規範的不允許的情形

對於初始化列表中發生的完美轉遞失敗的情形, 歸屬原因 1. 不過嚴格來說, 倒不是編碼器無法推導出引數的型別, 而是 C++ 標準規範了如果某一個函式樣板的某一個參數沒有被明確宣告為 std::initializer_list 型別, 那麼編碼器應該拒絕從從一個初始化列表中推導出 std::initializer_list 型別, 因此發生了編碼錯誤. 請注意, 此處是編碼器直接拒絕了型別推導, 而不是編碼器無法進行型別推導. 此處有一個繞行的手法. 既然從轉遞函式出發無法進行型別推導, 那麼我們從其它方面出發進行推導 :

#include <vector>
#include <utility>

void f(const std::vector<int> &);

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    auto init_list = {1, 2, 3};
    f_with_args(f, init_list);      //OK
}

對於將 0 或者 NULL 用作空指標中發生完美轉遞失敗的情形, 歸屬原因 2. 因為 0 會被編碼器推導為 int 型別, 而 NULL 的型別是實作定義行為, 不過必定是帶號數的整型型別 (一般是 long 或者 int). 因此, 從整型型別向 void * 型別直接發生隱含型別轉化是不允許的, 解決方案也很簡單 :

#include <utility>

void f(void *);

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    f_with_args(f, nullptr);        //OK, 推薦使用這種方法
    f_with_args(f, static_cast<void *>(0));     //OK
    f_with_args(f, static_cast<void *>(NULL));      //OK
}

對於類別內僅宣告的帶有 const 限定的整型型別靜態成員變數中發生完美轉遞失敗的情形, 歸屬原因 3. 對於整型型別的靜態成員變數, 一般來說, 我們都需要在類別之外定義它. 但是帶有 const 限定的有一些特殊, 如果它在類別內直接被指派了某個值, 那麼它是可以直接使用的, 而不需要在類別之外重新去定義. 但是, 編碼器並沒有為這個靜態成員變數開闢任何存儲空間, 而是直接當作字面值來傳遞, 也就是說, 在使用上, 它們和列舉是等價的. 然而, 這些靜態成員變數在傳遞過程中雖然是按照字面值進行傳遞, 但是它們的形式是一個變數, 因此轉遞函式對其推導結果是左值參考, 從而導致了這些靜態變數必須有屬於自己的存儲空間, 否則無法繫結到左值參考上 (列舉就無法繫結到任何左值參考上). 解決的方法不言而喻, 就是為其在類別之外定義它. 另外, 為這些靜態成員變數標識 constexpr 也可以解決問題 :

#include <utility>

void f(int);

struct Foo {
    constexpr static auto i {0};
};

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    f_with_args(f, Foo::i);     //OK
}

在 C++ 17 之後, 使用 inline 限制語義也可以解決這個問題 :

#include <utility>

void f(int) {}

struct Foo {
    static inline const auto i {0};
};

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    f_with_args(f, Foo::i);     //OK
}

inline 的用法詳見文章《《Effective C++》讀後感 – 從自己的角度講一講《Effective C++》》條款 30

對於多載函式中出現的完美轉遞失敗的情形, 歸屬原因 1. 這是由於編碼器在選擇的時候, 存在多個型別可以選擇, 編碼器並不知道如何選擇, 因為對於編碼器自己來說, 編碼通過或者產生編碼錯誤都可能是用戶想要的選擇. 於是, 編碼器乾脆兩手一攤, 不作選擇. 解決方法就是明確型別 :

#include <utility>

void f(int (*)(int));

int get_id(int);
int get_id(int, int);

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    int (*fp)(int) {get_id};
    f_with_args(f, fp);     //OK
}

或者乾脆借助 static_cast 明確型別 :

#include <utility>

void f(int (*)(int));

int get_id(int);
int get_id(int, int);

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    f_with_args(f, static_cast<int (*)(int)>(get_id));     //OK
}

要注意的是, 這裡 static_cast 什麼也沒幹, 只是幫助編碼器確定了型別而已, 就像 static_cast<int>(0) 那樣

對於函式樣板中出現完美轉遞失敗的情形, 原因和解決方案和函式多載一樣. 不過, 對於函式樣板, 還有另外一個解決方案, 就是手動對樣板進行具現化 :

#include <utility>

void f(int (*)(int));

template <typename T>
T get_id(T);

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    f_with_args(f, get_id<int>);     //OK
}

對於位元欄位中出現完美轉遞失敗的情形, 歸屬原因 3. C++ 標準規範了不帶有 const 限定的左值參考不得繫結到位元欄位上. 因為 C++ 並沒有位元的指標或者位元的參考. 解決方法就是將傳遞過去的引數設為不可更改 :

#include <utility>

void f(unsigned long);

struct Foo {
    unsigned long start : 4, info : 16, verify : 8, end : 4;
} v;

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    const Foo &cv {v};
    f_with_args(f, cv.info);     //OK
}

或者借助轉型產生右值副件, 避免原來的變數被更改 :

#include <utility>

void f(unsigned long);

struct Foo {
    unsigned long start : 4, info : 16, verify : 8, end : 4;
} v;

template <typename F, typename ...Ts>
inline constexpr void f_with_args(F f, Ts &&...args) {
    f(std::forward<Ts>(args)...);
}
int main(int argc, char *argv[]) {
    f_with_args(f, static_cast<unsigned long>(v.info));     //OK
    f_with_args(f, std::move(v).info);      //OK
}

不過, 使用移動的方法移動物件 v 之後, 可能導致原來的變數不可再被使用