Effective Modern C++ 條款30 熟悉完美轉發失敗的情況

熟悉完美轉發失敗的情況

完美轉發是C++11箱子裏最令人注目的特性之一,完美轉發,它是完美的!額,打開箱子後,然後你會發現理想中的“完美”和現實中的“完美”有點出入。C++11的完美轉發非常棒,但當且僅當你願意忽略一兩個特殊情況,才能真正得到完美。該條款就是爲了讓你熟悉那些特殊情況。

在着手探索我們的特殊情況之前,值得回顧一下“完美轉發”的意思。“轉發”的意思是一個函數傳遞——轉發——它的參數到另一個函數,目的是爲了讓第二個函數(被轉發參數的函數)收到第一個函數(進行轉發的函數)接受到的對象。這個規則排除值語義的形參,因爲它們只是拷貝原始調用者傳遞的參數,我們想要的是,接受轉發參數的函數能夠處理最開始傳遞進來的對象。指針形參也排除在外,因爲我們不想強迫調用者傳遞指針。通常,當發生有目的的轉發時,我們都是處理形參爲引用的參數。

完美轉發意思是:我們不單單轉發對象,我們還轉發它們重要的特性:它們的類型,它們是右值還是左值,它們是否是const或者volation修飾的。配合我們觀察到的我們一般處理引用參數,這暗示着我們會把完美轉發用到通用引用(看條款24)上,因爲只有通用引用形參纔會把傳遞給它們的實參的左值或右值信息進行編碼。

假設我們有一些函數f,然後我們寫了個函數(事實上是模板函數),這個函數把參數轉發到函數f。我們需要的關鍵代碼是這樣的:

template<typename T>
void fwd(T&& param)          // 接受參數
{
    f(std::forward<T>(param));      // 轉發到f
}

按照本性,進行轉發的函數是通用的。例如,fwd模板,應該可以接受一些類型不同的參數,然後轉發它所得到的。對於這種通用性的一種有邏輯的擴展是,fwd不應該只是個模板,而是個可變參數模板,因此可以接受任意數目的參數,可變參數模板fwd看起來應該是這樣的:

template<typename... Ts>
void fwd(T&& ...params)   //  接受一些參數
{
    f(std::forward<Ts>(param)...);    // 把它們轉發到f
}

這種形式的模板你能在其它地方見到,例如標準庫容器的emplace函數(看條款42),和智能指針工廠函數——std::make_sharedstd::make_unique(看條款21)。

給定我們的目標函數f和進行轉發的函數fwd,如果用某個實參調用f會做某件事,然後用同樣的實參調用fwd,但fwd裏的f行爲和前面那種情況不一樣,那麼完美轉發是失敗的:

f( expression );       // 如果f做某件事
fwd(expression);       // 而fwd裏的f做的是不同的事,那麼fwd完美轉發expression失敗

幾種類型的實參會導致這種失敗,知道它們是什麼和如何工作是很重要的,讓我們來觀察這幾種不能完美轉發的類型吧。


大括號初始值

假如f的聲明是這樣的:

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

這種情況,用大括號初始值調用f是可以通過編譯的:

f({1, 2, 3});   // 正確,“{1,2,3}”被隱式轉換爲std::vector<int>

但如果把大括號初始值傳遞給fwd就不能通過編譯:

fwd({1,2,3})// 錯誤,不能編譯

那是因爲使用大括號初始值會讓完美轉發失敗。

這種失敗的原因都是相同的。在直接調用f時(例如f({1,2,3})),編譯器觀察調用端的實參,又觀察f函數聲明的參數類型,然後編譯器對比這兩個類型,如果是相互兼容的,然後,如果是必要的話,編譯器會把類型進行隱式轉發,使得調用成功。在上面的例子中,編譯器從{1,2,3}生成一個臨時的std::vector<int>對象,因此f的參數v可綁定到一個std::vector<int>對象。

