天天看點

Effective Modern C++ Item 26 避免依萬能引用型别進行重載

一個常見的業務背景

假設一個業務場景是這樣的:

取用一個名字作為形參,然後記錄下目前時間,在把該名字添加到一個全局資料結構中。也許你會這樣實作:

std::multiset<std::string> names;       //全局資料結構

void logAndAdd(const std::string name)
{
    auto now = 
    std::chrono::system_clock::now();   //擷取目前時間
    log(now, "logAndAdd");              //制備日志條目
    names.emplace(name);                //将名字添加到全局資料結構中
}
           

這段代碼邏輯正确,但是效率可能不如人意,考慮如下調用:

std::string petName("Darla");

logAndAdd(petName);                     //傳遞左值std::string ①

logAndAdd(std::string("Persephone"));   //傳遞右值std::string ②

logAndAdd("Patty Dog");                 //傳遞字元串字面量     ③
           

在情況①中,

logAndAdd

的形參

name

綁定到了變量

petName

。在

logAndAdd

内部,

name

最終被傳遞給了

names.emplace

。由于

name

是個左值,它是被複制入

names

的。沒有任何辦法避免這個複制操作,因為傳遞給

logAndAdd

的是個左值(petName)。

在情況②中,形參

name

綁定到一個右值(從

Persephone

顯式構造的

std::string

型别的臨時變量)。name自身是個左值,是以它是被複制入names的。但我們能夠認識到,原則上該值是可以被移動入names的。是以在這個調用中,我們付出了一個複制的成本,但是我們可以用一次移動來達成同樣的目标。

在情況③中,形參

name

還是綁定到一個右值,但這次這個

std::string

型别的臨時對象是從

Patty Dog

隐式構造的。和第二個調用語句的情況一樣,

name

是被複制入

names

的,但在本語句中,傳遞給

logAndAdd

的實參是個字元串字面量。如果該字元串字面量是被直接傳遞給

emplace

,那就完全沒有必要構造一個

std::string

型别的臨時對象。

emplace

完全可以利用這個字元串字面量在

std::multiset

内部直接構造一個

std::string

對象。這裡我們付出了複制一個

std::string

對象的成本,但實際上我們連一次移動的成本都沒有必要付出,更别說複制了。

我們可以解決第二個和第三個調用語句的效率低下問題,隻需重寫

logAndAdd

,讓它接收一個萬能引用(見

Item 24

),并且根據

Item 25

,對該引用實施

std::forward

給到

emplace

。重寫結果不言自明:

template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

std::string petName("Darla");

logAndAdd(petName);                     //這裡和上面一樣,左值進行複制到multiset

logAndAdd(std::string("Persephone"));   //這裡對右值實施移動而非複制

logAndAdd("Patty Dog");                 //在multiset中直接構造一個std::string對象,
                                        //而非複制一個std::string型别的臨時對象
           

非常完美,效率達到極緻了!

這個常見業務場景的後續拓展需求,打開了潘多拉魔盒

但實際上,該函數的客戶并不總能直接通路到

logAndAdd

所需要的名字。有些客戶隻能通路到一個索引,

logAndAdd

需要根據該索引來查詢一張表才能找到對應的名字。為了支援這樣的客戶,

logAndAdd

提供了重載版本:

std::string nameFromIdx(int idx);       //傳回索引對應的名字

void logAndAdd(int idx)                 //新的重載函數
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));    
}
           

調用時的重載決議符合期望:

std::string petName("Darla");

logAndAdd(petName);                     //和forward版本行為一緻
logAndAdd(std::string("Persephone"));   //和forward版本行為一緻
logAndAdd("Patty Dog");                 //和forward版本行為一緻

logAndAdd(22);                          //本句調用了形參型别為int的重載版本
           

但事情不是總是想象的那麼美好,假設客戶使用了一個

short

型别的變量來持有這個索引值,并将該變量傳遞給了

logAndAdd

:

short nameIdx;          //指派給nameIdx
...
logAndAdd(nameIdx);     //錯誤!
           

這裡解釋一下,為何會錯誤:

logAndAdd

有兩個重載版本。形參型别為

T&&

的版本可以将T推導為

