Effective C++筆記之十四:以pass-by-reference-to-const替換pass-by-value
預設情況下C++ 以by value方式(一個繼承自C 的方式)傳遞物件至(或來自)函式。函式引數都是以實際實參的復件(副本)為初值。這些復件(副本)系由物件的copy建構函式產出,這可能使得pass-by-value成為昂貴的(費時的)操作。考慮以下class 繼承體系:
class Person { public: Person (); virtual ~Person(); ...... private: std::string name; std::string address; }; class Student: public Person { public: Student(); ~Student(); ...... private: std::string schoolName; std::string schoolAddress; };
現在考慮以下程式碼,其中呼叫函式validateStudent ,後者需要一個Student 實參(by value) 並返回它是否有效:
bool validateStudent(Student s); // 函式以by value方式接受學生
Student plato; // 柏拉圖,蘇格拉底的學生
bool platoIsOK = validateStudent(plato);// 呼叫函式
當上述函式被呼叫時,發生什麼事?無疑地Student 的copy建構函式會被呼叫,以plato 為藍本將s 初始化。同樣明顯地,當validateStudent遺回s 會被銷燬。因此,對此函式而言,引數的傳遞成本是"一次Student copy建構函式呼叫,加上一次Student解構函式呼叫"。
但那還不是整個故事喔。Student物件內有兩個string物件,所以每次構造一個Student物件也就構造了兩個string物件。此外Student 物件繼承自Person 物件,所以每次構造Student物件也必須構造出一個Person物件。一個Person物件又有兩個string物件在其中,因此每一次Person構造動作又需承擔兩個string構造動作。最終結果是,以by value方式傳遞一個Student物件會導致呼叫一次Student copy建構函式、一次Person copy 建構函式、四次string copy 建構函式。當函式內的那個Student復件被銷燬,每一個建構函式呼叫動作都需要一個對應的解構函式呼叫動作。因此,以by value 方式傳遞一個Student物件,總體戚本是"六次建構函式和六次解構函式" !
這是正確且值得擁有的行為,畢竟你希望你的所有物件都能夠被確實地構造和析構。但儘管如此,如果有什麼方法可以迴避所有那些構造和析構動作就太好了。有的,就是pass by reference-to-const:
bool validateStudent(const Student& s);
這種傳遞方式的效率高得多:沒有任何建構函式或解構函式被呼叫,因為沒有任何新物件被建立。修訂後的這個引數宣告中的const 是重要的。原先的validateStudent以by value 方式接受一個Student引數,因此呼叫者知道他們受到保護,函式內絕不會對傳入的Student 作任何改變; validateStudent只能夠對其復件(副本)做修改。現在Student 以by reference 方式傳遞,將它宣告為const 是必要的,因為不這樣做的話呼叫者會憂慮validateStudent會不會改變他們傳入的那個Student。以by reference方式傳遞引數也可以避免slicing ( 物件切割)問題。當一個derived class 物件以by value方式傳遞並被視為一個base class 物件, base class 的copy建構函式會別調用,而"造成此物件的行為像個derived class 物件"的那些特化性質全被切割掉了,僅僅留下一個base class 物件。這實在不怎麼讓人驚訝,因為正是base class 建構函式建立了它。但這幾乎絕不會是你想要的。假設你在一組classes 上工作,用來實現一個圖形視窗系統:
class Window {
public:
......
std::string name() const; // 返回視窗名稱
virtual void display() const;// 顯示視窗和其內容
};
class WindowWithScrollBars: public Window {
public:
......
virtual void display{} const;
};
現在假設你希望寫個函式列印視窗名稱,然後顯示該視窗。下面是錯誤示範:
void printNameAndDisplay(Window w)// 不正確!引數可能被切割。
{
std::cout << w.name();
w.display() ;
}
當你呼叫上述函式並交給它一個WindowWithScrollBars 物件,會發生什麼事呢?
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
喔,引數W 會被構造成為一個Window物件:它是passed by value ,還記得嗎?而造成wwsb" 之所以是個WindowWithScrollBars 物件"的所有特化資訊都會被切除。在printNameAndDisplay 函式內不論傳遞過來的物件原本是什麼型別,引數w 就像一個Window 物件(因為其型別是Window) 。因此在printNameAndDisplay 內呼叫display呼叫的總是Window::display,絕不會是WindowWithScrollBars::display。解決切割(slicing) 問題的辦法,就是以by reference-to- const 的方式傳遞w:
void printNameAndDisplay(const Window& w)// 很好,引數不會被切割
{
std::cout << w.name() ;
w.display();
}
現在,傳進來的視窗是什麼型別,w 就表現出那種型別。如果窺視C++ 編譯器的底層,你會發現, references往往以指標實現出來,因此pass by reference 通常意味真正傳遞的是指標。因此如果你有個物件屬於內建型別(例如int) , pass by value 往往比pass by reference 的效率高些。對內建型別而言,當你有機會選擇採用pass-by-value或pass-by-reference-to-const 時,選擇pass-by-value並非沒有道理。這個忠告也適用於STL 的選代器和函式物件,因為習慣上它們都被設計為passed by value。迭代器和函式物件的實踐者有責任看看它們是否高效且不受切割問題(slicing problem) 的影響。
內建型別都相當小,因此有人認為,所有小型types 都是pass-by-value的合格候選人,甚至它們是使用者自定義的class 亦然。這是個不可靠的推論。物件小並不就意味其copy建構函式不昂貴。許多物件一一包括大多數STL 容器一一內含的東西只比一個指標多一些,但複製這種物件卻需承擔"複製那些指標所指的每一樣東西"。那將非常昂貴。
即使小型物件擁有並不昂貴的copy建構函式,還是可能有效率上的爭議。某些編譯器對待"內建型別"和"使用者自定義型別"的態度截然不同,縱使兩者擁有相同的底層表述(underlying representation) 。舉個例子,某些編譯器拒絕把只由一個double組成的物件放進快取器內,卻很樂意在一個正規基礎上對光禿禿的doubles 那麼做。當這種事發生,你更應該以by reference 方式傳遞此等物件,因為編譯器當然會將指標(references的實現體)放進快取器內,絕無問題。
"小型的使用者自定義型別不必然成為pass-by-value優良候選人"的另一個理由是,作為一個使用者自定義型別,其大小容易有所變化。一個type 目前雖然小,將來也許會變大,因為其內部實現可能改變。甚至當你改用另一個C++ 編譯器都有可能改變type的大小。舉個例子,在我下筆此刻,某些標準程式庫實現版本中的string 型別比其他版本大七倍。
一般而言,你可以合理假設" pass-by-value 並不昂貴"的唯一物件就是內建型別和STL 的迭代器和函式物件。至於其他任何東西都請遵守本條款的忠告,儘量以pass-by-reference-to-const 替換passφy-value。
需要記住的
1.儘量以pass-by-reference-to-const替換pass-by-value。前者通常比較高效,並可避免切割問題(slicing problem) 。
2.以上規則並不適用於內建型別,以及STL 的迭代器和函式物件。對它們而言,pass-by-value往往比較適當。