當使用轉發模板fwd間接調用f時,編譯器不會比較調用端傳遞給fwd的實參和f聲明的形參,取而代之的是,編譯器推斷傳遞給fwd實參的類型,然後比較推斷的類型和f聲明的形參類型。當下面某種情況發生時,完美轉發會失敗:

  • 編譯器不能推斷出傳遞給fwd參數的類型,在這種情況下,代碼編譯失敗。
  • 編譯器爲傳遞給fwd參數推斷出“錯誤的”類型。這裏的“錯誤的”,可以表示實例化的fwd不能編譯推斷出來的類型,還可以表示使用fwd推斷的類型調用f的行爲與直接使用傳遞給fwd的參數調用f的行爲不一致。這種不一致行爲的一種源頭是f是個重載函數的名字,然後,根據“不正確的”推斷類型,fwd裏的f重載調用與直接調用f的行爲不一樣。

在上面“fwd({1,2,3})”的例子中,問題在於,給沒有聲明爲std::initialist_list類型的模板參數傳遞大括號初始值,正如標準庫所說,這是“non-deduced context”。通俗易懂的說,那意味着在fwd的調用中,編譯器禁止從表達式{1,2,3}推斷出一個類型,因爲fwd的形參不是聲明爲std::initializer_list。因爲那是被禁止推斷,所以編譯器拒絕這樣調用。

有趣的是,條款2解釋過auto變量在用大括號初始值初始化時會進行類型推斷。這些變量被視爲是std::initializer_list對象,然後對於進行轉發的函數的形參應該被推斷爲std::initializer_list的場合,提供了一個簡單的應對辦法——用auto聲明一個局部變量,然後把局部變量傳遞給進行轉發的函數:

auto il = {1, 2, 3};               // il的類型被推斷爲std::initializer_list<int>

fwd(il);           // 正確,把il轉發給f

0和NULL作爲空指針

條款8解釋過,當你嘗試把0和NULL作爲空指針傳遞給一個模板,類型推斷就會出錯,編譯器會把你傳入的參數推斷爲整型數類型(通常是int),而不是指針類型。這就導致了0和NULL都不可以作爲空指針被完美轉發,不過,解決辦法也很容易:用nullptr代替0或NULL。關於細節,請看條款8。


只聲明的static const成員變量

作爲一個通用的規則:不需要在類中定義static const成員變量;聲明它就行了。那是因爲編譯器會爲這些成員變量的值進行const propagation(常數傳播),因此不需要爲這些變量提供內存。例如,思考這份代碼:

class Widget {
public:
    static const std::size_t MinVals = 28;         // MinVals的聲明
    ...
};
...                 // 沒有定義MinVals

std::vector<int> weightData;
widgetData.reserve(Widget::MinVals);    // 使用MinVals

在這裏,我們使用Widget::MinVals(下面簡稱爲Minvals)來指定widgetData的初始容量,儘管MinVals缺乏定義。編譯器忽視MinVals沒有定義(就像它們被要求這樣)然後把28放到出現MinVals的地方。事實上沒有爲MinVals的值留出存儲空間是不成問題的,如果取MinVals的地址(例如,某人創建一個指向MinVals的指針),然後MinVals纔會去請求存儲空間的值(因此指針就有東西可指),然後對於上面的代碼,雖然它可以編譯,但它會鏈接失敗,除非爲MinVals提供定義。

把那些記住心裏,然後想象f(fwd把參數轉發的目的函數)是這樣聲明的:

void f(std::size_t val);

用MinVals直接調用f是沒問題的,因爲編譯器會用28代替MinVals:

f(Widget::MinVals);      // 正確,被視爲"f(28)"

但是,當我們通過fwd調用f時,事情就沒有那麼一帆風順了:

fwd(Widget::MinVals);           // 錯誤!不應該鏈接

代碼可以編譯,但是它不能鏈接。如果你能想起我們取MinVals地址會發生什麼,你就很聰明啦,那是因爲表面下的問題是相同的。

