首頁 > 軟體

C++類別中隱藏的幾個預設函數你知道嗎

2022-03-08 13:00:05

Test類中隱藏的六個預設的函數

class Test
{
public:
	//預設的建構函式
	Test();
	//解構函式 
	~Test();
	//拷貝建構函式 
	Test(const Test &t);
	//賦值函數 
	Test& operator=(const Test &x);
	//一般物件取地址函數 
	Test* operator&();
	//常物件取地址函數 
	const Test* operator&()const;
private:
		int data;
		//int *data;	// 注意:如果成員中含有指標型別,需過載拷貝函數與賦值函數
						// 否則會造成淺拷貝
						// 另外,需要注意在解構函式中,釋放類中使用的額外資源(堆區申請的資源)
};

1.建構函式

作用:物件所在的記憶體空間做初始化 、給物件賦資源

特點

1.可以過載 :可以根據實際需要進行預設的、多參過載

2.不依賴物件:物件無法呼叫建構函式,只能在物件定義點被呼叫

//成員函數類外實現,需在函數名前指定作用域,否則編譯器會認為在定義一個普通的函數
Test::Test()	//類中預設的建構函式
{
}
//此外,建構函式可以支援過載,我們可以根據需要自己寫一些建構函式
//需要注意的是,如果我們自己寫了建構函式,那麼編譯器就不會提供預設的建構函式了
Test::Test(int d = 0 )	//預設的建構函式
{
	data = d;
}
Test::Test(int d = 0 ):data(d)	//預設的建構函式,用初始化列表的方式初始化
{
}

兩者初始化的區別在於,初始化列表是真正意義上的初始化,它告訴編譯器在範例化物件的時候以何種方式對成員賦值,而在前者的賦值規則寫在了建構函式內部,是在已經生成了成員變數之後再進行的賦值操作。

初始化列表範例

Tips: 注意區分列表引數初始化列表初始化的區別 。列表引數初始化即在函數的形參列表後通過 fun(int ia) :mval(ia) 冒號+括號的這種方式初始化,而列表初始化一般是指如 std::vector<int> vec{ 1,2,3,4,5 }; vec{1,2,3,4,5}; 這種,在定義時通過 { } 括起來的列表初始化“陣列”的行為。 事實上,在C++11標準中還有一種就地初始化的概念,這裡先不做討論。

對於 初始化列表 有幾點特性需要注意:

比如以下操作,成員變數有參照型別和const型別,在C++中規定const型別為一個常數,定義時必須初始化,而參照我們認為是一個變數的別名也需要在定義時就初始化。所以以下操作只能使用初始化列表的方式初始化。

class Test
{
public:
	/* error 常數、參照在定義時就初始化
	Test(int a, int b,int c)
	{
		ma = a;
		mb = a;	// error
		mc = a; // error
	}
	*/
	Test(int a, int b, int c):ma(a),mb(b),mc(c)
	{
	}
private:
	int ma;
	int& mb;
	const int mc;
};

此外,如果有多個成員變數需要使用初始化列表的方式初始化,需要注意一點細節,初始化的順序只與成員變數的定義順序相關

如以下程式,可以寫成Test(int a):ma(mb), mb(a){}Test(int a):mb(a),ma(mb){}因為成員變數的定義順序為int mb; int ma;,也就是說賦值順序與初始化列表無關,只與成員變數被定義的順序有關。

class Test
{
public:
	Test(int a):ma(mb), mb(a)	//mb先被定義出來,先給mb賦值,再給ma賦值
	{
	}
	/* 下面錯誤的寫法: 
		解釋:
		1. mb先定義,ma後定義,兩者的使用參數列初始化的順序是先 mb, 再 ma
		2. 在初始化之前 ma 與 mb 都是隨機值,或被填充為0xcccccccc (具體看編譯器實現)
		3. 在初始化時, mb(ma) ,則mb被初始化為無效值(隨機值或0xcccccccc)
		                ma(a) ,  ma 被初始化為 a 的值。
						因此,如果呼叫Test(10), 則 mb: -858993460 ma: 10
	*/
	Test(int a) :mb(ma), ma(a)	
	{
	}
public:
	void Show()
	{
		std::cout << "ma: " << ma << std::endl;
		std::cout << "mb: " << mb << std::endl;
	}
private:
	int mb;
	int ma;
};

注:以下函數的Test類成員均為 int *ma ,表示資料成員為指標時,各成員函數的實現方法。

2.解構函式

作用:釋放物件所佔的其他資源。

特點

不可過載 : 物件銷燬時會呼叫解構函式,並釋放空間。依賴物件:可手動呼叫即this->~Test()或 Test t; t.~Test(),但是不建議,因為物件銷燬時會自動呼叫,如果手動呼叫可能會引起記憶體空間的重複解構導致程式崩潰

