Effective Modern C++ 條款26 避免對通用引用進行重載

避免對通用引用進行重載

假如你要寫一個函數,參數是name,它先記錄當前日期和時間,然後把name添加到全局數據結構中。你可能會想出這樣的一個函數:

std::multiset<std::string> names;    // 全局數據結構

void logAndAdd(const string& name)
{
    auto now = std::chrono::system_clock::now();  // 獲取當前時間

    log(now, "logAndAdd");    // 記錄日記

    names.emplace(name);      // 把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對象是由字符串隱式創建而來。和第二個調用一樣,name是被拷貝到names,但在這個例子中,一開始傳遞給logAndAdd的參數是字符串。如果將字符串直接傳遞給emplace,是不需要創建臨時的std::string對象的。取而代之的是,emplace會直接在std::multiset中用字符串構建std::string對象。在第三個調用中,我們還是要拷貝一個std::string對象的,不過我們真的沒必要承擔移動的開銷,更何況移動。

通過重新寫logAndAdd,讓其接受一個通用引用,然後服從條款25對通用引用使用std::forward,我們可以消除第二個調用和第三個調用的低效率。代碼是這樣的:

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");  // 在multisest內創建std::string,來代替創建臨時std::string對象

yoooohu!最佳工作效率!

這就是故事的結尾了嗎,我們可以功成身退了,不過,我沒有告訴你,用戶並不總是直接持有logAndAdd需要的name。一些用戶只有名字表的索引,爲了支持這些用戶,我們重載了logAndAdd

std::string nameFromIdx(int idx);    // 根據idx放回name

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

這兩個函數的重載決策的工作滿足我們期望:

std::string petName("Darla");   // 如前

logAndAdd(petName);                // 如前,這三個都是使用T&&的重載
logAndAdd(std::string("Persephone"));
logAndAdd("PattyDog");

logAndAdd(22);    // 使用int重載

實際上,決策工作正常是因爲你想的太少了。假如用戶有個short類型持有索引,然後把它傳遞給logAndAdd

short nameIdx;
...               // 給nameIdx賦值
logAndAdd(nameIdx);   // 錯誤

最後一行的註釋講得不清楚,讓我來解釋這裏發生了什麼。

logAndAdd有兩個重載,其中的接受通用引用的重載可以將T推斷爲short,因此產生了精確匹配。而接受int的重載需要提升才能匹配short參數。任何一個正常的重載決策規則,精確匹配都會打敗需要提升的匹配,所以會調用接受通用引用的重載。

在那個重載中,參數name被綁定到傳進來的short,然後name被完美轉發到names(std::multiset<std::string>)的成員函數emplace中,在那裏,相應地,emplace盡職地把short轉發到std::string的構造函數。std::string不存在接收short的構造函數,因此multiset.emplace內的std::string構造調用會失敗。這所有的所有都是因爲對於short類型,通用引用的重載比int的重載更好。

接受通用引用作爲參數的函數是C++最貪婪的函數,它們可以爲幾乎所有類型的參數實例化,從而創建的精確匹配。這就是爲什麼結合重載和通用引用幾乎總是個糟糕的想法:通用引用重載吸收的參數類型遠多於開發者的期望。


一種容易掉進這個坑的方法是寫完美轉發的構造函數。對logAndAdd這個例子進行小小的改動就可以展示這個問題,相比於寫一個接受std::string或索引的函數,試着想象一個類Person,它的構造函數就是做那個函數的事情:

class Person {
public:
    template<typename T>
    explicit Person(T&& n)         // 完美轉發構造函數
    : name(std::forward<T>(n)) {}  // 初始化成員變量

    explicit Person(int idx)   // 接受int的構造
    :name(nameFromIdx(idx)) {}
    ...
private:
    std::string name;
};

logAndAdd的情況一樣,傳遞一個不是int的整型數會調用通用引用構造函數,然後那會導致編譯失敗。但是,這裏問題更糟,因爲比起看見的,Person會出現更多重載。條款17解釋過在合適的條件下,C++會生成拷貝和移動構造,就算這個類有模板化的構造函數,它在實例化時也會生成拷貝和移動構造的簽名。如果Person類生成移動和拷貝構造,Person看起來是這樣的:

class Person {
public:
    template<typename T>
    explicit Person(T&& n)
    : name(std::forward<T>(n)) {}

    explicit Person(int idx);

    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被一個非const左值(p)初始化,那意味着模板構造函數可以被實例化來接受一個非const左值Person,這樣實例化之後,Person的代碼變成這樣:

class Person {
public:
    explicit Person(Person& n)      // 從完美轉發模板構造實例化而來
    : name(std::forward<Person&>(n)) {} 

    explicit Person(int idx);

    Person(const Person& rhs); 

    ...
};

在這條語句中,

auto cloneOfP(p);

p既可以傳遞給拷貝構造,又可以傳遞給實例化模板。調用拷貝構造的話需要對p添加const才能匹配拷貝構造的參數類型,但是調用實例化模板不用添加什麼。因此生成的模板是更加匹配的函數,所以編譯器做了它們應該做的事情:調用更加匹配的函數。因此,“拷貝”一個非const的左值Person,會被完美轉發構造函數處理,而不是拷貝構造函數。

如果我們稍稍改一下代碼,讓對象拷貝const對象,我們就會得到完全不一樣結果:

const Person cp("Nancy");  // 對象是**const**的

auto cloneOfP(cp);      // 調用拷貝構造!

因爲對象拷貝的對象是const的,它會精確匹配拷貝構造函數。模板化構造函數可以實例化出一樣的簽名,

class Person {
public:
    explicit Person(const Person& n);    // 實例化的模板構造

    Person(const Person& rhs);   // 編譯器生成的拷貝構造

    ...
};

不過這沒關係,因爲C++重載決策的一個規則是:當一個模板實例化函數和一個非模板函數(即,一個普通函數)匹配度一樣時,優先使用普通函數。因此,在相同的簽名下,拷貝構造(普通函數)勝過實例化模板函數。

(如果你想知道爲什麼在實例化模板構造函數可以得到拷貝構造的簽名的情況下,編譯器還能生成拷貝構造函數,請去複習條例17。)

完美轉發構造函數與編譯器生成的拷貝和移動構造函數之間的糾紛在繼承介入後變得更加雜亂。特別是,派生類的拷貝和移動構造的常規實現的行爲讓你大出所料。看這裏:

class SpecialPerson :  public Person {
public:
    SpecialPerson(const SpecialPerson& rhs)  // 拷貝構造函數
    : Person(rhs)              // 調用基類的完美轉發構造
    { ... }

    SpecialPerson(SpecialPerson&& rhs)    // 移動構造函數
    : Person(std::move(rhs))   // 調用基類的完美構造函數
    { ... }
};

就像註釋表明那樣,派生類的拷貝和移動構造並沒有調用基類的拷貝和移動構造,而是調用了基類的完美轉發構造!要理解爲什麼,你要注意到派生類的函數把類型SpecialPerson傳遞給基類,然後實例化模板,重載決策,對Person使用完美構造函數。最終,代碼不能通過編譯,因爲std::string沒有以SpecialPerson爲參數的構造函數。

我希望我現在可以說服你應該儘可能地避免以通用引用重載函數。不過,如果對通用引用進行重載是個糟糕的想法,而你需要轉發參數,或者特殊處理一些參數,該怎麼做呢?方法有很多種,因爲太多了,所以我決定把它放進一個條款中,它就是條款27,也就是下一條款,繼續看吧,你會和它撞個滿懷的。


總結

需要記住的2點:

  • 對通用引用進行重載幾乎總是會導致這個重載函數頻繁被調用,超出預期。
  • 完美轉發構造函數是特別有問題的,因爲在接受非const左值作爲參數時,它們通常比拷貝構造匹配度高,然後它們還能劫持派生類調用的基類的拷貝和移動構造。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章