儘管源代碼沒有取MinVals的地址,但fwd的參數是個通用引用,然後引用呢,在編譯器生成的代碼中,通常被視爲指針。對於程序的二進制代碼中(或對於硬件),指針和引用在本質上是同一個東西。在這個層次上,有一句反應事實的格言:引用只是會自動解引用的指針。情況既然是這樣了,MinVals被引用傳遞和被指針傳遞是相同的,而這樣的話,必須要有指針可以指向的內存。以引用傳遞static const成員變量通常要求它們被定義過,而這個要求可以導致代碼完美轉發失敗。

在之前的討論中,你可能會注意到我的一些含糊用詞。代碼“不應該”鏈接,引用“通常”被視爲指針,以引用傳遞static const成員變量“通常”要求它們被定義過。這就好像是我知道一些東西,但是沒有告訴你。

那是因爲,我現在告訴你。根據標準庫,以引用傳遞MinVals要求MinVals被定義,但不是所有實現都強迫服從這個要求。因此,取決於你的編譯器和鏈接器,你可能會發現你可以完美轉發沒有定義過的static const成員變量。如果真的可以,恭喜你,不過沒有理由期望這樣的代碼能夠移植。爲了讓代碼可移植,就像我們談及那樣,簡簡單單地爲static const成員變量提供一份定義。對於MinVals,代碼是這樣的:

const std::size_t Widget::MinVals;              // 在存放Widget的 ".cpp"文件中

留意到定義沒有重複初始值(對於MinVals這個例子,是28),不過,不用在意這個細節。如果你在聲明和定義兩個地方都忘記提供初始值,你的編譯器會發出抱怨,然後就能讓你記起你需要在其中一個地方指定初始值。


重載函數名字和模板名字

假如我們的函數f(我們想要藉助fwd轉發參數到該函數)想通過接受一個函數作爲參數來定製它的行爲,假定該函數接受和返回int,那麼f應該被聲明爲這樣:

void f(int (*pf)(int));        // pf = "進行處理的函數"

值得注意的是f也可以被聲明爲使用簡單的非指針函數。這樣的聲明看起來是下面這樣的,儘管它和上面的聲明具有相同的意思:

void f(int pf(int));    // 聲明和上面一樣

無所謂,現在假如我們有個重載函數,processVal:

int processVal(int value);
int processVal(int value, int priority);

我們可以把processVal傳給f,

f(processVal);         // 正確

不過有一些讓我們驚訝的東西。f需要的是一個指向函數的指針作爲它的參數,但是processVal既不是個函數指針,也不是一個函數,它是兩個不同函數的名字。不過,編譯器知道它們需要哪個processVal:匹配f形參類型的那一個。因此,編譯器會選擇接受一個int的processVal,然後把那個函數地址傳給f。

使得代碼可以工作的原因是f的聲明讓編譯器知道需求那個版本的processVal。但是,fwd,是個模板函數,沒有任何關於需求類型的信息,這讓編譯器不能決定——應該傳遞哪個重載函數:

fwd(processVal);      //  錯誤!哪個processVal

單獨的processVal沒有類型。 沒有類型,就不能進行類型推斷;沒有類型推斷,就留給我們另一種完美轉發失敗的情況。

當我們嘗試用一個模板函數名字來代替重載函數名字,會出現相同的問題。一個模板函數不是代表成函數,它代表很多函數:

template<typename T>
T workOnVal(T param)        // 一個處理值的模板
{ ... }

fwd(workOnVal);        // 錯誤!哪個workOnVal實例化?

像fwd這種進行完美轉發的函數,想要接受一個重載函數名字或者模板名字的方法是:手動指定你想要轉發的那個重載或者實例化。例如,你可以創建一個函數指針,它的類型與f的形參類型相同,然後用processVal和workOnVal初始化那個指針(所以能夠選擇合適的processVal版本或生成合適的workOnValue實例化),然後把指針傳遞給fwd:

