程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++——對象復制控制

C++——對象復制控制

編輯:關於C++

對酒當歌,人生幾何?譬如朝露,去日苦多。

人的一生可能驚濤駭浪,更可能波瀾不驚,這次我們就來探討一下“對象”(當然各位同學自己的對象不在本次討論范圍之內O(∩_∩)O,課後自己討論吧)一生的“起起落落”,即對象的復制控制。

復制控制包括三個部分:復制構造函數的調用、賦值操作符的調用、析構函數的調用。下面就這三個操作來逐一進行介紹,大家共同學習(*^-^*)

 

一、復制構造函數

復制構造函數:首先它也是構造函數,所以函數名與類名相同,沒有返回值;其次,它只有一個形參,且該形參是對本類類型對象的引用(常用const修飾)。

為什麼復制構造函數的參數需要用引用呢?我們先看復制構造函數的調用場景:

1. 用一個同類對象顯示或隱式初始化另一個對象時。

2. 復制一個對象,把它作為實參傳遞給函數時。

3. 從函數返回一個對象時。

4. 初始化順序容器中的元素時。

5. 根據元素初始化式列表初始化數組元素時。

假設復制構造函數的參數是該類類型的對象,而不是引用。那麼當我們在給一個函數傳遞實參的時候(其它情況也是一樣的道理)會隱式調用復制構造函數,而復制構造函數本身又需要該類對象的實參,又會去調用該類的復制構造函數!從而將形成函數調用的“死循環”。

初始化和賦值有時候對於新手來說可能不是一件容易區分的事情,我就稍作說明吧。初始化就是類對象剛剛形成的同時對其進行值的設置,而賦值則是對象形成後再對其進行值的設置(在這裡,我們把同一個語句中的操作當成是同時進行的操作,雖然從微觀的角度來看並非如此)。

假設有如下的代碼調用:

 

 

{

A a1 = a2;//a2也是類A的對象,對象a1在形成的同時進行值設置,所以調用復制構造函數,因為這是初始化操作。

}

 

{

A a1;

a1 =a2;//此時調用賦值操作符函數,因為此時不再是初始化了。

}

 

和默認構造函數一樣,C++ Standard上說,如果類中沒有聲明拷貝構造函數,就會有一個隱式地聲明或者定義出現。但是拷貝構造函數又會被分為trivial和nontrivial,實際上只有nontrivial的時候編譯器才會合成相應的函數(很多時候都是這樣的,習慣就好了)。而且,C++ Standard要求編譯器盡量延遲nontrivalmembers的實際合成操作,直到真正遇到其使用場合為止。

那麼什麼時候編譯器會真正合成復制構造函數呢?在以下四種情況出現的時候:

1.當類中含有一個成員對象,而該對象的類中有一個復制構造函數的時候(無論是顯式聲明或者隱式合成)。此時,需要在合成出來的復制構造函數中調用該成員對象的復制構造函數。

2.類中的基類有復制構造函數時(顯式或隱式)。此時,需要在合成出來的復制構造函數中調用該基類的復制構造函數。

3.類中存在虛函數時(繼承的或自身聲明的)。此時,需要在合成出來的復制構造函數中設置vptr(虛函數表指針)的指向。

4.類的繼承鏈中存在虛基類的時候(無論是直接的還是間接的)。此時,需要在合成出來的復制構造函數中維護虛基類部分的位置信息。

上面的情況與默認構造函數的情況可以類比一下,要證明也可以模仿默認構造函數調用時的證明方式,我就偷偷懶了(。?_?。)

需要注意的是,默認情況下的復制操作都是淺復制(有指針時,只是復制了指針的值,而並沒有復制指針指向的對象),要實現深復制(既復制指針的值,同時也復制指針指向的對象)時,還是需要我們親自來操作的。

 

 

二、賦值操作符

要讓賦值操作符(=)有效地執行我們指定的操作,有時需要顯示重載等號操作符。當然,當合成的賦值操作符可以達到要求時,就沒有必要再手動定義了,因為這可能還會使執行效率降低。那麼什麼時候需要顯示重載該操作符呢?逐個成員賦值不能滿足我們需求的時候,典型的情況就是類中有指針成員的時候。

我們說,當類中沒有定義自己的賦值操作符,則編譯器會合成一個。這是理論上的(很多事情都是理論上的,大家都懂的),實際上編譯器不一定會合成相應的操作符函數。

那麼什麼時候編譯器會合成呢?主要是以下四種情況(編譯器永遠只在自己需要的時候才合成相應的函數):

1.類中有一個成員對象,而該對象的類中有一個賦值操作符函數。這時,我們在為該成員對象賦值時需要調用該操作符。

2.類的基類中有賦值操作符。這時,我們在為基類對象(作為派生類對象的一部分)賦值時會調用該操作符。

3.類中聲明了虛函數。此時不能直接復制右端vptr(虛函數指針)的值,因為它可能是一個派生類對象。

4.該類繼承了虛基類。此時對象虛基類部分的位置可能會發生改變(如果從派生類對象賦值的話)。

可以簡單地證明一下:

當只有如下簡單的類定義時

class A

{

int a;

};

進行如下操作:

{

A a1;

A a2;

a2 = a1;

}

其反匯編代碼如下圖:

\

 

由此可見,其中並沒有調用賦值操作符函數。

如果為上面的類添加一個虛函數(其它情況就不一一證明了),則同樣的代碼調用,其反匯編情況如下:

\

 

