Effective c++(筆記) 中關於建構函式、解構函式以及賦值操作符的知識

jsjliuyun發表於2014-05-27

最近在看Effective c++ 感覺這本書不錯,學習c++的同學可以看一下,加深自己對c++的認識。

1.什麼時候類中需要複製建構函式(copy constructor)和賦值操作符(Assignment)?

不止Effective c++中講到,我依然還記得c++ primer中也提到過這個問題,可見這個問題的重要性

答案:當class中有動態配置的記憶體時或者說當class類中有指標成員時需要複製建構函式和賦值運算子並同時需要解構函式(在解構函式中新增一些釋放記憶體等操作)

可是大家想過沒有為什麼是這樣哈?

下面就套用書上的一個例子說明

class String
{
public:
	String(const char *value);
	~String();
private:
	char *data;
};
String::String(const char *value)
{
	if(value)
	{
		data = new char[strlen(value)+1];
		strcpy(data,value);
	}else{
		data = new char[1];
		*data='\0';
	}
}
String::~String()
{
	delete [] data;
}

上面的String類中沒有copy constructor 和 assignment

當我們在呼叫這個類並執行下面的程式時大家就能看出問題了

String a="Hello";
String b="World";
b=a;

物件a中指標data指向記憶體的一個位置

物件b中的指標data指向記憶體的另一個位置

當執行b=a;  時因String類中沒有賦值操作符,只能呼叫類中預設的賦值操作符    即:右邊物件的資料成員賦值給左邊物件

此時細心的讀者就會發現問題,左邊的物件的指標data指向的記憶體怎麼沒有釋放--------這就會導致第一個問題------記憶體洩漏

另外當呼叫完系統預設賦值操作符後物件a,物件b中的指標data都指向了記憶體的同一塊區域,這就會產生第二個問題---當某個指標釋放記憶體後,另外一個就成了懸浮指標-----用一個名詞來說就是野指標(是指向被釋放的或者訪問受限記憶體的指標)

新增賦值操作符的程式碼如下

String& String::operator =(const String &rhs)
{
	if(this == &rhs)
	{
		return *this;
	}
	delete [] data;
	data = rhs.data;
	return *this;
}

當程式中只要有傳值操作時就會呼叫copy constructor複製建構函式(我們應該避免用這種傳值的方式,應該用引用)

void doNothing(String localString){}
String str="I Love The World";
doNothing(str);

看上面的程式碼說明當類中有指標成員時,類中沒有複製建構函式也會產生野指標的情況

當str字串以傳值的方式到函式doNothing中,也就是呼叫系統預設的也就是預設的複製建構函式,則localString中便有了str中指標成員的副本,但是當函式執行完後,localString的指標成員data所指向的記憶體已經釋放,而str中的指標成員data還指向著這段記憶體,所以就會造成------------野指標的情況

結論-----當類中具有指標成員時必須在類中編寫自己的複製建構函式和賦值運算子以及在解構函式中釋放記憶體。

原因-----如果不這樣做的話會出現 -----------記憶體洩漏和野指標(指標指向一塊已經釋放的記憶體)

2.建構函式中初始化列表與在建構函式體內進行賦值初始化的區別?

讀過c++ primer和Effective c++的人應該都知道盡量使用初始化列表的方式進行對類的資料成員進行初始化。

為什麼是這樣呢?有什麼特殊情況麼?

首先,當類成員中含有const成員和引用成員時必須使用初始化列表的形式進行初始化,因為在函式體內無法用賦值運算子進行初始化。

除了const成員和引用成員外其他資料成員應該怎麼選擇哈?

當類的資料成員不是內建型的也就是不是內建型的(如int  double   float都為內建型成員)時,使用初始化列表比在函式體內使用賦值運算子效率高

因為,當在函式體內使用賦值運算子時首先要呼叫該非內建型別資料成員的預設建構函式  然後再呼叫賦值運算子進行賦值,而使用初始化列表的方式只需要呼叫複製建構函式即可(copy constructor),前者需要呼叫兩個函式,而後者則需要呼叫一個函式。