using ProcessFuncType = int (*)(int);

ProcessFuncType processValPtr = processVal;    // 指定了processVal的簽名

fwd(processValue);          // 正確

fwd(static_cast<ProcessFuncType>(workOnVal));   // 也是正確的

當然,這需要你知道fwd轉發的目的函數需要的函數指針類型,我們可以合理假設完美轉發函數的文檔或註釋會說明轉發的目的函數需要的函數指針類型。最後,進行完美轉發的函數被設計來能夠接受任何東西,所以如果沒有文檔告訴你要傳遞的類型,那你怎麼知道呢?

位域(Bitfields)

最後一種完美轉發失敗的情況是,當位域被用作函數實參。爲了在實際中知道這是什麼意思,觀察一個模型化的IPV4頭部:

struct IPv4Header {
    std::uint32_t version : 4,
                         IHL : 4,
                         DSCP : 6,
                         ECN : 2,
                         totalLength : 16;
    ...
};

如果我們可憐的函數f(我們的轉發函數fwd永恆的目標)被聲明爲接受一個std::size_t參數,然後用IPv4Header對象的totalLength域來調用f,編譯器不會發出怨言:

void f(std::size_t sz);   // 被調用的函數

IPv4Header h;
...
f(h.totalLength);         // 正確

但是,想要藉助fwd把h.totalLength轉發f,就是另外的結果了:

fwd(t.totalLength);      // 錯誤

問題在於,fwd的形參是個引用,而h.totalLength是個非const的位域。這聽起來可能不是很糟糕,但是C++標準對於這種結合,平淡無趣地講:“A non-const reference shall not be bound to a bit-field.”(不是常量引用不能綁定位域。)對於這個禁令,原因很充分:位域可能是包括機器字(world)的任意部分(例如,32位int的3-5個位。),但是沒有方法直接獲取它們的地址。我之前提起過在硬件層面上引用和指針是相同的東西,然後,就像沒有辦法創建指向任意位的指針(C++表明可指向的最小的東西是一個char),也沒有辦法對任意位進行綁定引用。

繞過不能完美轉發轉發位域很簡單,只要你意識到接受位域作爲參數的函數只是接收它的值的拷貝。畢竟,沒有函數可以對位域綁定引用,也沒有函數可以接受一個指向位域的指針,因爲指向位域的指針不可能存在。可以傳遞位域的參數種類只有傳值參數,和,有趣的常量引用(reference-to-const),在傳值參數的情況裏,被調用的函數明顯接收位域的值的拷貝,而在常量引用參數的情況裏,標準規定引用實際上綁定的是位域的值的拷貝(這份拷貝存儲在某些標準整型類型中,例如int)。常量引用不會綁定位域,它們綁定的是“正常的”對象,這個對象拷貝了位域的值。

那麼,把位域傳遞給進行完美轉發函數的關鍵是,利用轉發目的函數總是接收位域的值拷貝這個優勢。所以你可以自己進行拷貝,然後用這個拷貝調用轉發函數。例如,在IPv4Header這個例子,可以用這個把戲:

//  拷貝位域的值,初始化形式看條款6
auto length = static_cast<std::uint16_t>(h.totalLength);

fwd(length);   // 轉發拷貝

總結

在大多數情況下,完美轉發工作得像它宣稱那樣,你很少需要仔細考慮它。但有時它不能工作——當一些看起來合理的代碼編譯失敗,或者可以編譯,行爲卻和你預料的不一樣——知道完美轉發有瑕疵是重要的,同樣重要的是知道如何繞過它們。在大多數情況下,完美轉發是直截了當的。

需要記住的2點:

  • 當模板類型推斷失敗或推斷出錯誤的類型時,完美轉發會失敗。
  • 導致完美轉發失敗的幾種實參有:大括號初始值,0和NULL代表的空指針,只聲明的static const成員變量,模板函數名字和重載函數名字,位域。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章