有效創建一個類(二)

上一篇記錄了在創建一個類時,首先要考慮這個類的構造函數、拷貝構造函數、拷貝賦值操作、以及析構函數的聲明及定義;那麼本篇主要說明的是有關類成員的聲明及定義;有關類成員聲明的工作實際上大多數時候都是在決定類構造函數、拷貝函數及析構函數之前需要考慮的。那麼爲什麼我要把構造函數等作爲創建類考慮的第一個因素呢?因爲在大多數軟件設計的情況下,無論這個軟件是一個大型的應用程序還是其中的微小組件,都是先進行概要設計再進行詳細設計。而概要設計的核心工作就是給出組件完成什麼功能,爲了完成目標功能如何與其他組件協同工作,遵守什麼樣的協定。詳細設計纔會根據功能以及組件間的協定給出類定義。那麼這就意味着概要設計完成後,類的設計者應該已經對需要定義的類與類之間的鬆耦合關係、類層次結構甚至類應該擁有一些什麼樣的成員有了一個大致藍圖。而根據上述這三個因素,構造函數、拷貝函數、以及析構函數可以優先考慮。當然不能否認,在具體的類成員(尤其下述的前兩種)出來後,可能對構造函數等一族需要進行進一步修改。 


這部分工作雖然看上去簡單,但是如果視同創建一個類與創建一個類型相同的話,那麼這部分工作就變得不是那麼簡單了。 

大致類成員分成四種: 

1. 類常量 
2. 類變量 
3. 類成員函數(讀取第2種的) 
4. 類成員函數(改變第2種的) 

下面我們針對每一種類成員分別說明。 

1. 類常量 

作爲類專屬常量,爲了將常量的作用域限制於類內,必須讓它成爲類的一個成員;而爲確保此常量至多隻有一份實體,你必須讓它成爲一個static成員: 

C++代碼 
  1. class Cache {  
  2. private:  
  3.     static const int BUFSIZE = 4196;  
  4.     char buffer[BUFSIZE];  
  5.     // ...  
  6. };   

上述只是聲明式而不是定義式,如果要取某個類專屬常量的地址甚至即使不取其地址時,C++編譯器卻堅持要看到一個定義式,所以我們必須提供定義式如下: 

C++代碼 
  1. const int Cache::BUFSIZE;  

這個定義式請放入實現的文件中而非頭文件中。因爲聲明時,類常量獲得初始值 ,因此定義時不可以再設初始值。順帶一提,宏定義#define無法創建一個類專屬常量,因爲#define不重視作用域。一旦宏被定義,它就在其後的編譯過程中有效。這表示不僅不能定義類專屬常量,而且不能提供任何封裝性。 老的編譯器也許不支持上述語法,它們不允許static成員在其聲明式上獲得初始值,另外所謂的"in-class初值設定";也只允許對整數常量進行。那麼怎麼辦呢?可以通過下述的方式進行: 

C++代碼 
  1. class Cache {  
  2. private:  
  3.    static const int BUFSIZE;  
  4.    char buffer[BUFSIZE];  
  5.    // ... ...   
  6. };  
  7. const int Cache::BUFSIZE=4196;   


假如在編譯期間需要一個類常量值,例如上述的Cache::buffer的數組聲明式中,編譯器堅持必須在編譯期間知道數組的大小。這時候萬一編譯器不允許“staic整數型類常量完成in class初值設定”,可使用the enum hack補償。 
C++代碼 
  1. class Cache {  
  2. private:  
  3.   enum { BUFSIZE=4196};  
  4.   char buffer[BUFSIZE];  
  5.   // ... ...  
  6. };  

關於enum hack我會詳細介紹。 

除了類常量外,還有一種是non-static的const成員,這種用const修飾的成員向編譯器表達了一個語義約束,表示這個成員不該被改動。當然這個語義約束理解起來並不困難,而編譯器會強制實施這項約束。如果類中成員有這樣的約束事實存在,那麼請一定清晰的告訴編譯器,以獲得它的幫助。 

const可謂多才多藝。它可以用來修飾 
(1)global或者namespace作用域中的常量 
(2)文件、函數、或者block scope中被聲明爲static的對象 
(3)類內部的static或者non-static的成員變量 
(4)指針本身,指針所指對象; 

C++代碼 
  1. char greeting[] = "Hello";    
  2. char *p = greeting;         //non-const ptr, non-const data  
  3. const char *p = greeting;   //non-const ptr, const data  
  4. charconst p = greeting;   //const ptr, non-const data  
  5. const charconst p = greeting; //const ptr, const data  


