4. 類成員函數(改變第2種的)
設計類改變成員變量的成員函數,需要考慮的因素非常多,但是這些因素大致可以分爲兩類:一類是比較通用的,另一類呢就是有類體系的前提;
(1)是否真需要成爲成員函數
(2)是否有必要返回對象?如果有必要返回對象,那麼不要返回其引用
(3)函數參數寧以pass-by-reference-to-const傳遞替換pass-by-value
(4)是否需要提供一些適合類操作的運算符?如果是,那麼提供哪些運算符重載是合理的?
(5)類型是否需要提供轉換?
(6)類成員函數是否與類繼承關係有關?如果有,什麼樣的繼承關係?如何審慎應用繼承關係
(1)~(3)應該可以被視爲是第一類,通用性的考慮因素;後三者則該被視爲是與類層級關係有關係的考慮因素;
(1)是否真的需要稱爲成員函數
- 代碼摘自《Effective C++》條款23
- class WebBrowser {
- public:
- ...
- void clearCache();
- void clearHistory();
- void removeCookies();
- ...
- void clearEverything();
- };
在上述的代碼中表示了一個WebBrowser類,在這樣一個類中提供了很多成員操作函數,如clearCache(), clearHistory(), removeCookies()等,其中許多用戶想提供一個整體化執行這些上述三個操作的函數,因此又提供了一個clearEverything()的類成員函數;
當然,這個功能可以由另一個非成員函數調用適當的成員函數而實現:
- void clearBrowser(WebBrowser& wb){
- wb.clearCache();
- wb.clearHistory();
- wb.removeCookies();
- }
那麼上面這兩種實現,哪個更好呢?或許有些人會選擇clearEverything()成員函數更好些,因爲這樣更符合面向對象設計原則(數據與數據操作綁定在一起)。實際上,這個選擇並不是遵守想象中這條設計原則,因爲這個選擇帶來了別clearBrowser更差的封裝性。那麼爲什麼這麼說呢?
當一個類的提供了很多的成員函數可以用於操作或者改變private成員變量,那麼意味着這個類沒什麼封裝性可言。就此而言,clearBrowser()因爲不是成員函數,並未給客戶提供增加操作類私有成員變量的可能性,因此封裝性比clearEverything()好。更重要的一點是non-member函數,或者non-friend函數可以爲類設計提供更好包裹彈性(package flexibility)。
再考慮下面的代碼
- 代碼摘自《Effective C++》條款24
- class Rational {
- public:
- Rational(int numerator = 0, int denominator = 1);
- int numerator() const;
- int denominator() const;
- const Rational operator* (const Rational& rhs) const
- private:
- ...
- };
- Rational oneEighth(1,8);
- Rational oneHalf(1,2);
- Rational result = oneHalf * oneEighth; //good
- result = result * oneEighth; // good
- result = oneHalf * 2; //good, but implicit type conversion occurs
- result = 2 * oneHalf; //error!
oneHalf是一個內含operator*函數的class的對象,所以編譯器調用該函數。但是當整數2並沒有相應的class,也就沒有operator*成員函數。編譯器也會嘗試尋找可被調用的non-member operator*,可惜的是也沒有;因此報錯了。
如果上述的operator*成員函數以non-member函數的形式提供
- const Rational operator*(const Rational& lhs, const Rational& rhs){
- return Rational(lhs.numerator() * rhs.numerator(),
- lhs.denomenator() * rhs.denominator());
- }
這樣上述result = 2 * oneHalf就通過編譯了。
因此根據上述兩個例子,考慮函數是否真的有必要稱爲成員函數;
《Effective C++》條款23, 24:
條款23:寧以non-member, non-friend替換member函數
條款24:若所有參數皆需類型轉換,請爲此採用non-member函數
(2)是否有必要返回對象?如果有必要返回對象,那麼不要返回其引用
- //代碼摘自《Effective C++》條款21
- const Rational& operator*(const Rational& lhs,
- const Rational& rhs){
- Rational result(lhs.n*rhs.n, lhs.d*rhs.d); //on-stack
- return result;
- }
- const Rational& operator(const Rational& lhs,
- const Rational& rhs){
- Rational *result = new Rational(lhs.n*rhs.n, lhs.d*rhs.d); //on-heap
- return *result;
- }
注意引用的語義只是另一個既有對象的別名;如果那個既有的對象已經被銷燬了,或者其他的原因已經不存在了,再進行引用招致不必要的風險了。所以返回引用時,一定是在函數外已經創建,且函數可見的變量或者對象,而不是函數體內local的on-stack或者on-heap創建的函數局域變量或者對象;這是很危險的操作!
(3)函數參數寧以pass-by-reference-to-const傳遞替換pass-by-value
C++默認方式下,是通過pass-by-value傳遞函數參數的,因此都是函數參數默認情況以傳遞的參數爲初始值,調用參數類型的拷貝構造函數構造一個副本,因此操作起來成本可謂是十分昂貴的。
- 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;
- };
值傳遞方式:
- bool validateStudent(Student s);
- Student plato;
- bool platoIsOK = validateStudent(plato);
每次以pass-by-value方式調用validateStudent的成本:
調用Student的拷貝構造函數一次
調用Person的拷貝構造函數一次
Student對象內包含的兩個string對象
Person對象內包含的兩個string對象
一旦使用完畢後,相應地還需要調用析構函數。因此總成本是6次構造函數,6次析構函數。
引用傳遞
- bool validateStudent(const Student& s);
這種方式避免了不必要的6次構造函數和6次析構函數成本;但是注意到上述的引用傳遞中,更重要的是使用了const修飾了引用傳遞的參數;因爲在值傳遞中,函數實際上是對參數的副本做操作,而引用傳遞中,函數直接操作參數本身,並非副本。爲了避免函數對傳進的參數有改動,使用const修飾,就不必擔心validateStudent是否會改變傳入的那個參數了。
另外以引用傳遞方式傳遞參數還可以避免對象切割問題。對於某些內置類型而言,pass-by-value實際上有可能比pass-by-reference效率更高些,爲什麼呢? 指針的調用的成本比起一些內置類型的拷貝成本更高,取決於機器instruction的位數,以及內置類型的設定長度。
(4)是否需要提供一些適合類操作的運算符?如果是,那麼提供哪些運算符重載是合理的?
用戶可以重載的運算符
- + - * / % ^ &
- | ~ ! = < > +=
- -= *= /= %= ^= &= |=
- << >> >>= <<= == != <=
- >= && || ++ -- ->* ,
- -> [] () new new[] delete delete[]
用戶不可以重載的運算符
- :: //scope resolution
- . //member selection
- .* //member selection through pointer to member
二元運算符與一元運算符
× 一個二元運算符可以定義爲取一個參數的非靜態成員函數,也可以定義爲取兩個參數的非成員函數。
× 一個一元運算符可以定義爲無參數的非靜態成員函數,也可以定義爲取一個參數的非成員函數;
那麼根據上述運算符的定義,除了考慮需要提供哪些合理的類運算符之外,更需要考慮的就是這類運算符的提供應該以non-member的方式提供,還是以member的方式提供;(參考(1))
另外,那就是需要注意一些特殊意義的運算符以及相關的注意事項;
I. operator=返回一個reference to *this; 需要注意處理"自我賦值"
II. operator(); // function call
I. operator= 返回一個reference to *this; 需要注意處理"自我賦值"
- int x, y, z;
- x = y = z = 15;
爲了實現上面的“連續賦值”,賦值運算符必須返回一個reference指向運算符左側實參;
當然這個不僅適用於以上標準賦值形式,也適用於所有賦值相關運算。
- class Widget { ... };
- Widget w;
- ...
- w = w;
或許上面的代碼已然讓我們感覺到驚訝!怎麼會存有這樣的賦值呢?當然會有,下面的這個就比較隱蔽了
- a[i] = a[j]; //i==j
如果運用對象管理資源,而且你可以確定所謂“資源管理對象”在copy發生時有正確的舉措。這種情況下賦值運算符或許是“安全”的,不需要額外小心。但是如果在嘗試自行管理資源時,很可能會掉進“在停止使用資源前意外釋放了它”的陷阱。看下面的code
- class Bitmap { ... };
- class Widget {
- ...
- private:
- Bitmap *pb;
- };
- Widget& Widget::operator=(const Widget& rhs){
- delete pb;
- pb = new Bitmap(*rhs.pb);
- return *this;
- }
如果上面的operator=函數內的*this和rhs是一個對象的話,那麼delete不僅僅銷燬了當前的pb,而且刪除了rhs.pb;想象這樣的後果會怎樣?解決方法可以是identity test,也可以是copy and swap。
- Widget& Widget::operator=(const Widget& rhs){
- if(this == &rhs) return *this; //identity test
- delete pb;
- pb = new Bitmap(*rhs.pb);
- return *this;
- }
- /*
- * below is copy and swap
- */
- class Widget {
- ...
- void swap(Widget& rhs);
- ...
- };
- Widget& Widget::operator=(const Widget& rhs){
- Widget temp(rhs);
- swap(temp);
- return *this;
- }
II. operator() // 函數調用
相當於一個二元運算符@ : expression @ expression-list
重載函數調用運算符非常有用,提別是對定義那些只有一個運算的類型,和那些具有某個主導運算的類型。
函數調用運算符最明顯或許也是最重要的應用是爲了對象提供常規的函數調用語法形式,使它們具有像函數一樣的行爲方式。一個行爲像函數的對象常被稱爲函數對象。
- class Add {
- complex val;
- public:
- Add(complex c):val(c){}
- Add(double r, double i){val=complex(r,i);}
- void operator()(complex& c) const { c += val; }
- };
- void h(vector<complex>& aa, list<complex>& ll, complex z){
- for_each(aa.begin(), aa.end(), Add(2,3));
- for_each(ll.begin(), ll.end(), Add(z));
- }
(5)類型是否需要提供轉換?
原則上我們並不希望轉換,如果需要轉換,那麼儘量縮小轉換的使用範圍。其他原則同(4)。注意隱式類型轉換。
(6)類成員函數是否與類繼承關係有關?如果有,什麼樣的繼承關係?如何審慎應用繼承關係
繼承關係在C++語言中,十分複雜。繼承關係也有如下種類:
I.公有繼承【is a的關係,適用於base類的每件事也可以應用到其衍生類】
II.保護繼承
III.私有繼承【is implementated in terms of a 】
IV.接口繼承【純虛函數】
V.實現繼承【非純虛函數、已經實現的成員函數】
VI.多重繼承
VII.虛繼承 【鑽石繼承,虛基類】
在具體說到設計成員函數的考慮之前,有必要逐一說明一下以上的幾種繼承關係;
I. 公有繼承
- class Bird {
- public:
- virtual void fly();
- ...
- };
- class Penguin: public Bird {
- ...
- };
- class derived_class_name:public base_class_name
即表示公有繼承;
切忌,在C++的語義中,公有繼承表示着“is a”的邏輯關係。
如上述代碼通過公有繼承表示這樣一個邏輯關係:“企鵝是一種鳥類“;雖然從動物學分類的角度看,這種邏輯關係是正確的,但是從C++語義上不僅僅單純地表示了企鵝是一種鳥類,而且還表現出了更深一層的語義,那就是”企鵝會飛”這樣一個虛假的事實,即使這是違背程序員意志的一種錯誤語義。
因此,在面向對象程序設計中涉及公有繼承關係時,需要知道C++的語義不僅僅單純地表示了“is a"這樣一個邏輯關係,還表示了更深一層的語義:即基類可完成的動作,同樣地可以應用到其子類中,因爲每個衍生類對象也都是一個基類對象。
II. 保護繼承
- class drived_class_name:protected base_class_name
表示保護繼承;
保護繼承表現的邏輯關係不像公有繼承和私有繼承那樣十分明確。從語義上說,這種繼承關係允許子類知道它與基類的繼承關係;
III. 私有繼承
- class drived_class_name:private base_class_name
表示私有繼承;
私有繼承表現的邏輯關係是”is implemented in terms of"。
在C#,JAVA語言中,默認的繼承關係只有公有繼承;(雖然通過compostion的方式可以表現出私有繼承的邏輯關係)
IV. 接口繼承與實現繼承
C++中並沒有abstract關鍵字表示該類是抽象類;但是如果類中含有純虛函數,那麼該類就是抽象類。
- class Shape { //abstract class
- public:
- virtual void rotate(int) = 0; //pure virtual function
- virtual void draw() = 0; // pure virtual function
- virtual bool is_closed() = 0; //pure virtual function
- // ...
- };
抽象類不能實例化,因此必須通過繼承,在子類覆蓋純虛函數後或者提供純虛函數的實現,纔可實例化子類對象。那麼在抽象類中的純虛函數就是接口繼承,而非純虛函數或者已經提供實現的函數就是實現繼承。
JAVA,或C#語言中的interface關鍵字定義的接口類十分類似C++的虛基類(即只有純虛函數聲明的抽象類)。JAVA或C#的一個類可實現多接口,實際內部上就是多重繼承。爲什麼這麼說呢?原因在虛繼承一節中具體說明。
VI.多重繼承
多重繼承,顧名思義,一個子類(衍生類)可以繼承多個基類;例如:
- class File { ... };
- class InputFile: public File { ... };
- class OutputFile: public File { ... };
- class IOFile : public InputFile, public OutputFile { ... };
多重繼承在一些特定的應用場景下,的確給我們帶來了好處,但是總是有弊的一面,如果繼承的基類中,有名稱相沖突的成員函數,或者成員變量,都會導致子類在使用過程中的歧義。另外,像上面的代碼中所表示的,IOFile中含有了兩套File類的成員變量,這顯然導致了不必要的空間浪費。因此,C++又引入了虛繼承;
VI.虛繼承 【鑽石繼承,虛基類】
- class File { ... };
- class InputFile: virtual public File { ... };
- class OutputFile: virtual public File { ... };
- class IOFile: public InputFile, public OutputFile { ... };
虛繼承解決了因多重繼承招致的基類成員變量重複的問題。但是虛繼承也帶來更復雜的問題與後果:
- 虛繼承的那些實例化的類對象通常體積要比那些非虛繼承產生的類對象要大
- 訪問虛基類的成員變量時,也比訪問非虛基類的成員變量速度慢
- 虛基類的成本還包括其他方面。支配虛基類初始化的規則比起非虛基類的情況更復雜且不直觀。
缺省情況下,虛基類的初始化責任是由繼承體系中的最低層的衍生類負責,這暗示了若派生自虛基類而需要初始化,必須認知虛基類,不論那些虛基類距離多遠。同時也暗示了當一個新的派生類加入到繼承體系中,它有可能承擔其虛基類的初始化責任。
注意,JAVA和C#的實現多接口的機制其實質就是多重虛繼承;爲了避免純虛基類初始化責任,因此JAVA和C#不允許在接口類中含有任何數據。
通常前三種繼承可以與後四種進行組合搭配起來。(哇!C++在這點上,爲程序開發人員提供了很大的靈活性,但是作爲程序開發人員來講,必須爲這種靈活性付出代價!增加了學習曲線和複雜度。不過這種複雜度一旦under control,那變庖丁解牛,遊刃有餘了!似乎這也是很多C++高手們驕傲的地方)。
書歸正傳,成員函數的設計一定要注意到所在類在類層級結構中扮演的角色,然後再考慮繼承關係屬於上述哪幾種;最後,在特定繼承關係下,考慮應該如何避免一些歧義的產生,如何避免高額的成本等等。以下請參考《Effective C++》提供一些考慮因素
- 如果是基類的成員函數,那麼是否需要用virtual聲明?
- 如果需要用virtual來聲明能否可以用其他的選擇替代virtual?(別忘記基類中應該提供virtual的析構函數)
- 如果是衍生類中的成員函數,那麼該成員函數是否是因爲繼承關係得來的?
- 如果是,繼承得來的是接口還是實現?如果是實現,則絕不要重新定義繼承而來的函數實現,也絕不重新定義繼承而來的缺省參數值。(參考《Effective C++》條款36,37)
- 如果衍生類中成員函數與繼承得來的成員函數名稱相同,儘量避免遮掩繼承來的名稱;(參考《Effective C++》條款33)