有上可見,其中調用了合成的默認構造函數和賦值操作符。

上面的四種情況可以對比復制構造函數合成的情況,其實賦值操作符的工作和復制構造函數也差不多,只是調用時機有所區別。實際上,我們應該將這兩個操作看作一個單元。如果需要其中一個,我們幾乎也肯定需要另一個。

賦值操作符必須定義為成員函數而不能是友元函數。這是語法規則上的限制,當然即使沒有這個限制也不應該定義為為友元,畢竟調用的時候可能會讓人不適應。例如:operator=(a1,a2)相對應a1 = a2這種方式,是不是顯得有點別扭。

自己定義時有一種情況需要特別注意:自身賦值,尤其是有資源需要釋放的時候。

正確的定義方式如下:

Class& Class::operator=(const Class &rhs)

{

if(this == &rhs)

return *this;

//釋放對象持有的資源

//分配指定的新資源

……

return *this;

}

如果沒有判斷自身賦值的情況直接釋放原有資源肯定是要出事的,我想這就不用多說了。


 

三、析構函數

析構函數作為構造函數的補充,在構造函數中打開和分配所需的資源,在析構函數中關閉和釋放已有的資源。

析構函數沒有返回值,沒有形參,就像是在默認構造函數的前面加了一個‘~’符號。如下:

class A{

……

~A(){cout<<”在析構函數中”<

};

當對象被撤銷時會自動調用析構函數,如果是動態分配的對象,只有在指向該對象的指針被刪除時才撤銷。注意:當對象的引用或指針(不論是棧指針還是接收new操作首地址的指針)超出作用域時,不會運行析構函數。只有刪除指向動態分配對象的指針或實際對象(而不是對象的引用或指針)超出作用域時,才會運行析構函數。

示例代碼如下:

1.棧指針的情況:

{

A a;

{

A*pa = &a;

}//此時pa超出作用域,但並不運行A的析構函數

}//此時實例對象a超出作用域,將運行相應的析構函數

2.接收new操作符首地址的情況:

{

A*pa = new A();

//delete pa;在此處顯示調用delete語句可以析構pa指向的對象

}//此時pa指針超出作用域,但並不調用析構函數;而且錯過釋放內存的機會,將造成內存洩漏。

3.引用的情況:

{

Aa;

{

A&pa = a;

}//此時a的引用pa超出作用域,但不會調用析構函數

}//此時對象a自身超出作用域,將會調用析構函數

當撤銷一個容器(不論是標准庫容器還是內置數組)時,也會運行容器中的類類型元素的析構函數。

示例代碼如下:

{

AarrayA[3] = {A(1),A(2),A(3)};

vector vA(arrayA,arrayA + 3);

A *pa = new A[3];

delete [] pa;//調用pa中3個元素的析構函數

}//調用arrayA和vA中元素的析構函數

如果我們的類沒有顯示定義的析構函數,編譯器會在需要的時候為我們合成一個。合成的析構函數按對象創建時的逆序撤銷每個非static成員,即按成員在類中的聲明次序的逆序撤銷成員。對於類類型的成員,合成析構函數會調用該成員的析構函數來撤銷對象。注意:合成析構函數並不刪除指針成員所指向的對象。

上面說編譯器將會在需要的時候合成析構函數,那麼什麼時候是所謂“需要的時候”?就是以下的兩種情況:

1.class內的成員對象擁有析構函數(不論是合成的還是顯示定義的)。

2.class的基類含有析構函數(不論是合成的還是顯示定義的)。

這個時候為什麼需要合成析構函數呢?因為編譯器需要在合成的析構函數中調用上面對應的析構函數達到析構相應對象的目的。

例如有如下的類定義:

classVirtualBase

{

int vb;

};

classA:public virtual VirtualBase

{

public:

int a;

virtual void vfun(){}

};

當我們有如下代碼時:

{

A a;

}

其反匯編的代碼如下:

\

 

盡管有虛函數、虛繼承這種復雜的機制,但是此時編譯器依然沒有為我們合成析構函數。因為在析構函數看來,虛函數和虛繼承所帶來的只是兩個指針而已(可參考第一式),而撤銷指針不需要額外的操作(調用一個析構函數此時反而是效率上的負擔)。由上也可以看到編譯器此時為我們合成了構造函數(可參考第四式)。

若有如下的類定義:

class B

{

string name;

};

類似的代碼:

{

B b;

}

此時反匯編情況如下:

\

 

我們可以看到後面調用了相應的析構函數。因為string類中定義了析構函數,編譯器需要合成一個析構函數來調用string類成員對象的析構函數。

下面是析構函數的操作順序:

1. 執行析構函數的函數體

2. 如果類中具有成員對象,而後者擁有析構函數,那麼它們會以其聲明順序的相反順序被調用。

3. 如果對象內含有虛函數指針,現在被重新設定,指向適當基類的虛函數表。

4. 如果有任何直接的非虛繼承的基類,而後者有析構函數,它們會以其聲明順序的相反順序被調用。

5. 如果有任何的虛基類有析構函數,而目前的這個類是最尾端的類(最後的非虛基類),那麼它們的析構函數會以其原來的構造順序相反的順序被調用。

關於上面的順序,只要定義一個合適的繼承鏈,顯示定義析構函數輸出相應的信息就可以得出結論了。

最後還有一個實踐得出的經驗法則。三法則:如果類需要析構函數,則它也需要復制構造函數和賦值操作符。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved