Perfect Forwarding 轉發什麼東西?

原文转自:https://medium.com/@tjsw/%E6%BD%AE-c-11-perfect-forwarding-%E5%AE%8C%E7%BE%8E%E8%BD%89%E7%99%BC%E4%BD%A0%E7%9A%84%E9%9C%80%E6%B1%82-%E6%B7%B1%E5%BA%A6%E8%A7%A3%E6%9E%90-f991830bcd84

Perfect Forwarding 完美轉發和 std::forward,C++ 11 最常和 rvalue reference 右值引用一起提到的東西,那麼今天就來說說 Perfect Forwarding 揪竟用在哪,該怎麼用,並且有多麼的完美。


Perfect Forwarding 轉發什麼東西?

有一定英文水平的同學都知道 Perfect 就是完美,不用多汙辱大家智商。但 Forwarding:「轉發」這個詞在 C++ 裡面什麼意思?

先繞開一下賣個關子:「轉發」在我們的日常生活通常是這樣的:

媽媽:小明!叫妹妹給我滾下樓吃晚餐啦!

主管:Ming,幫忙跟測試部門的小艾請他務必確認人力資源和時程是不是對的起來,對不起來就是你們其中之一的問題囉 ^.^

市長:這部份我們請新聞局局長回答。

在生活中,我們「轉發」的就是「需求 / 請求」,或是請別人轉發我們的需求。並且轉發地越完整越接近原意當然是越好,也就是「完美」。

那在寫 C++ 中開發者對 code 程式碼的「需求 / 請求」到底是什麼?當然是叫 code 幫我們做事情啦!但什麼情況會需要把「code 做事情」這件事又轉給別的 code 做呢?

就是函式呼叫 (function call)。

在 C++ 裡面,呼叫函式的場合可說千奇百怪,能傳的參數型別也是五花八門,就先從我們童年回憶,寫函式取兩個數字的較大值開始帶同學們理解 perfect forwarding。

找最大的數

小時候老師會讓同學寫一個找兩個數比較大的的函式 MyMax(a, b) 讓大家練習 if else,但這邊再講找兩個數最大值太遜了。所以我們要講找三個數的最大值。

我們這邊想寫一個泛型 (generic) 的函式 MyMax3(a, b, c),利用傳參照/引用 (reference) 的方式把三個變數找一下最大值。

我們把這個 MyMax3(a, b, c) 分解一下邏輯:

  1. 先對 a, b 取較大值,設為 m。也就是呼叫 MyMax(a, b)
  2. 再對 m, c 取較大值,就是答案。也就是呼叫 MyMax(m, c)

這樣看起來,我們不就是把找最大值的需求轉發給其它函式了嗎?整個泛型的 MyMax3 和 MyMax 寫起來很簡單,就是寫個樣版吃 reference 參數。

參數都吃 reference 的原因是因為身為泛型的函式,考量可能傳入的型別是一大坨物件,copy 操作會很貴這樣。

我們把 result 是一個引用這回事,從 MyMax3 轉發進去 MyMax 了,因此 main() 裡的 result 變數的引用就一路被「轉發」下去,維持我們想要更動 result 變數的這個「需求」。

很快地同學們會發現這樣的函式很沒用,因為下面這些情況編譯器都會報錯

編譯器會跟你抱怨說:他想要吃的都是 l-value reference 左值引用。但你卻傳入了 3, 11, 1 這些常量 (literal),也就是 r-value 右值。相信有一點經驗的同學知道這時候有兩種解法:

  1. 把 MyMax3 和 MyMax 函式的 abc 參數從 T& 改為 const T& 變成可以綁定暫時變數的引用。
  2. 或是改為 T&&, U&&, V&&直接變成三個 universal reference ,各自可以接左值也可以接右值。

不太懂左值右值引用,或 universal reference 的同學可以先看看這篇 move semantics, move constructor, rvalue reference 的講解再來往下看。

採用第二種解法,我們的程式碼會變成下面這樣,我為每個 MyMax() 的結果 result 多掛上一個樣版參數 R,畢竟 result 一定是左值引用。

採用 universal reference 的解法

上面程式碼中的 #1 和 #2 這兩行,我們在 MyMax3 中保留了 result 是左值引用的語義轉發給 MyMax(a, b, result)

