摘要訊息 : C++ 的移動, 通用參考和完美轉遞有哪些需要注意的地方?
0. 前言
C++ 11 引入的移動語意或許是 C++ 11 最成功的特性, 它使得 C++ 有能力在處理類別的時候和處理內建型別那樣簡便, 極大地提升了運作效率. 這篇文章需要割下熟悉 C++ 的移動語意, 通用參考和完美轉遞, 否則可以先閱讀《C++ 學習筆記》.
更新紀錄 :
- 2022 年 5 月 17 日進行第一次更新和修正.
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
需要的是型別而不是一個樣板, 所以 T
永遠不會是 Foo
. 那麼在需要的情況下, 編碼器仍然為 Foo
生成複製建構子, 複製指派運算子, 移動建構子, 移動指派運算子還有解構子.
有些程式設計師可能會對下列程式碼感到不適 :
#include <string>
std::string f(const std::string &str) {
std::string s;
//...
return s;
}
讓他們感受到不適的原因就在於 C++ 11 引入了移動語義, 所以應該把 return
陳述式改為 return std::move(s);
. 這個行為是幾乎錯誤的, 目前比較好的 IDE 也沒有對這些行為給出一些警告, 編碼器也不會預設地對這樣的行為給出警告. 這是因為自 C++ 98 標準發布以來, C++ 中就存在著回傳值優化 (return value optimization, 簡稱 RVO), 這是由編碼器來完成的. 要想一個回傳值發生 RVO, 必須滿足下面兩個條件 :
- 局域變數的型別於函式的回傳型別完全相同;
- 回傳的是局域變數本身, 而非其參考或者其它.
那麼也就是說, 任何遵循 C++ 標準的編碼器都會對函式 f
回傳的 std::string
物件進行優化, 使得其在回傳時無須進行複製. 這是因為編碼器直接把回傳值放到了回傳值對應的存儲空間中, 然後針對這個存儲空間中的值, 運作函式 f
剩餘的陳述式. 如果不進行優化的話, 後果就是在某一個存儲空間中建構一個 std::string
物件, 當函式運作終結的時候, 從這個存儲空間中複製 std::string
物件到回傳值對應的存儲空間中. 因此經過 "優化" 的函式 f
想法是好的, 但是它阻止了 RVO. 因為經過移動之後, 局域變數 s
的型別從 std::string
變成了 std::string &&
, 這和回傳型別並不相符, 不符合 RVO 發生的條件, 結果適得其反 : 編碼器無法直接將局域變數 s
放在回傳值對應的存儲空間中, 只能另外為其開闢一段存儲空間, 只不過最後使用的是移動將 s
移動到了回傳值對應的存儲空間中罷了. 這樣, 不但多了一次移動, 還多了一次解構.
對於 RVO 如何進行優化, 可以參考文章《《Effective C++》讀後感 —— 從自己的角度講一講《Effective C++》》中第 21 節.
但是 C++ 的程式碼是千變萬化的, 你可能會寫出下面這段程式碼反駁我的言論, 你認為下面這段程式碼 RVO 很難發生 :
#include <string>
std::string f(int id) {
std::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 的前提被滿足的時候, 編碼器只有兩種選擇 :
- 對回傳值進行優化, 避免不必要的複製和解構操作發生;
- 隱含地為用戶將回傳值轉型為右值, 相當於編碼器為我們隱含地進行了移動.
在 Code 2 中, 如果局域變數 s
的型別是 std::string &
而不是 std::string
, 那麼 RVO 的條件就不能被滿足. 這個時候, 才需要我們手動地在 return
陳述式進行移動.
2. 通用參考
C++ 對於通用參考的限定非常嚴格, 要想成為通用參考, 必須是以 T &&
的形式出現, 而且 T
必須由編碼器推導而來. 要注意的是, std::vector<T> &&
這樣的型別並不是 T &&
的形式, 所以它不是通用參考. 除了使用樣板產生通用參考之外, auto &&
, decltype(v) &&
, using
宣告和 typedef
宣告都可能產生通用參考. 其中, decltype(v)
中的 v
必須是未知型別變數, 否則它將產生固定的型別, 這不會導致 decltype(v) &&
產生通用參考.
template <typename T>
void f(T &&t) {
decltype(t) &&v1 {t};
auto &&v2 {t};
typedef T && type;
}
template <typename T>
using type = T &&;
有一個不太準確的斷言 : 可以發生參考折疊的即是通用參考. 這裡要注意, 發生參考折疊的型別必須是未知的, 如果是像 int && &
折疊到 int &
, 由於 int
是確定型別, 所以這裡面不存在通用參考.
通用參考雖然非常好用, 但是我們如果誤用的話, 也會導致程式的行為和我們預期的不太一致. 若某個無回傳值的函式 f
接受任何型別的引數, 那麼我們通常會使用通用參考來實作 : template <typename T> void f(T &&);
. 如果我們有希望函式 f
對內建的有號數整型型別有不同的行為, 那麼有些人會一步到位, 寫出這樣一個多載 : void f(long long);
. 他們希望任意一個有號數整型型別由編碼器自動地提升到 long long
型別, 從而匹配到函式 void f(long long);
上. 但是這個想法在存在通用參考的多載函式的情況下, 絕對錯誤. 考慮有一個 int
型別的變數 i
, 用它來呼叫函式 f
: f(i)
. 它會使得通用參考的多載函式版本具現化出這樣的實體 : template <> void f<int>(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>(Foo &); 從而匹配到具現化出的那個實體上, 而並非使用複製建構子
const Foo f3;
Foo f4(f3); // 使用複製建構子
}
為了解決這樣的困擾, 最容易想到的就是放棄函式多載或者乾脆使用 C++ 98 式的方法, 只留下預設建構子 Foo::Foo()
, 剩下的改為 template <typename T> Foo::Foo(const T &);
. 當然, 也可以乾脆直接傳遞值, 不過有時候, 直接傳值而導致的複製代價太高.
現在讓我們回到函式 f
的問題上. 我們要解決的是對於整形型別應該匹配到一個專門的多載函式 f
上, 而不是匹配到通用參考上. 為此, 可以利用委託的形式來實作 :
#include <type_traits>
#include <utility>
template <typename T>
void f_aux(T &&, std::true_type);
template <typename T>
void f_aux(T &&, std::false_type);
template <typename T>
void f(T &&value) {
f_aux(std::forward<T>(value), std::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
來移除參考限定, 因此正確的實作應該是把 std::is_integral
中的樣板引數改為 std::is_integral<std::remove_reference_t<T>>
.
還有一種方法是利用 SFINAE (參考《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》和《【C++ Template Meta-Programming】SFINAE》) :
#include <type_traits>
template <typename T, typename = std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>>
void f(T &&);
template <typename T>
void f(T &&);
使用 SFINAE 可以同樣可以解決 Code 4 中類別 Foo
遇到的問題 :
#include <type_traits>
class Foo {
public:
template <typename T, typename = std::enable_if_t<std::is_same_v<Foo, std::decay_t<T>>>>
Foo(T &&);
//...
};
在 Code 5-2 和 Code 6 中, 我統一省略了 std::enable_if
的第二個樣板引數, 因為它被預設為 void
. 這個解決方法考慮地不夠全面, 接下來我們講詳細討論關於類別 Foo
建構子的正確實作方法. 你可能覺得不可思議, 因為我們這裡使用了 std::decay
, 而 std::decay
會將所有遇到的 const
, volatile
以及參考限定通通去掉, 比 std::remove_reference
還要狠. 然而考慮下面這種情形 :
#include <type_traits>
class Foo {
public:
template <typename T, typename = std::enable_if_t<std::is_same_v<Foo, std::decay_t<T>>>>
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
, 因為任何一個變數都可以認為從自己衍生. 因此, 我們應該把 std::enable_if 的第一個樣板引數更換為 std::is_base_of<Foo, std::decay_t<T>>
. 這樣就結束了嗎? 太天真. 為了防止任何引數都可以向 Foo
型別轉型, 我們還要為 Foo
型別中帶有通用參考的建構子加上 explicit
限定才算完. 當然, 如果你有著這樣的需求, 那麼可以不需要加上 explicit
限定.
綜上所述, 我們得出結論 : 通用參考在性能方面確實具備一定的優勢, 但是在易用性方面有著一定的劣勢. 但是易用性並不只是體現在上面那些實例中, 有時候還體現在編碼錯誤的信息中 :
#include <string>
template <typename T>
void f(T &&t) {
std::string s(t);
//...
}
int main(int argc, char *argv[]) {
f(L"Hello"); // Error. std::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 <string>
#include <type_traits>
template <typename T>
void f(T &&t) {
static_assert(std::is_constructible_v<std::string, T>, "Cannot use T to construct string");
std::string s(t);
//...
}
std::is_constructible
同樣來自標頭檔 <type_traits>
, 它用來檢查給定的型別或者一系列型別 (它可以接受任意多個樣板引數) 能否建構第一個型別. 此時就會提示我們 "Cannot use T to construct string"
. 但是我們自己給定的信息並不一定出現在最前面, 有時候會出現在中間或者最後面. 比如函式 f
最開始呼叫了另外一個函式 f2
, 它裡面也使用引數 t
去建構一個 string
, 這樣即使我們寫了 static_assert
, 但是給出的信息並不是在最前面, 可能的情況是出現在中間. 而且這種方法也比較麻煩, 我們幾乎要為每一個可能的函式都增加這樣一個提示.
本節最後我們來討論一下四種可能會發生參考折疊情形的不同之處. 實際上, 不同之處只有一個, 如果通用參考位於函式參數當中, 在其被推導為右值參考的時候, 會去掉右值參考限定; 但是對於使用 auto
, 型別別名和 decltype
獲得通用參考, 在進行型別推導的時候, 右值參考會保留下右值參考限定 :
#include <type_traits>
#include <utility>
#include <iostream>
template <typename T>
void f(T &&t) {
using remove_ref_t = typename std::remove_reference<T>::type;
auto &&value {forward<T>(t)}; // 改為 decltype(t) && 也有一樣的效果
std::cout << std::is_same_v<T, remove_ref_t>;
std::cout << std::is_same_v<T, remove_ref_t &>;
std::cout << std::is_same_v<T, remove_ref_t &&>;
std::cout << std::is_same_v<decltype(value), remove_ref_t>;
std::cout << std::is_same_v<decltype(value), remove_ref_t &>;
std::cout << std::is_same_v<decltype(value), remove_ref_t &&> << endl;
}
int main(int argc, char *argv[]) {
int a {0};
int &&b {0};
f(a); // 010010
f(b); // 010010
f(std::move(b)); // 100001
f(0); // 100001
}
3. 完美轉遞
一說到完美轉遞, 大家就可以立馬想起通用參考. 除了通用參考之外, 完美轉遞還需要 std::forward
的配合. 為了更加深刻地理解 std::forward
, 我們需要知道 std::forward
到底是如何工作的, 它和 std::move
有什麼不同. 現在, 我寫出一個和 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
函式既可以繫結到左值參考也可以繫結到右值參考上. 對於通用參考, 使用 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
, 這只是為了防止類似於 forward<int &>(0);
這樣錯誤的程式碼.
但是 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
.
對於完美轉遞, 除了像 std::forward<int &>(0)
這樣的程式碼之外, 絕大多數時候確實完美. 沒錯, 只是絕大多數時候, 有時候它也沒有我們想像中的那麼完美. 在某些情況之下, 它也存在失敗的情形. 對於完美轉遞失敗的定義, 並非編碼器擲出編碼錯誤那麼簡單 :
#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
完美轉遞失敗. 導致完美轉遞失敗的情況可能產生於 :
- 初始化列表 : 設函式為
void f(const std::vector<int> &);
, 函式呼叫f({1, 2, 3})
沒有任何問題, 但是函式呼叫f_with_args(f, {1, 2, 3})
則會產生編碼錯誤; - 將
0
或者NULL
用作空指標 : 設函式為void f(int *);
, 函式呼叫f(0)
或者f(NULL)
沒有任何問題, 但是函式呼叫f_with_args(f, 0)
或者f_with_args(f, NULL)
則會產生編碼錯誤; - 類別內僅宣告的帶有
const
標識的整型型別靜態成員變數作為函式引數 : 設函式為void f(int);
, 設類別A
中存在一個宣告static const int i {0};
,i
沒有在類別之外被定義, 函式呼叫f(Foo::i)
沒有任何問題, 但是函式呼叫f_with_args(f, Foo::i)
會產生編碼錯誤; - 多載函式 : 設函式為
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)
會產生編碼錯誤; - 函式樣板 : 設有函式
void f(int (*)(int));
和函式樣板template <typename T> T get_id(T);
, 函式呼叫f(get_id)
沒有任何問題, 但函式呼叫f_with_args(f, get_id)
會產生編碼錯誤; - 位元欄位 : 設函式為
void f(unsigned long);
, 設類別A
中存在一個位元欄位宣告unsigned long start : 4;
, 函式呼叫f(v.info)
沒有任何問題, 但是函式呼叫f_with_args(f, v.info)
則會產生編碼錯誤.
上面六個錯誤情況幾乎涵蓋了所有完美轉遞失敗的情形, 為了導出這些完美轉遞失敗情形的解決方法, 我們首先要了解這些錯誤產生的原因 :
- 編碼器無法推導出轉遞函式所接受引數對應的型別;
- 編碼器在推導轉遞函式所接受引數的時候, 給出了與預期型別不符的結果;
- 轉遞函式的通用參考無法繫結到某一變數上, 或者在發生繫結的時候出現了 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
限制語義也可以解決這個問題 (參考《【C++】另一種 inline》). 這個時候, 我們要把 Foo
中 i
的宣告改為 constexpr inline static auto i {0};
.
對於多載函式中出現的完美轉遞失敗的情形, 歸屬原因 1. 這是由於編碼器在選擇的時候, 存在多個型別可以選擇, 編碼器並不知道如何選擇, 因為對於編碼器自己來說, 編碼通過或者產生編碼錯誤都可能是用戶想要的選擇. 於是, 編碼器乾脆兩手一攤, 不作選擇. 解決方法就是明確型別或者借助 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[]) {
int (*fp)(int) {get_id};
f_with_args(f, fp); // OK
f_with_args(f, static_cast<int (*)(int)>(get_id)); // OK, 這裡 static_cast 什麼也沒幹, 只是幫助編碼器確定了型別而已
}
對於函式樣板中出現完美轉遞失敗的情形, 原因和解決方案和函式多載一樣. 不過, 對於函式樣板, 還有另外一個解決方案, 就是手動對樣板進行具現化 :
#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
f_with_args(f, static_cast<unsigned long>(v.info)); // OK
f_with_args(f, std::move(v).info); // OK, 使用移動的方法移動物件 v 之後, 可能導致原來的變數不可再被使用
}
自創文章, 原著 : Jonny. 如若閣下需要轉發, 在已經授權的情況下請註明本文出處 :