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