默認(rèn)情況下,C++為函數(shù)傳入和傳出對象是采用傳值方式的(這是由C語言繼承而來的特征)。除非你明確使用其他方法,函數(shù)的形式參數(shù)總會通過復(fù)制實(shí)在參數(shù)的副本來創(chuàng)建,并且,函數(shù)的調(diào)用者得到的也是函數(shù)返回值的一個(gè)副本。這些副本是由對象的拷貝構(gòu)造函數(shù)創(chuàng)建的。這使得“傳值”成為一項(xiàng)代價(jià)十分昂貴的操作。請觀察下邊的示例中類的層次結(jié)構(gòu):
class Person {
public:
Person(); // 省略參數(shù)表以簡化代碼
virtual ~Person(); // 條目7解釋了它為什么是虛函數(shù)
...
private:
std::string name;
std::string address;
};
class Student: public Person {
public:
Student(); // 再次省略參數(shù)表
virtual ~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};
請觀察下面的代碼,這里我們調(diào)用一個(gè)名為validateStudent的函數(shù),通過為這一函數(shù)傳進(jìn)一個(gè)Student類型的參數(shù)(傳值方式),它將返回這一學(xué)生的身份是否合法:
bool validateStudent(Student s); // 通過傳值方式接受一個(gè)Student對象
Student plato; // 柏拉圖是蘇格拉底的學(xué)生
bool platoIsOK = validateStudent(plato); // 調(diào)用這一函數(shù)
在這個(gè)函數(shù)被調(diào)用時(shí)將會發(fā)生些什么呢?
很顯然地,在這一時(shí)刻,通過調(diào)用Student的拷貝構(gòu)造函數(shù),可以將這一函數(shù)的s參數(shù)初始化為plato的值。同樣顯然的是,s在validateStudent返回的時(shí)候?qū)⒈讳N毀。所以這一函數(shù)中傳參的開銷就是調(diào)用一次Student的拷貝構(gòu)造函數(shù)和一次Student的析構(gòu)函數(shù)。
但是上邊的分析僅僅是冰山一角。一個(gè)Student對象包含兩個(gè)string對象,所以每當(dāng)你構(gòu)造一個(gè)Student對象時(shí),你都必須構(gòu)造兩個(gè)string對象。同時(shí),由于Student類是從Person類繼承而來,所以在每次構(gòu)造Student對象時(shí),你都必須再構(gòu)造一個(gè)Person對象。一個(gè)Person對象又包含兩個(gè)額外的string對象,所以每次對Person的構(gòu)造還要進(jìn)行額外的兩次string的構(gòu)造。最后的結(jié)果是,通過傳值方式傳遞一個(gè)Student對象會引入以下幾個(gè)操作:調(diào)用一次Student的拷貝構(gòu)造函數(shù),調(diào)用一次Person的拷貝構(gòu)造函數(shù),調(diào)用四次string的拷貝構(gòu)造函數(shù)。在Student的這一副本被銷毀時(shí),相應(yīng)的每次構(gòu)造函數(shù)調(diào)用都對應(yīng)著一次析構(gòu)函數(shù)的調(diào)用。因此我們看到:通過傳值方式傳遞一個(gè)Student對象總體的開銷究竟有多大?竟達(dá)到了六次構(gòu)造函數(shù)和六次析構(gòu)函數(shù)的調(diào)用!
下面向你介紹正確的方法,這一方法才會使函數(shù)擁有期望的行為。畢竟你期望的是所有對象以可靠的方式進(jìn)行初始化和銷毀。與此同時(shí),如果可以繞過所有這些構(gòu)造和析構(gòu)操作將是件很愜意的事情。這個(gè)方法就是:通過引用常量傳遞參數(shù):
bool validateStudent(const Student& s);
這樣做效率會提高很多:由于不會創(chuàng)建新的對象,所以就不會存在構(gòu)造函數(shù)或析構(gòu)函數(shù)的調(diào)用。改進(jìn)的參數(shù)表中的const是十分重要的。由于早先版本的validateStudent通過傳值方式接收Student參數(shù),所以調(diào)用者了解:無論函數(shù)對于傳入的Student對象進(jìn)行什么樣的操作,都不會對原對象造成任何影響,validateStudent僅僅會對對象的副本進(jìn)行修改。而改進(jìn)版本中Student對象是以引用形式傳入的,有必要將其聲明為const的,因?yàn)槿绻贿@樣,調(diào)用者就需要關(guān)心傳入validateStudent的Student對象有可能會被修改。
通過引用傳參也可以避免“截?cái)鄦栴}”。當(dāng)一個(gè)派生類的對象以一個(gè)基類對象的形式傳遞(傳值方式)時(shí),基類的拷貝構(gòu)造函數(shù)就會被調(diào)用,此時(shí),這一對象的獨(dú)有特征——使它區(qū)別于基類對象的特征會被“截掉”。剩下的只是一個(gè)簡單的基類對象,這并不奇怪,因?yàn)樗怯苫悩?gòu)造函數(shù)創(chuàng)建的。這肯定不是你想要的。請看下邊的示例,假設(shè)你正在使用一組類來實(shí)現(xiàn)一個(gè)圖形窗口系統(tǒng):
class Window {
public:
...
std::string name() const; // 返回窗口的名字
virtual void display() const; // 繪制窗口和內(nèi)容
};
class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
};
所有的Window對象都有一個(gè)名字,可以通過name函數(shù)取得。所有的窗口都可以被顯示出來,可以通過調(diào)用display實(shí)現(xiàn)。display是虛函數(shù),這一事實(shí)告訴我們,簡單基類Window的對象與派生出的WindowWithScrollBars對象的顯示方式是不一樣的。(參見條目34和36)
現(xiàn)在,假設(shè)你期望編寫一個(gè)函數(shù)來打印出當(dāng)前窗口的名字然后顯示這一窗口。下面是錯(cuò)誤的實(shí)現(xiàn)方法:
void printNameAndDisplay(Window w) // 錯(cuò)誤! 參數(shù)傳遞的對象將被截?cái)啵?/span>
{
std::cout << w.name();
w.display();
}
考慮一下當(dāng)你將一個(gè)WindowWithScrollBars對象傳入這個(gè)函數(shù)時(shí)將會發(fā)生些什么:
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
參數(shù)w將被構(gòu)造為一個(gè)Window對象——還記得么?它是通過傳值方式傳入的。這里,使wwsb具體化的獨(dú)有信息將被截掉。無論傳入函數(shù)的對象的具體類型是什么,在printNameAndDisplay的內(nèi)部,w將總保有一個(gè)Window類的對象的身份(因?yàn)樗旧砭褪且粋€(gè)Window的對象)。特別地,在printNameAndDisplay內(nèi)部對display的調(diào)用總是Window::display,而永遠(yuǎn)不會是WindowWithScrollBars::display。
解決截?cái)鄦栴}的方法是:通過引用常量傳參:
void printNameAndDisplay(const Window& w)
{ // 工作正常,參數(shù)將不會被截?cái)唷?/span>
std::cout << w.name();
w.display();
}
現(xiàn)在w的類型就是傳入窗口對象的精確類型。
揭開C++編譯器的面紗,你將會發(fā)現(xiàn)引用通常情況下是以指針的形式實(shí)現(xiàn)的,所以通過引用傳遞通常意味著實(shí)際上是在傳遞一個(gè)指針。因此,如果傳遞一個(gè)內(nèi)建數(shù)據(jù)類型的對象(比如int),傳值會被傳遞引用更為高效。那么,對于內(nèi)建數(shù)據(jù)類型,當(dāng)你在傳值和傳遞常量引用之間徘徊時(shí),傳值方式不失為一個(gè)更好的選擇。迭代器和STL中的函數(shù)對象也是如此,這是因?yàn)樗鼈冊O(shè)計(jì)的初衷就是能夠更適于傳值,這是C++的慣例。迭代器和函數(shù)對象的設(shè)計(jì)人員有責(zé)任考慮復(fù)制時(shí)的效率問題和截?cái)鄦栴}。(這也是一個(gè)“使用哪種規(guī)則,取決于當(dāng)前使用哪一部份的C++”的例子,參見條目1)
內(nèi)建數(shù)據(jù)類型體積較小,所以一些人得出這樣的結(jié)論:所有體積較小的類型都適合使用傳值,即使它們是用戶自定義的。這是一個(gè)不可靠的推理。僅僅通過一個(gè)對象體積小并不能判定調(diào)用它的拷貝構(gòu)造函數(shù)的代價(jià)就很低。許多對象——包括大多數(shù)STL容器——其中僅僅包含一個(gè)指針和很少量的其它內(nèi)容,但是復(fù)制此類對象的同時(shí),它所指向的所有內(nèi)容都需要復(fù)制。這將付出十分高昂的代價(jià)。
即使體積較小的對象的拷貝構(gòu)造函數(shù)不會帶來巨大的開銷,它也會引入性能問題。一些編譯器對內(nèi)建數(shù)據(jù)類型和用戶自定義數(shù)據(jù)類型是分別對待的,即使它們的表示方式完全相同。比如說一些編譯器很樂意將一個(gè)單純的double值放入寄存器中,這是語言的常規(guī);但將一個(gè)僅包含一個(gè)double值的對象放入寄存器時(shí),編譯器就會報(bào)錯(cuò)了。當(dāng)你遇到這種事情時(shí),你可以使用引用傳遞這類對象,因?yàn)榫幾g器此時(shí)一定會將指針(引用的具體實(shí)現(xiàn))放入寄存器中。
對于“小型的用戶自定義數(shù)據(jù)類型不適用于傳值方式”還有一個(gè)理由,那就是:作為用戶自定義類型,它們的大小可能會改變。現(xiàn)在很小的類型在未來的版本中可能會變得很大,這是因?yàn)樗膬?nèi)部實(shí)現(xiàn)方式可能會改變。即使是你更改了C++語言的具體實(shí)現(xiàn)都可能會影響到類型的大小。比如,在我編寫上面的示例的時(shí)候,一些對標(biāo)準(zhǔn)庫實(shí)現(xiàn)中string的大小竟然達(dá)到了另一些的七倍。
總體上講,只有內(nèi)建數(shù)據(jù)類型、STL迭代器和函數(shù)對象類型適用于傳值方式。對于所有其它的類型,都應(yīng)該遵循本條款中的建議:盡量使用引用常量傳參,而不是傳值。
時(shí)刻牢記
l 盡量使用引用常量傳參,而不是傳值方式。因?yàn)橐话闱闆r下傳引用更高效,而且可以避免“截?cái)鄦栴}”。
l 對于內(nèi)建數(shù)據(jù)類型、STL迭代和函數(shù)對象類型,這一規(guī)則就不適用了,對它們來說通常傳值方式更實(shí)用。