一個常見的業務背景
假設一個業務場景是這樣的:
取用一個名字作為形參,然後記錄下目前時間,在把該名字添加到一個全局資料結構中。也許你會這樣實作:
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. 完美轉發構造函數的問題尤其嚴重,因為對于非常量的左值型别而言,他們一般都會形成相對于複制構造函數的更加比對,并且他們還會劫持派生類中對于基類的複制和移動構造函數的調用。 |