那麼問題來了,在這兩行呼叫中的 abc 呢?他們依然有保留著原本在 MyMax3 中是左(右)值,在 MyMax 中依然是左(右)值嗎?你各位一定已經發現事有蹊蹺,因為不論他們在 MyMax3() 中是左值引用還是右值引用,他們在 MyMax() 裡面推導出來的型別都是左值引用。這裡就不展開解釋為什麼都是左值了,先賣個關子。重點是:我們失去了完美轉發的語義了,原本是右值的東西,被我們轉手呼叫之後就變成左值了。

當然同學們可能想說「就算都變成左值引用了,並不妨礙我找他們最大值的邏操作啊!」在這個找最大值的例子中可能沒什麼用,但在其它呼叫函式的場合,這關係可非常非常地大。

物件工廠 Factory Pattern

C++ 裡另個會在函式內又呼叫別的函式的情境大概是 factory pattern 工廠模式了,只是這個轉發呼叫的函式是類別的 constructor 建構子。

為了講解方便,我們假設我們的工廠只生成一種類別 Fish。這個 Fish 類的 ctor 的需要吃一個 FishData 類當作是這隻魚的基本資料來初始化。而這個 FishData 類因為內含了很多肥厚的資料,所以 Fish 除了有一般的 copy conversion constructor 以外,還實作了一個 move conversion constructor,吃的就是 FishData 的右值引用,想利用 move semantics 移動語義更快速地初始化。

而我們的工廠模式就發生在 MakeFish 這個樣版函式中,和上面的找最大值範例一樣,為了能吃各式各樣的 FishData 左值右值,參數 fd 宣告成 universal reference。然後把 fd 丟進去給 Fish 的 constructor,也是一種「轉發」,轉發了我們想用 fd 初始化 Fish 物件的「需求」。

如上程式碼標註的:三次的 MakeFish 呼叫,#1, #2, #3 傳入的 FishData 分別是左值,右值,右值,而我們在 Fish 的 ctor 裡印出執行了哪個 ctor 來觀察轉發的情況。結果我們發現印出的會是:

fish copy conversion ctor
fish copy conversion ctor
fish copy conversion ctor

如果我們的工廠 MakeFish 真是完美轉發,保留了 FishData 的左派右派調調,#2 和 #3 應該要呼叫 move conversion ctor 才對,顯然我們又失敗了。我們的 MakeFish 並不完美,他把左右值都轉成左值轉發了…。

這就茲事體大了,和找最大值不一樣,畢竟可能用更高效的 move conversion 初始化,卻被搞成 copy conversion 複製厚重的 FishData 資源。怎麼辦?

完美轉發的小工具 std::forward

和移動語義 (move semantics) 一樣,我們需要一個小語法來告訴編譯器下面這件事:

template<typename T>
void MakeFish(T&& fd)
{
return Fish{ 幫我完美轉發 (fd) 給 ctor };
}

而這個語法就是 C++11 在 <utility> 裡面的小工具函式 std::forward。所以正確的用法應該是

template<typename T>
void MakeFish(T&& fd)
{
return Fish{ std::forward<T>(fd) };
}

前面講了那麼一大串例子,其實真正要做到完美轉發 perfect forwarding 的目的,就只要這樣呼叫一個小函式就達成目標了。寫代碼就短短的很簡單,但是精神在於讓同學們了解為什麼需要完美轉發,還有完美轉發的意涵。

std::forward 是一個樣版函式,需要自己手動指定要轉發的參數型別,通常的使用情境就像上面那樣,參數原本的樣版型別是什麼就照填。咦!好奇的同學提問了:樣版函式不是可以依照參數直接推導型別嗎?為何要我們手動填呢?這個我們晚點談 :)

有了上面的 std::forward 之後,上面的 factory 例子輸出就應該變成

fish copy conversion ctor
fish move conversion ctor
fish move conversion ctor

皆大歡喜,皆大歡喜,終於達到了完美轉發的目的了,恭喜同學們又學會一項 C++11 的技術了。

這邊解釋一下:行為上,std::forward<T>(t) 會吐給我們的是

  1. 如果參數 t 是左值引用 (T 推導成 Fish&),那吐出來的就是 Fish& 左值引用。
  2. 如果參數 t 是右值引用 (T 推導成 Fish),那吐出來的就是 Fish&&右值引用。

這就是我們上面講了這麼久的完美轉發了 lol。

如果工廠的參數很多? (加上 variadic template 的用法)

上面的工廠太簡單啦,就只有一個參數,如果我們今天想要一個更通用泛用的工廠,勢必工廠的函式參數也不一定,這個時候就可以借助 C++11 的 variadic template 還有 parameter pack 來幫助我們一次完美轉發一大包參數給別人了!