類的資料成員是內建型成員時,初始化列表和使用賦值運算子的效果是等同的

當類中有陣列成員時,只能在建構函式體內用賦值運算子進行初始化,不能在初始化列表中進行初始化的

例外情況:

當類中有大量的內建型成員,則使用賦值運算子的效果比初始化列表的方式要好。看下面的例子

class ManyDataMbrs{
public:
	ManyDataMbrs();
	ManyDataMbrs(const ManyDataMbrs &x);
	void init();
private:
	int a,b,c,d,e,f,g,h;
	double i,j,k,l,m,n;
};
void ManyDataMbrs::init()
{
	a=b=c=d=e=f=g=h=0;
	i=j=k=l=m=n=0;
}
ManyDataMbrs::ManyDataMbrs()
{
	init();
}

3.類中各資料成員的初始化順序是由誰來決定的?

可能我們以前並不注意類中成員初始化順序。大家認為類中資料成員的初始化順序是跟建構函式中的初始化列表和函式體內的初始化順序一樣麼?

如果大家都這麼認為的話就打錯特錯了哈!

類中資料成員的順序是與在類中定義的順序來初始化的,與建構函式中初始化列表順序和函式體內順序是完全沒有關係的哈!

#include <iostream>
class Test{
public:
	Test(int value):y(value) , x(y){}
	friend std::ostream& operator<<(std::ostream &out , const Test &T);
private:
	int x;
	int y;
};
std::ostream& operator<<(std::ostream &out , const Test &T)
{
	return out << "x= " << T.x << " y= " <<T.y <<std::endl;	
}
int main()
{
	Test T(3);
	std::cout << T;
}

因在類中x先定義而y後定義,則先初始化x,但是x是由y初始化的那麼此時x將會是一個隨機值,而y再根據value可以正常初始化

列印結果如下:



結論-------請大家在對類的資料成員初始化時儘量保持使類中資料成員的宣告順序和初始化順序相同--


4.如果該類是基類(有類從此類派生),則儘量讓基類的解構函式為虛擬函式

先向大家說明一下一個類的虛擬函式是執行的原理。

虛擬函式是多型機制的基礎,就是在程式在執行期根據呼叫的物件來判斷具體呼叫哪個函式,現在我們來說說它的具體實現原理。

當一個類中含有虛擬函式,該類例項化也就是建立該類的物件時,會給該物件的資料成員分配空間,同時在該空間中會有一個虛擬函式指標vptr,這個指標在物件記憶體的位置是在所有該物件所有資料成員的最前面,該虛擬函式指標指向虛擬函式表vtbl,這個虛擬函式表vtbl中存放著該類中所以虛擬函式的函式指標,其中包括從基類繼承的虛擬函式,通過虛擬函式指標來判斷執行哪個虛擬函式。

編譯器會循著該物件的vptr所指的vtbl,決定呼叫哪一個函式,編譯器會在vtbl中尋找適當的函式指標。

常常應該將基類的解構函式設定為虛擬函式,因為當基類指標或引用繫結的是該類的派生類時,在刪除該指標時,如果基類的解構函式不為虛擬函式,則只會呼叫基類的解構函式(因為非虛擬函式時,是由該指標的靜態型別所決定),而不會呼叫該指標所指向派生類物件的解構函式,所以應該將基類的解構函式設定為虛擬函式。當基類的解構函式為虛擬函式時,由於該指標動態繫結的是派生類物件,所以先呼叫派生類物件的解構函式,然後再依次呼叫基類的解構函式。

當我們想封裝一個抽象類,只想讓別人繼承它,並不希望它例項化,此時就應該將該類中的某個成員函式為純虛擬函式。如果沒有找到合適函式為純虛擬函式時,可將基類的解構函式設定為純虛擬函式,但需要注意的是,當基類的解構函式為純虛擬函式時,該函式必須例項化,因為基類的解構函式一定是會呼叫的。而其他成員函式如果是純虛擬函式時則如果基類不需要操作則不需要該純虛擬函式的實體。