//預設的解構函式 
Test::~Test()
{		//沒有額外的資源,什麼都不寫
}
//如果程式中有額外的空間需要釋放
class Test
{
public:
	//建構函式 
	Test(int ia = 0) 
	{
		data = new int{ ia };		//data指向一塊堆區記憶體
	}
	//解構函式
	~Test();
private:
		int* data;
};
//解構函式 
Test::~Test()
{
	delete data;	//把額外空間的釋放寫進解構函式
	data = nullptr;
}

3.拷貝建構函式

作用:拿一個已存在的物件來生成相同型別的新物件

注意:類中提供的拷貝建構函式為一個淺拷貝型別,即如果成員變數中含有指標型別,它在進行拷貝構造的時候不會進行額外空間的開闢,最終會造成函數解構時的錯誤。

class Test
{
public:
	//建構函式 
	Test(int ia = 0) 
	{
		data = new int{ ia };		//data指向一塊堆區記憶體
	}
	//拷貝建構函式 
	Test(const Test &t);	//一定要傳參照,否則在開闢形參的過程中會遞迴的呼叫拷貝建構函式來構造形參,而函數始終無法執行
private:
		int* data;
};
//預設的拷貝建構函式 
Test::Test(const Test &t)
{
	data = t.data;		//淺拷貝,只把現有的成員變數進行拷貝,沒有對堆區記憶體進行拷貝,使多個物件的data指向了同一片堆區空間,在物件銷燬時會造成空間的重複釋放引發程式崩潰。
}
//拷貝建構函式
Test::Test(const Test &t)
{
	data = new int;		//如果是字元型別data = new char[strlen(t.data) + 1]; 
						// 注意strlen() 函數不能傳遞nullptr引數
	strcpy_s(data,sizeof(int), t.data);
}
// 或者使用初始化列表的方式
Test::Test(const Test& t) :data(new int{*(t.data)})
{
}

4.賦值運運算元的過載函數

作用:拿一個已存在的物件給相同型別的已存在物件賦值

實現步驟

1.賦值判斷

2.釋放舊資源

3.生成新資源

4.賦值

class Test
{
public:
	//建構函式 
	Test(int ia = 0) 
	{
		data = new int{ ia };		//data指向一塊堆區記憶體
	}
	//賦值函數
	Test& operator=(const Test &x);	//以自身類型別的參照的方式返回
private:
		int* data;
};
//預設的賦值函數(淺拷貝)
Test& Test::operator=(const Test &x)
{
	if(this!=&x)		//自賦值判斷
	{
		data=x.data;	//淺拷貝
	}
	return *this;		//返回自身類型別的參照
}
//賦值函數(深拷貝)
Test& Test::operator=(const Test &x)
{
	if(this!=&x)		//自賦值判斷
	{
		delete data;	//釋放原資源 
		//delete[] data; 如果申請的空間是多個,即陣列形式,需要delete [] data 釋放
		data = new int;	//開闢空間
		memcpy(data, x.data, sizeof(data));	// 賦值
	}
	return *this;		//返回自身類型別的參照
}

5.一般物件取地址函數

//一般物件取地址函數 
Test::Test* operator&()
{
	return this;
}

6.常物件取地址函數

//常物件取地址函數 
const Test::Test* operator&()const
{
	return this;
}

C++11以後增加了右值參照的概念,同時增加了移動建構函式、和移動賦值函數

7.移動建構函式

作用:針對某些情況構造物件的優化,避免重複的開闢記憶體。

使用場景:比如把臨時物件的資源作為構建新物件的資源使用,而臨時物件銷燬時,資源繼續被其他物件使用(這裡就節省了一次舊物件資源的的銷燬與新物件資源申請的開銷)。

class Test
{
public:
	//建構函式 
	Test(int ia = 0) :data(new int{ ia })
	{}
	// 拷貝構造 ..
	// 賦值..
	// 解構..
	// 移動構造
	Test(Test&& rhs)
	{
		this->data = rhs.data;	// 資源的轉移
		rhs.data = nullptr;		// 資源的釋放
	}
	 /* 
	 //使用初始化列表的方式
	Test(Test&& rhs) :data(rhs.data)
	{
		rhs.data = nullptr;
	}
	*/
public:
	void print() { std::cout << (void*)data << std::endl; }
private:
	int* data;
};
int main()
{
	Test t(10); 
	t.print();
	Test t2(std::move(t));	// 把 t 的資源轉移給 t2
	t.print();
	t2.print();
	return 0;
}

8.移動賦值函數

作用:資源的轉移,針對某些情況下,節省記憶體的開闢。

class Test
{
public:
	//建構函式 
	Test(int ia = 0) :data(new int{ ia })
	{}
	// 拷貝構造 ..
	// 賦值..
	// 解構..
	// 移動構造
	// 移動賦值
	Test& operator=(Test&& rhs) noexcept // 不丟擲異常
	{
		if (this != &rhs)	// 防止自賦值
		{
			delete data;			// 銷燬當前資源
			this->data = rhs.data;	// 轉移資源,即接收對方資源
			rhs.data = nullptr;		// 對方放棄資源的擁有
		}
		return *this;
	}

public:
	void print() { std::cout << (void*)data << std::endl; }
private:
	int* data;
};