最常見的用法可能是做出一隻 Fish 的 std::shared_ptr。(Variadic Template 和 Parameter pack 的講解詳見這裡。)

template<typename... Args>
shared_ptr<Fish> MakeFishPtr(FishType type, Args&&... args)
{
if (type == FishType::shark)
return make_shared<Shark>( forward<Args>(args)... );

else if (type == FishType::salmon)
return make_shared<Salmon>( forward<Args>(args)... );

return nullptr;
}

轉發成員函式呼叫 (member function call) 的表達式

上面所有的範例都在和同學們說怎麼轉發一個變數 / 參數。但今天一堆變數的運算表達式,或是變數的成員函式呼叫也有左值右值之分。同樣地,我們也可以把這樣的左值右值通過 std::forward 完美地轉發出去。

今天我們的 FishData 長這樣子:對於用左值的物件呼叫成員函式 Name() 以及對於用右值的物件呼叫成員函式 Name() 有不一樣的輸出。

而我們的 Fish 類別變成建構子吃左值和右值的字串,同樣印出自己是 copy conversion ctor 還是 move conversion。

我們的魚工廠一樣吃一個 FishData 的 universal reference,以方便我們隨意丟左值或右值的 FishData,但轉發給 Fish ctor 則是呼叫 Name() 的結果。根據前面的說明,我們呼叫 std::forward<T>(t) 給定的樣版參數 T 應該要是後面那也就是 t 表達式的型別?所以我們借用了 decltype 來得到表達式的型別,並且塞給 std::forward 轉發給 Fish 的 constructor。

先講結論:這樣寫是錯的,搭配上面的 FishDataFish你會得到兩行

copy conversion: Left Fish
copy conversion: Left Fish

那正確應該要是什麼?

  • 首先,MakeFish() 在轉發 fd.Name() 時,應該要保留 fd 左值右值引用的語義,所以第二行應該印出 Right Fish 而不是 Left Fish。
    而更深層的原因是因為當我們寫下 fd 這兩個字的瞬間,他就是左值了 (named value),fd 這變數的左右值和他的型別是無關的。
  • 再來,因為如果真是右值的 FishData 物件,那呼叫成員函式 Name() 時吐出的 string 是個右值,所以丟給 Fish 的 constructor 應該對應到的是 move conversion 而不是 copy。

那我們要怎麼保留 fd 的左右調性?一樣,完美轉發。就是用 std::forward 保留起來:std::forward<T>(fd)

所以我們要呼叫的不是 fd.Name(),而是 std::forward<T>(fd).Name()。所以給 std::forward<T> 的 T 應該要填成 decltype(forward<T>(fd).Name())

努力到最後一步我們拼裝起來,我們的 MakeFish() 就變成

之後我們的程式就可以順利印出上面我們分析的結果了,普天同慶可喜可賀!

copy conversion: Left Fish
move conversion: Right Fish

利用 decltype 和 declval 讓程式短一點

同學可能覺得同樣的字寫多了有點醜,主要是因為 decltype 裡面和 std::forward 的參數重複了。我們觀察到其實 decltype 求出的只是型別,而不是真的呼叫了 fd::Name() 得到的字串是什麼內容。所以那個 decltype 裡面的 fd 是誰不重要,只是需要一個 FishData 物件 (對應 fd 的左值右值) 在那邊就可以了。

C++ 為這件事提供一個小幫手 helper function,std::declval<T>()。讓我們不需要真的呼叫 FishData 的 constructor 做出物件,就可以呼叫 FishData 的成員函式。

使用 declval 變短了一點點的代碼

備註一下,std::declval<T>(),在 fd 是左值引用 (T = FishData&) 時,做出的是 FishData&。在 fd 是右值引用 (T = FishData) 時,做出的則是 FishData&&。所以 MakeFish() 傳入左(右)值引用的 fd ,通過 declval<T> 假物件呼叫的就真的是左(右)值應該呼叫的成員函式。

小結一下

由於 C++11 開始導入的右值引用和 move ctor,導致了需要完美轉發的機制才能確保正確的語義被使用,也就是移動語義 / 複製語義能順利不被扭曲地轉傳。而 C++11 也考量了這個需求,因此在標準庫裡面直接實作了好用的 std::forward 幫助大家完成需求,同學熟練之後就會發現這個簡單的小函式真的是越用越順手很強壯很好用。

猜你喜欢

转载自www.cnblogs.com/dklig/p/12688861.html