short

,進而産生一個精确比對。而形參型别為

int

的版本卻隻能在型别提升以後才能比對到

short

型别的實參。按照普适的重載決議規則,精确比對優先于提升後才能比對。是以,形參型别為萬能引用的版本才是被調用到的版本。

調用執行後,形參

name

被綁定到傳入的

short

型别變量上。然後,

name

std::forward

傳遞給

names

(一個

std::multiset<std::string>

)的

emplace

成員函數,然後,又被轉發給

std::string

的構造函數。而在

std::string

的構造函數并不存在以

short

為傳參的版本。這一切的原因歸根結底在于,對于

short

的型别實參來說,萬能引用産生了比

int

更好的比對。

形參為萬能引用的函數,是C++中最貪婪的。他們會在具現過程中,和幾乎任何實參型别都會産生精确比對(例外情況詳見

Item 30

)。這就是為何把重載和萬能引用兩者結合起來,幾乎總是馊主意:一旦萬能引用成為重載候選,他就會吸引走大批的實參型别,遠比撰寫重載代碼的程式猿期望的要多。

為了解決打開魔盒的後果,我們進行了一個更“好”的嘗試

填上這個坑的一個簡單辦法,是撰寫一個帶完美轉發的構造函數。

請記住這話是個挖下了一個更大的坑。但我們繼續看為什麼這個坑更大

logAndAdd

這個示例做了一點點修改,就暴露了問題。我們先不去撰寫一個自由函數來同時取用

std::string

或一個用以查表傳回

std::string

的索引,而是先考慮一個

Person

類,它的構造函數有相同的功能:

class Person {
public:
    template<typename T>
    explicit Person(T&& n) : name(std::forward<T>(n))
    {}                                  //完美轉發構造函數,初始化資料成員
    explicit Person(int indx) : name(nameFromIdx(idx))
    {}                                  //形參為int的構造函數
private:
    std::string name;
}
           

logAndAdd

的情景中,傳入一個非int型别的整型(例如

std::size_t

short

long

等)都會導緻調用形參為萬能引用的構造函數重載版本,進而引發編譯失敗。但是上例中都的情景則要糟糕的多,因為在

Person

中還有比我們肉眼所見更多的重載版本。

Item 17

解釋了,在适當條件下,C++會同時生成複制和移動構造函數,并且這一點在即使類中包含着一個模闆化的構造函數,且它可以具現出複制和移動構造函數的前面來的前提下也依然成立。假如

Person

中真的如此生成了複制和移動構造函數,那麼它實際上會是呈現成這樣的:

class Person {
public:
    template<typename T>
    explicit Person(T&& n) :name(std::forward<T>(n))
    {}                              //完美轉發構造函數
    explicit Person(int idx);       //形參為int的構造函數
    Person(const Person& rhs);      //複制構造函數(由編譯器生成)
    Person(Person&& rhs);           //移動構造函數(由編譯器生成)
    ...
};
           

隻有花費了大量時間與編譯器和寫編譯器的人打交道,才能形成對于程式行為的直覺,并忘記普通人的思維方式:

Person p("Nancy");

auto cloneOfP(p);       //從p觸發建立新的Person型别對象,
                        //上述代碼無法通過編譯!
           

在這裡我們嘗試從一個

Person

出發,建立另一個

Person

,看起來再明顯不過會是調用複制構造的情況(p是個左值,這就足以打消一切機會将“複制”通過移動完成的想法)。但這段代碼竟沒有調用複制構造函數,而是調用了完美轉發構造函數。該函數是在嘗試從一個

Person

型别的對象(p)出發來初始化另個一個

Person

型别的對象的

std::string

型别的資料成員。而

std::string

型别卻不具備接受

Person

型别形參的構造函數,你的編譯器隻能舉手投降,也許會丢出一堆冗長且無法了解的錯誤資訊作為發洩。

你可能感覺到莫名其妙,“這是怎麼回事?怎麼會調用的是完美轉發構造函數而不是複制構造函數呢?這不是明明在用一個

Person

型别的對象初始化另一個

Person

型别的對象嗎?”确實是在做這件事,但是編譯器是宣誓效忠C++規則的,而這裡用到的規則關于調用重載函數時的決議。