有人發明的一種指針的讀法比較有助於記憶和識別,這種指針讀法就是從右往左念。 
例如,最後一個p是常量指針指向字符常量;另外,在《The C++ Programming Language》一書中,曾經提及過“引用”可以理解爲常量指針,一旦被初始化或者賦值,其指針地址不可更改。 

下面要知道const修飾的標識符什麼時候被初始化? 
實際上const修飾的標識符有兩種,一種稱爲編譯器const對象;另一種稱爲運行時const對象(函數參數爲主);編譯期const對象是針對編譯器而言,如果用於初始化const對象的值在編譯期即被確定,則通過類型檢查後用這個初始值代替這個const對象本身(聽起來好像跟宏#define相似啊):)而對於運行時const對象,其初始化時機和對象本身被創建的時機相同。作爲函數參數的const對象(包括任何引用類型)在參數傳遞生成參數時同時初始化。 

2. 類變量 

類變量感覺上好像沒什麼可說的,但是這部分涉及到了OO的三大特性之一——封裝。 
類變量也稱爲數據成員,那麼在一個類中的數據成員可以用public, protected, private修飾。這也是OO的封裝級別,public意味着完全不需要封裝,protected意味着派生類可以訪問,但並不比public更具有封裝性,private表示只有類成員函數以及友元類函數可以訪問。原則上,類變量要求用private修飾。 

在具體談到某個數據成員的封裝級別之前,我們應該首先考慮這個數據成員是否有必要被封裝;換句話說,如果沒必要封裝,就表示它可以不是該類的數據成員。按照開閉原則和里氏替換原則來說的話,被封裝的數據應該是那些變化的數據,而不是那些不變化的數據。 

一旦決定某數據是需要被封裝在類中的以後,那麼就是決定封裝級別的時候了。考慮封裝級別的時候,就參考下面的引用: 
引用

封裝的重要性比你最初見到它時還重要。如果你對客戶隱藏成員變量(也就是封裝它們),你可以確保class的約束條件總是會獲得維護,因爲只有成員函數可以影響它們。猶有進者,你保留了日後變更實現的權利。如果你不隱藏它們,你很快會發現,即使擁有class源代碼,改變任何public事物的能力還是極端受到束縛,因爲那會破壞太多客戶代碼。Public以爲不封裝,而幾乎可以說,不封裝以爲不可以改變,特別是對被廣泛使用的classes而言。被廣泛使用的classes,是最需要封裝的一個族羣,因爲它們最能夠從“改採用一個較佳實現版本”中獲益。“封裝性與當期內容改變時可能造成的代碼破壞量成反比” -- 參考《EFFECTIVE C++》條款22, 23. 


另外需要考慮成員變量的聲明通過採用外覆類型(wrapper types)可以使得用戶不易誤用。例如:(這個例子摘自《Effective C++》) 
C++代碼 
  1. class Date{  
  2. public:  
  3.   Date(int month, int day, int year);  
  4. private:  
  5.   int m, d, y;  
  6. };  

乍看之下,這個類變量的聲明看上去挺合理的。但是Date的客戶卻不像想象中的那麼合理使用這個類;例如,歐洲的客戶很容易輸入錯誤的次序傳遞參數: Date d(30, 12, 2010); 更有可能輸入錯誤的日期Date d(2, 30, 2010);那麼怎麼防範呢?很多人第一反應是,應該在所有的接口函數加上一些判斷語句就可以了。如 
C++代碼 
  1. Date::Date(int month, int day, int year){  
  2.  if(month>=1 && month <=12)  
  3.      m = month;  
  4.  else  
  5.      throw bad_date();  
  6.  //...  
  7. };  

這樣,雖然能達到目的,但是不覺得這樣一個構造函數已經很醜陋了嗎?上面的代碼還沒有寫出可以解決客戶容易誤用的第二個錯誤的判斷語句。如果再加上那樣的判斷語句,估計會更醜陋的。那麼還有什麼更好的方法看上去不那麼醜陋嗎? 
C++代碼 
  1. struct Day{  
  2.  explicit Day(int d):val(d){}  
  3.  int val;  
  4. };  
  5.   
  6. struct Month{  
  7.  explicit Month(int m):val(m){}  
  8.  int val;  
  9. };  
  10.   
  11. struct Year{  
  12.   explicit Year(int y):val(y){}  
  13.   int val;  
  14. };  
  15.   
  16. class Date{  
  17. public:  
  18.   Date(const Month& m, const Day& d, const Year& y);  
  19. private:  
  20.   Year y;  
  21.   Month m;  
  22.   Day d;  
  23. };  
  24.   
  25. Date d(30, 12, 2010) // error! wrong type  
  26. Date d(Day(30), Month(12), Year(2010)); // error! wrong type  
  27. Date d(Month(12), Day(30), Year(2010)); // correct!  

針對第二種容易誤用的解決方案,我想可以通過ENUM+外覆類型可以得到更好地解決; 
那麼類變量在聲明時,除了考慮其封裝性外,還需要考慮其合理範圍,儘量避免誤用。 

3. 成員函數(讀取第2種的) 

設計這種成員函數時,在C++語言中需要注意和理解三個事項; 
(1)const 修飾符 
(2)inline 的裏裏外外 
(3)避免返回handler指向對象內部成分 

(1)如上所述,const多才多藝,但const最具威力的用法就是面對函數聲明時的應用;針對一個函數的聲明式,const可以和函數的返回值、各參數、函數自身產生關聯。但是針對第3種const成員函數而言,主要說明下const主要跟函數返回值和函數本身的兩種關聯的意義。 

I. 令函數返回一個常量值,往往可以降低因客戶錯誤而造成的意外,而又不至於放棄安全性和高效性。 
II. 將const實施於成員函數的目的是爲了確認該成員函數可作用於const對象身上。這樣一來,使得class接口比較容易被理解,二來呢,它們使“操作const對象”成爲可能,對於編寫高效率代碼是個關鍵,也很重要。(例如,pass-by-reference to const 這一技術的前提是,我們有const成員函數可用來處理取得的const對象)注意,C++成員函數只因constness不同,可以被重載。這是個非常重要的特性。 

(2)inline修飾符語義是對inline函數的每一次調用都以函數本體替換之。那這個語義跟宏的函數定義不一樣嗎?語義上一樣,但是執行上不一樣,要比宏更好。爲什麼呢?很顯然這樣每一次調用它們時,由於事實上函數已經被函數本體替換,所以不需要蒙受函數調用所招致的額外開銷。當然,至此宏也能這樣做到;但是inline函數實際上只是向編譯器發出這樣一個申請,但是申請的結果完全取決於編譯器優化的結果。這就好像,你申請美國的過境簽證一樣,即使萬事俱備,也未必會得到審批。 

由於inline的語義,有足夠的理由可以相信,這樣會導致程序產生的目標執行代碼會膨脹。如果在一臺資源,尤其是內存吃緊的機器上運行目標代碼時,這樣的代碼膨脹會導致你的程序招致內存換頁所引起的開銷,降低cache命中率。但是如果inline函數本體很小,替換後的結果如果比函數調用更小,那麼我們也有足夠的理由相信產生的目標代碼更小,當然程序執行效率也會很高,也提高了cache的命中率。 

那麼什麼樣的函數本體算很小,可以比函數調用更小呢? 
至少函數體包含循環語句,或者調用virtual函數,再或者利用函數指針調用都會使得inline的申請遭到拒絕。但是僅僅列出這兩個標準,似乎並不是讓人很滿意的答案。幸運的是,現代編譯器大多數都提供了一個診斷級別:如果無法將被申請函數inline化,會發出警告信息。 

另一個慎重使用inline便是由於其語義而導致的debug困難。 

(3) 
C++代碼 
  1. 摘自《Effective C++》- 條款28  
  2. class Point {  
  3. public:  
  4.   Point(int x, int y);  
  5.   ...  
  6.   void setX(int newVal);  
  7.   void setY(int newVal);  
  8.   ...  
  9. };  
  10.   
  11. struct RectData {  
  12.   Point ulhc; //upper left hand corner  
  13.   Point lrhc; //lower right hand corner  
  14. };  
  15.   
  16. class Rectangle {  
  17. public:  
  18.   Point& upperLeft() const { return pData->ulhc; }  
  19.   Point& lowerRight() const { return pData->lrhc; }  
  20. ...  
  21. private:  
  22.   std::tr1::shared_ptr<RectData> pData;  
  23. };  

雖然這樣可以通過編譯,但是卻是個邏輯上自相矛盾的錯誤。一方面upperLeft()和lowerRight()被聲明爲const成員函數,因爲它們的目的只是爲客戶提供一個得知Rectangle相關座標點的方法,而不是讓客戶修改Rectangle。另一方面,兩個函數都返回引用指向private數據,使得private的封裝形同虛設。 

因此: 
(一)、成員變量的封裝性最多隻等於“返回其reference”的函數的訪問級別。 
(二)、const成員函數返回一個引用且引向數據與對象自身有關聯,那麼函數調用者有機會更改對象內部數據。 
發佈了37 篇原創文章 · 獲贊 1 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章