int main()
{
	Test t(10), t2(20);
	t.print();
	t2.print();
	t2 = std::move(t);	// 把 t 的資源交給 t2
	t.print();	// 輸出 00000000
	t2.print();
	return 0;
}

補充:

另外一個關於移動構造的話題是異常。對於移動建構函式來說,丟擲異常有時是件危險的事情。因為可能移動語意還沒完成,一個異常卻丟擲來了,這就會導致一些指標就成為懸掛指標。因此程式設計師應該儘量編寫不丟擲異常的移動建構函式,通過為其新增一個noexcept關鍵字,可以保證移動建構函式中丟擲來的異常會直接呼叫terminate程式終止執行,而不是造成指標懸掛的狀態。而標準庫中,我們還可以用一個std::move_if_noexcept的模板函數替代move函數。該函數在類的移動建構函式沒有noexcept關鍵字修飾時返回一個左值參照從而使變數可以使用拷貝語意,而在類的移動建構函式有noexcept關鍵字時,返回一個右值參照,從而使變數可以使用移動語意。

關於移動建構函式的範例程式,參照自《深入理解C++11》一書:

#include <iostream>
using namespace std;
class HasPtrMem {
public:
	HasPtrMem() :d(new int(3)) {
		cout<<"Construct:"<<++n_cstr<<endl;
	}
	HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) {
		cout<<"Copy construct:"<<++n_cptr<<endl;
	}
	HasPtrMem(HasPtrMem&& h) :d(h.d) {//移動建構函式
		h.d = nullptr;//將臨時值的指標成員置空
		cout<<"Move construct:"<<++n_mvtr<<endl;
	}
	~HasPtrMem() {
		delete d;
		cout<<"Destruct:"<<++n_dstr<<endl;
	}
	int* d;
	static int n_cstr;
	static int n_dstr;
	static int n_cptr;
	static int n_mvtr;
};
int HasPtrMem::n_cstr = 0;		
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
int HasPtrMem::n_mvtr = 0;
HasPtrMem GetTemp() {
	HasPtrMem h;
	cout<<"Resource from"<<__func__<<":"<<hex<<h.d<<endl;
	return h;
}
int main() {
	HasPtrMem a = GetTemp();
	cout<<"Resource from"<<__func__<<":"<<hex<<a.d<<endl;
}
//編譯選項:g++ -std=c++11 test.cpp -fno-elide-constructors

輸出:左邊是輸出結果,右邊是註釋

Construct:1						// 在GetTemp() 函數中,執行 HasPtrMem h; 構造物件
Resource fromGetTemp:0x1047f28	// 在GetTemp() 函數中,執行cout << ... 
Move construct:1				// 在GetTemp() 函數中,return h; 產生臨時物件,此時第一次呼叫移動構造
Destruct:1						// 進入main() 函數時,GetTemp();呼叫結束、進行清棧(棧幀回退),解構掉區域性物件( h )
Move construct:2				// 在main() 函數中,執行 GetTemp(); 後產生的返回值是一個臨時無名物件,呼叫了移動建構函式,此時第二次呼叫移動構造
Destruct:2						// 在main() 函數中,執行了a = GetTemp(); 後,臨時物件生存期結束,解構掉臨時物件(函數返回值)
Resource frommain:0x1047f28		// 在main() 函數中,執行cout << ... 
Destruct:3						// main() 函數結束,物件 a 生存週期結束,銷燬物件 a 

需要注意的是,在編譯器中存在被稱為RVO/NRVO的優化(RVO,Return Value Optimization,返回值優化,或者NRVO,Named Return Value optimization)。因此在上述編譯時使用了 -fno-elide-constructors 選項在g++/clang++中關閉這個優化,這樣可以使我們在程式碼執行的結果中較為容易地利用函數返回的臨時量右值。 

如果在編譯的時候不使用該選項的話,我們寫的很多含有移動語意的函數都被省略了。例如以下的程式碼

A ReturnRvalue(){A a();return a;}
A b=ReturnRvalue();

b 變數實際就使用了ReturnRvalue函數中a的地址,任何的拷貝和移動都沒有了。通俗地說,就是b變數直接“霸佔”了a變數。這是編譯器中一個效果非常好的一個優化。不過RVO/NRVO並不是對任何情況都有效。比如有些情況下,一些構造是無法省略的。還有一些情況,即使RVO/NRVO完成了,也不能達到最好的效果。 

總體而言,移動語意除了可以解決某些情況下編譯器無法解決的優化問題,還在一些其他特殊的場合有著重要的用途(比如在unique_ptr中禁止建構函式,卻可以通過移動的構造或移動賦值對unique_ptr擁有的資源進行轉移)。

總結

本篇文章就到這裡了,希望能夠給你帶來幫助,也希望您能夠多多關注it145.com的更多內容!   


IT145.com E-mail:sddin#qq.com