編譯器是這麼思考問題的:

cloneOfP

被非常量左值(p)初始化,那意味着模闆構造函數可以執行個體化來接受

Person

型别的非常量左值形參。如此執行個體化後,

Person

的代碼應該變成下面這樣:

class Person {
public:
    template<typename T>
    explicit Person(Person& n) :name(std::forward<Person&>(n))
    {}                              //完美轉發構造函數
    explicit Person(int idx);       //形參為int的構造函數
    Person(const Person& rhs);      //複制構造函數(由編譯器生成)
    ...
};
           

在下述語句中,

p

既可以傳遞給複制構造函數,也可以傳遞給執行個體化了的模闆。但是調用複制構造的話,就要先對

p

添加

const

修飾。因而,模闆生成的重載版本是更加比對,是以編譯器的做法完全符合設計:它調用了符合更加比對原則的函數。這麼一來,“複制”一個非常量左值的左值

Person

型别對象,會由完美轉發構造函數,而不是複制構造函數來完成。

如果我們稍微修改一下代碼,使得複制之物成為一個常量對象,反響就完全不同了:

const Person p("Nancy");    //對象成為常量了

auto cloneOfP(p);           //這回調用的是複制構造函數了!
           

因為想要複制的對象是個常量,就形成了對複制構造函數形參的精确比對。另一方面,那個模闆化的構造函數可以經由執行個體化得到的同樣的簽名:

class Person {
public:
 explicit Person(const Person& n);  //從模闆觸發執行個體化
                                    //而得到的構造函數
Person(const Person& rhs);          //複制構造函數(由編譯器生成)
...
};
           

但這并不要緊,因為C++重載決議規則中有這麼一條:若在函數調用時,一個模闆實力化函數和一個非函數模闆(即,一個“正常”函數)具備相等的比對程度,則優先選用正常函數。

根據這一條,在簽名相同時,複制構造函數(它是個普通函數)就會壓過執行個體化了的函數模闆。

(如果你想知道,為什麼明明執行個體化了的模闆構造函數已經生成了和複制構造函數一模一樣的簽名,編譯器還會生成複制構造函數,請參考

Item 17

更有甚者,當繼承遇上完美轉發構造函數模闆時

完美轉發構造函數與編譯器生成的複制和移動操作之間的那些錯綜複雜的關系,再加上繼承以後就變的讓人更加眉頭緊鎖。特别的,派生類的複制和移動操作的平凡實作會表現出讓人大跌眼鏡的行為。請看好:

class SpecialPerson: public Person {
public:
    SpecialPerson(const SpecialPerson& rhs) //複制構造函數:
    :Person(rhs)                            //調用的是基類的完美轉發函數!
    {...}
    SpecialPerson(SpecialPerson&& rhs)      //移動構造函數:
    :Person(std::move(rhs))                 //調用的是基類的完美轉發函數!
    {...}
};
           

注釋說的很明白,派生類的複制和移動構造函數并未調用到基類的移動和複制構造函數,調用到的是基類的完美轉發構造函數!要了解背後的原因,請注意,派生類函數把型别為

SpecialPerson

的實參傳遞給了基類,然後在Person類的構造函數中完成模闆執行個體化和重載決議。最終,代碼無法通過編譯,因為

std::string

的構造函數中沒有任何一個會接受

SpecialPerson

型别的形參。

小小總結

我希望我已經說服了你去盡可能避免以把萬能引用型别作為重載函數的形參選項。不過,如果使用萬能引用進行重載是個糟糕的思路,而你有需要針對絕大多數的實參型别實施轉發,隻針對某些實參型别實施特别處理,這時應該怎麼做呢?解決的辦法多種多樣,後續

Item 27

将詳細展開。

要點速記
1. 把萬能引用作為重載候選型别,幾乎總會讓該重載版本在始料未及的情況下被調用。
2. 完美轉發構造函數的問題尤其嚴重,因為對于非常量的左值型别而言,他們一般都會形成相對于複制構造函數的更加比對,并且他們還會劫持派生類中對于基類的複制和移動構造函數的調用。

繼續閱讀