標籤:c++ 設計
本文的11個問題提取自《C++沉思錄》第四章。所有問題的說明均為自己補充。
1 你的類需要一個建構函式嗎?
——正確的定義建構函式,把握好建構函式的職能範圍
- 有些類太簡單,它們的結構就是它們的介面,所以不需要建構函式。
class print{ void print1(){cout<<"1"<<endl;} void print2(){cout<<"2"<<endl;} void print3(){cout<<"3"<<endl;}};
- 有些類很複雜,構造過程就有很複雜的行為,所以必須設計一個建構函式,甚至多個重載建構函式。
class string{public: string(){_buf_ptr = new char [15];} string(const string &); string(const char *);private: char *_buf_ptr;};
- 我常常困擾在這樣的問題上:
“我應該把這部分操作放在建構函式中,還是另外聲明一個函數單獨執行?”
例如:
class socket_connect{public: socket_connect(); PowerOn();private: int sock_fd;};socket_connect::socket_connect(){ sock_fd = socket(AF_INET,SOCK_STREAM,0); init_sockaddr_in();}void socket_connect::PowerOn(){ int ret = bind(sock_fd,(const sockaddr*)&servaddr,sizeof(servaddr)); PERROR(ret,"bind failed!"); ret = listen(sock_fd,BACK_LOG); PERROR(ret,"listen failed!");}
在這裡,根據我們的需求,socket本身在初始化的時候就應該開始監聽了吧?所以我們完全可以把bind()和listen()放到建構函式裡面去嘛!
但是我還是單獨提出來了一個函數PowerOn(),這是有理由的。因為我傾向於以全域變數的方式建立socket,而且不希望在我完成一系列socket之外的初始化前,就有用戶端試圖串連。
嗯,聽起來都很有道理。第一種做法賦予了建構函式更全面的“構造”功能;第二種做法將操作細化,可以更好地控制類的行為。
2 你的資料成員是私人的嗎?
——pubic , private 還是 protected?
在逐漸熟練的使用C++的過程中,我越來越傾向於將所有的資料成員都隱藏起來這樣的做法。看這樣一個例子:
class SOURCE{public: void init(const char*,unsigned int, unsigned int,void*); inline void increase() {source_amount++;}; inline int decrease(unsigned int val){source_amount -= val}; unsigned int get_sleeptime() const {return sleep_time;} unsigned int get_amount() const {return source_amount;} unsigned int get_speed() const {return source_speed; } char* get_name() const { return name; }private: Mutex source_lock char name[20]; unsigned int source_amount; unsigned int sleep_time; unsigned int source_speed;};
看!我把所有的資料成員的存取權限都設定為private了!
如果你很懶。將資料成員直接暴露在public也並不是錯誤的做法,但是這樣的話有兩個致命缺點
失去了對資料成員變化的完全控制
. 我們不知道在何處,在哪裡,資料成員被修改了!暴露資料成員意味著會發生原本希望進行一次++操作,卻意外的被清零這樣的類似問題!
. 所以說,這就相當於你把root許可權給了所有使用者,多可怕的一件事!
不易於修改
. 假設:今天我們的需求是: 每次把這個東西+1,明天需求可能就變成了: 每次把這個東西-1!
. 難道說每一次你都要在所有可能出現修改類成員的地方都把++改為–嗎?這顯然是不現實的!
. 如果我們把資料成員隱藏,僅僅提供一個提供者,那事情就變得簡單了!我只需要修改這個函數的行為就可以了!
. 在上面的例子中,我可以在介面內進行mutex_lock()和mutex_unlock()操作,修改成安全執行緒的自增操作!這很酷!
因此藏起來所有希望保護的資料成員可能是一個不錯的習慣!(儘管定義各種代替訪問行為的函數介面會增加工作量)
3 你的類需要一個無參的建構函式嗎?
這個問題和問題1相近,建構函式執行到什麼程度算好呢?
顯然這個問題沒有標準答案,決定因素是:你對這個class的設計意圖!
class Example{ Example(int p,int q){cout<<p+q<<endl;}};class Example_2{ Expamle_2() {cout<<"nothing"<<endl;}};
如果只提供一個帶參數的constructor,會有哪些損失?
Example eg;Example_2 eg_2;
- 顯然第一個式子是錯誤的。作為類的設計者,你瞭解關於這個類的一切,可能不會犯這樣的錯誤。但是如果別人需要使用你的類呢?這樣的建立對象顯然會帶來一些小問題。
- 考慮
Example a[100],同樣的道理,只提供一個有參建構函式有時候會帶來小麻煩。
好吧,到底該怎麼做呢?
- 如果你的設計意圖很明確:我不希望編譯器給我建立預設建構函式,我希望嚴格控制這個class對象的構造行為
- 那麼放心的僅僅提供有參建構函式,一定沒錯!(但是注意把這個要求傳達給使用者)
- 如果你思考後,覺得提供一個無參建構函式並不被你的設計意圖所排斥,而且不會因為沒有賦給對象一個有意義的初值導致程式崩潰
- 那麼提供一個無參建構函式會讓你的類使用起來更方便!
看看STL吧,string s; string s(const string&);
這是一種很好的參考模型。
- 另外,提供預設值的有參建構函式也可以被用於無參的調用!這也不失為好的解決辦法
class A{public: A(int a = 10) {}};...int main(){ A a(11);//合法 A b;//合法}
4 是不是建構函式應該初始化所有的資料成員?
看起來這個問題似乎有些奇怪,如果用下面的這種問法,應該會好回答一些:
是不是每個資料成員都應該被建構函式初始化?
顯然應該對每個出現的成員進行一個合理的初始化操作。不然出現未定義的行為會讓程式出現意料之外的錯誤。
幾個常用的例子有:
int data = 0;
char *ptr = NULL;
我想大多數人都知道像上面這樣做以避免“未初始化”這樣的錯誤!那麼,建構函式中也理應如此。
不過,也別做的那麼絕對。書中提到:
有時,類會有一些資料成員,它們只在它們的對象存在了一定時間後才有意義。
對於這種成員,要不要初始化?這就需要留到實際問題中去思考了!不過,養成把指標初始化成NULL的習慣一定不會有錯!
5 類需要解構函式嗎?
這個問題不難回答,我常在解構函式中做的就是使用delete來釋放new建立的對象。(同理malloc和free也是如此!)
保證一個new匹配一個delete,不要多也不要少。
有時候唯一需要注意的是,用delete還是delete []。
6 類需要虛解構函式嗎?
這個問題比問題5有意義多了!
首先應該知道:
絕不會用作基類的類是不需要虛解構函式的!
那麼,為什麼解構函式有時候需要被聲明為virtual?
什麼時候應該聲明解構函式為virtual?
class B {};class D: public B {};int main(){ B *b_ptr = new D; //這是正確的 delete b_ptr; //可能會造成錯誤}
只要有人可能會對實際指向基類D對象的、但類型確實B*類型的指標執行delete運算式,就應該給B添加一個虛解構函式!
為什麼這樣做?這是我的解讀
- 首先,你一定知道:衍生類別內部是存在一個完整的基類對象的,衍生類別做的一切變化都是在這個基類對象後面添加的。
- 可以這樣理解:基類
B是1樓,衍生類別D是1樓+2樓。現在我們的指標是B *b_ptr,它的作用範圍只有1樓;但是因為B *b_ptr = new D;,所以這個指標實際指向的對象擁有1樓+2樓。
- 如果我們
delete b_ptr,很顯然,因為指標被編譯器限制作用範圍為1樓,所以只會是1樓被delete了!但是別忘了,我們的對象是有2樓的!
- 那麼1樓被拆掉,2樓會怎樣呢?當然是直接懸空!不用懷疑,這是相當危險的!殘餘的2樓不僅沒有被安全釋放,還因為1樓已經消失,使得我們無法再通過1樓上到2樓。
因此,虛解構函式是有必要的!對於一個使用不恰當的指標(上面的b_ptr),我們一旦發現這個指標是不合理的,就通過動態綁定的方式,給他一個假的樓層讓它去析構。如此就避免了2樓還在1樓卻沒了的情況!
虛解構函式通常是空的
7 你的類需要一個賦值操作符嗎?
等號=太常用了,以至於我們經常忘記確定class是否有=操作就去使用它。
這的後果是什麼呢?
class A{public: A(){name = new char[100];} ~A(){delete [] name;}//注意這裡private: char *name;};void function(const A& base){ A copy = base;}//function結束後調用解構函式int main(){ A base; function(base);}//main結束後調用解構函式
上面的代碼中,function()函數內,建立了一個名為copy的對象,並使用了=操作。嘗試運行一下,會發生什嗎?
————————崩潰啦!!!
為什嗎?
- 因為預設的
=操作符只是簡單地賦值了一份記憶體中的。copy和base的資料成員char *name值是相等的,指向同一片記憶體!
- 這就造成,在
function()調用結束後,析構copy,name指向的記憶體已被刪除。
- main函數結束後,還需要析構
base一次。而此時name已經被釋放了!再次delete必然崩潰!
給我們的啟示:
- 動態分配的資源,一定要考慮重載
=。
這裡可以這樣修改:
A& A::operator=(const A&base){ char *temp = new char [100]; strcpy(temp,base.name); name = temp; //others = base.others; return *this;}
- 良好的指標習慣可以讓你在類設計不完善時避免程式崩潰!
~A(){delete [] name;name = NULL;}
——拒絕懸垂指標,從我做起!
上面的操作雖然沒有達到預期的目的,但是至少不會崩潰嘛!
TIPS:
通常operator=應該返回一個引用class&,並且由return *this結束以保證與內建的複製操作符一致。
8 你的類需要一個複製建構函式嗎?
如果需要一個複製建構函式,那麼你多半也需要定義一個=吧。即使你不需要一個=,它們的實現方法也是一致的。所以請參見問題7
(當然,如果你的複製建構函式(copy constructor)設計的足夠好的話,你完全可以用copy constructor來實現operator=而不是用=來實現copy constructor)
9 你的賦值操作符能正確的將對象賦給對象本身嗎?
——複製自己帶來的麻煩
記得《劍指offer》最開始的面試題嗎?
如下類型A的聲明,為該類添加一個賦值運算子函數。
class A{public: A(char *pData = NULL);//pData一般用new動態分配 A(const A&str); ~A();private: char *pData;};
還記得問題7,8嗎?
我們的思路很明確:在賦值運算子函數內,先調用delete,釋放原來的字串,然後再new一個字串,用strcpy拷貝過來!(再次強調:delete後先賦值為NULL是好習慣)
那麼想一想,下面的語句,會發生什麼呢?
A origin;origin = origin;
origin 先執行delete,然後拷貝自己。很顯然,origin玩脫了!複製自己的時候,已經delete過了,顯然無法完成正確的賦值!
核心問題就是這個啦——防止複製自身!
解決方案如下:
A& A::operator =(const A& origin){ if(this==&origin) return *this; delete [] pData; pData = NULL; pData = new char [strlen(origin.pData)+1]; strcpy(pData,origion.pData); return *this;}
另一個可行,且較好的方案是,用temp臨時儲存起來origin的值。(調換語句順序)
A& A::operator =(const A& origin){ char *temp = new char[strlen(origion.pData)+1]; strcpy(temp,origin.pData); delete [] pData; pData = temp; return *this;}
既然說到了這裡,同樣要提到copy constructor裡面的坑——下面哪個複製建構函式是正確的?
class A{public: A(A origion);//1 A(A& origion);//2 A(const A& origion);//3};
10 const總是很重要!
在問題9的末尾,已經提到了 在賦值運算子和複製建構函式中使用const限定符 有關的東西。
我們使用const是為了防止該變數被修改
原則上來講,凡是不希望改變資料成員的函數我們都給它聲明為const
那麼,當你真的使用const限定符後,對於某些成員函數,添加const同樣也是必須的!
假如一個class被限定為const,編譯器會判定一切非const的成員函數的調用為非法!
因為這些函數可能會擁有改變資料成員值的行為!
即使它實際上並沒有改變資料成員的想法。
這個例子是一個很好的說明:
class Vec{public: int len_const() const {return len;} int len(){return len;} int len;};void use_Vec(const Vec& origin){ int ret1 = origin.len_const();//合法的 int ret2 = len();//error:不相容的類型限定符}
以上的問題常常被忽略,所幸IDE內建語法檢查,讓我們能夠及時發現這類錯誤!
11 刪除數組時你記住了用delete []嗎?
——new和delete的匹配
這條實在是簡單的很,但是實際應用起來卻不知忘記了多少次!
每一個new都要匹配一個delete。除非你確認程式馬上就會結束。
每一個new [ ] 都應該匹配一個delete[ ]。遵循對稱原則總沒有錯!
完
《C++沉思錄》:類設計者的核查表——有關class的11問