5.在賦值操作符operator=過載中必須檢查左右物件是否相同,同時傳回*this的引用!

剛才的程式碼再貼過來繼續討論

String& String::operator =(const String &rhs)
{
	if(this == &rhs)
	{
		return this;
	}
	delete [] data;
	data = rhs.data;
	return *this;
}

假如我們不檢查左右物件是否相同的話會出現什麼問題哈?

當我們嘗試進行

a=a;

則首先會釋放左邊物件的記憶體空間,然後將右邊物件的指標data成員賦值給左邊物件的指標

別慌----此處就有錯誤了,左右兩物件一樣,你釋放了左邊物件指標成員data的記憶體,右邊的指標成員data指向了已經釋放的記憶體,又是野指標的情況,所以我們必須在賦值運算子中必須檢查左右物件是否相同。

檢查兩個物件相同常用了有兩種方式

檢查*this==rhs,這樣檢查判斷兩個物件的值是否相同,所比較的是兩個物件的資料成員值是否相同,並不是檢查兩個物件的地址

檢查this==&rhs,此時是檢查兩個物件在記憶體中的地址是否相同,這在c++中是我們常用的,計算速度相對比較快。


最後我們傳回的是*this的引用,也就是物件的引用。這樣不用傳值那樣需要複製增加的效率,同時在各種選擇下,只有傳回*this的引用時最佳的。


6.在賦值運算子operator=中我們只對該類的資料成員賦值對麼?

答案是錯誤的,如果該類不是從其他類派生而來的,那麼當呼叫賦值運算子的時候只將自己的資料成員賦值就好了,但是如果該類是從其他類派生而來的,此時情況就大不同了哈!

我們常常在類的賦值運算子中會忽略這一點,千萬要注意哈!

看下面的例子

class Base{
public:
	Base(int initialValue = 0) : x(initialValue){}
private:
	int x;
};
class Derived : public Base{
public:
	Derived(int initialValue = 0) : Base(initialValue) , y(initialValue){}
	Derived& operator=(const Derived &rhs);
private:
	int y;
};
Derived& Derived::operator=(const Derived &rhs)
{
	if(this == &rhs)
	{
		return *this;
	}
	y = rhs.y;
	return *this;
}

首先,先說明派生類的建構函式,在派生類的建構函式中應該先對該類的直接基類初始化列表中進行初始化,然後再初始化該類的資料成員。

然後再說賦值運算子中的資料成員的賦值,在派生類中只針對該派生類的資料成員y進行了賦值,這肯定是錯誤的,在派生類中應該對基類的資料成員進行賦值,但基類的資料成員是private ,派生類訪問不到,所以只能在派生類中顯示的呼叫基類的賦值操作符

Derived& Derived::operator=(const Derived &rhs)
{
	if(this == &rhs)
	{
		return *this;
	}
	<strong>Base::operator=(rhs);</strong>
	y = rhs.y;
	return *this;
}

同樣在派生類的複製建構函式中也應該對基類的複製建構函式(拷貝建構函式)進行顯式呼叫。

class Base{
public:
	Base(int initialValue = 0) : x(initialValue){}
	Base(const Base &rhs) : x(rhs.x){}
private:
	int x;
};
class Derived : public Base{
public:
	Derived(int initialValue = 0) : Base(initialValue) , y(initialValue){}
	<strong>Derived(const Derived &rhs) : Base(rhs) , y(rhs.y){}</strong>
	Derived& operator=(const Derived &rhs);
private:
	int y;
};
Derived& Derived::operator=(const Derived &rhs)
{
	if(this == &rhs)
	{
		return *this;
	}
	<strong>Base::operator=(rhs);</strong>
	y = rhs.y;
	return *this;
}

結論-----當類從某個類派生而來時,該派生類的的建構函式(對直接基類進行初始化)、賦值運算子operator=以及拷貝建構函式中都要對基類的資料成員進行處理,這是非常容易犯錯的哈!


相關文章