本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝! 如果你需要寫一個以名字作為參數,並記錄下當前日期和時間的函數,在函數中還要把名字添加到全局的數據結構中去的話。你可能會想出看起來像這樣的一個函數: std::multiset name ...
本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!
如果你需要寫一個以名字作為參數,並記錄下當前日期和時間的函數,在函數中還要把名字添加到全局的數據結構中去的話。你可能會想出看起來像這樣的一個函數:
std::multiset<std::string> name; // 全局數據結構
void logAndAdd(const std::string& name)
{
auto now = // 得到當前時間
std::chrono::system_clock::now();
log(now, "logAndAdd"); // 產生log條目
names.emplace(name); // 把name添加到全局的數據結構中去
// 關於emplace的信息,請看Item 42
}
這段代碼並非不合理,只是它可以變得更加有效率。考慮三個可能的調用:
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中去的,但是我們知道,從原則上來說,它的值能被move到names中。在這個調用中,我們多做了一次拷貝,但是我們本應該通過一個move來實現的。
在第三個調用中,name參數再一次被綁定到了一個右值上,但是這次是由“Patty Dog”字元串隱式創建的臨時變數---std::string。就和第二種調用一樣,name試被拷貝到names中去的,但是在這種情況下,被傳給logAndAdd原始參數是字元串。如果把字元串直接傳給emplace的話,我們就不需要創建一個std::string臨時變數了。取而代之,在std::multiset內部,emplace將直接使用字元串來創建std::string對象。在第三種調用中,我們需要付出拷貝一個std::string的代價,但是我們甚至真的沒理由去付出一次move的代價,更別說是一次拷貝了。
我們能通過重寫logAndAdd來消除第二個以及第三個調用的低效性。我們使logAndAdd以一個universal引用(看Item24)為參數,並且根據Item 25,再把這個引用std::forward(轉發)給emplace。結果就是下麵的代碼了:
templace<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")); // 用move操作取代拷貝操作
logAndAdd("Patty Dog"); // 在multiset內部創建
// std::string,取代對
// std::string臨時變數
// 進行拷貝
萬歲!效率達到最優了!
如果這是故事的結尾,我能就此打住很自豪地離開了,但是我還沒告訴你客戶端並不是總能直接訪問logAndAdd所需要的name。一些客戶端只有一個索引值,這個索引值可以讓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); // 和之前一樣,這些函數
logAndAdd(std::string("Persephone")); // 都調用T&&版本的重載
logAndAdd("Patty Dog");
logAndAdd(22); // 調用int版本的重載
事實上,決議結果能符合期待只有當你不期待太多時才行。假設一個客戶端有一個short類型的索引,並把它傳給了logAndAdd:
short nameIdx;
... // 給nameIdx一個值
logAndAdd(nameIdx); // 錯誤!
最後一行的註釋不是很明確,所以讓我來解釋一下這裡發生了什麼。
這裡有兩個版本的logAndAdd。一個版本以universal引用為參數,它的T能被推導為short,因此產生了一個確切的匹配。以int為參數的版本只有在一次提升轉換(譯註:也就是類型轉換,從小精度數據轉換為高精度數據類型)後才能匹配成功。按照正常的重載函數決議規則,一個確切的匹配擊敗了需要提升轉換的匹配,所以universal引用重載被調用了。
在這個重載中,name參數被綁定到了傳入的short值。因此name就被std::forwarded到names(一個std::multiset<std::string>
)的emplace成員函數,然後在內部又把name轉發給std::string的構造函數。但是std::string沒有一個以short為參數的構造函數,所以在logAndAdd調用中的multiset::emplace調用中的std::string構造函數的調用失敗了。這都是因為比起int版本的重載,universal引用版本的重載是short參數更好的匹配。
在C++中,以universal引用為參數的函數是最貪婪的函數。它們能實例化出大多數任何類型參數的準確匹配。(它無法匹配的一小部分類型將在Item 30中描述。)這就是為什麼把重載和universal引用結合起來使用是個糟糕的想法:比起開發者通常所能預想到的,universal引用版本的重載使得參數類型失效的數量要多很多。
一個簡單的讓事情變複雜的辦法就是寫一個完美轉發的構造函數。一個對logAndAdd例子中的小改動能說明這個問題。比起寫一個以std::string或索引(能用來查看一個std::string)為參數的函數,我們不如寫一個能做同樣事情的Person類:
class Person {
publci:
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外的整形類型(比如,std::size_t, short, long)將不會調用int版本的構造函數,而是調用universal引用版本的構造函數,然後這將導致編譯失敗。但是這裡的問題更加糟糕,因為除了我們能看到的以外,這裡還有別的重載出現在Person中。Item 17解釋了在適當的條件下,C++將同時產生拷貝和move構造函數,即使類中包含一個能實例化出同拷貝或move構造函數同樣函數簽名的模板構造函數,它還是會這麼做。因此,如果Person的拷貝和move構造函數被產生出來了,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); // move構造函數
… // (編譯器產生的)
};
只有你花了大量的時間在編譯期和寫編譯器上,你才會忘記以人類的想法去思考這個問題,知道這將導致一個很直觀的行為:
Person p("Nancy");
auto cloneOfP(p); // 從p創建一個新的Person
// 這將無法通過編譯!
在這裡我們試著從另外一個Person創建一個Person,這看起來就拷貝構造函數的情況是一樣的。(p是一個左值,所以我們能不去考慮“拷貝”可能通過move操作來完成)。但是這段代碼不能調用拷貝構造函數。它將調用完美轉發構造函數。然後這個函數將試著用一個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既能被傳給拷貝構造函數也能被傳給實例化的模板。調用拷貝構造函數將需要把const加到p上去來匹配拷貝構造函數的參數類型,但是調用實例化的模板不需要這樣的條件。因此產生自模板的版本是更佳的匹配,所以編譯器做了它們該做的事:調用更匹配的函數。因此,“拷貝”一個Person類型的非const左值會被完美轉發構造函數處理,而不是拷貝構造函數。
如果我們稍微改變一下例子,使得要被拷貝的對象是const的,我們將得到一個完全不同的結果:
const Person cp("Nancy"); // 對象現在是const的
auto cloneOfP(cp); // 調用拷貝構造函數!
因為被拷貝的對象現在是const的,它完全匹配上拷貝構造函數的參數。模板化的構造函數能被實例化成有同樣簽名的函數,
class Person {
public:
explicit Person(const Person& n); //從模板實例化出來
Person(const Person& rhs); // 拷貝構造函數
// (編譯器產生的)
...
};
但是這不要緊,因為C++的“重載決議”規則中有一條就是當模板實例和一個非模板函數(也就是一個“正常的”函數)都能很好地匹配一個函數調用時,正常的函數是更好的選擇。因此拷貝構造函數(一個正常的函數)用相同的函數簽名打敗了被實例化的模板。
(如果你好奇為什麼當編譯器能用模板構造函數實例化出同拷貝構造函數一樣的簽名時,它們還是會產生一個拷貝構造函數,請複習Item 17。)
當繼承介入其中時,完美轉發構造函數、編譯器產生的拷貝和move構造函數之間的關係將變得更加扭曲。尤其是傳統的派生類對於拷貝和move操作的實現將變得很奇怪,讓我們來看一下:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) // 拷貝構造函數,調用
: Person(rhs) // 基類的轉發構造函數
{ … }
SpecialPerson(SpecialPerson&& rhs) // move構造函數,調用
: Person(std::move(rhs)) // 基類的轉發構造函數
{ … }
};
就像註釋標明的那樣,派生的類拷貝和move構造函數沒有調用基類的拷貝和move構造函數,它們調用基類的完美轉發構造函數!為了理解為什麼,註意派生類函數傳給基類的參數類型是SpecialPerson類型,然後產生了一個模板實例,這個模板實例成為了Person類構造函數的重載決議結果。最後,代碼無法編譯,因為std::string構造函數沒有以SpecialPerson為參數的版本。
我希望現在我已經讓你確信,對於universal引用參數進行重載是你應該儘可能去避免的事情。但是如果重載universal引用是一個糟糕的想法的話,那麼如果你需要一個函數來轉發不同的參數類型,並且需要對一小部分的參數類型做特殊的事情,你該怎麼做呢?事實上這裡有很多方式來完成這件事,我將花一整個Item來講解它們,就在Item 27中。下一章就是了,繼續讀下去,你會碰到的。
你要記住的事
- 重載universal引用常常導致universal引用版本的重載被調用的頻率超過你的預期。
- 完美轉發構造函數是最有問題的,因為比起非const左值,它們常常是更好的匹配,並且它們會劫持派生類調用基類的拷貝和move構造函數。