前言 本篇是c++總結的第二篇,關於c++的對象模型,在構造、拷貝虛函數上重點分析,也包含了c++11class的新用法和特性,如有不當,還請指教! c++三大特性 訪問許可權 在c++中通過public、protected、private三個關鍵字來控製成員變數和成員函數的訪問許可權,它們分別表示 ...
前言
本篇是c++總結的第二篇,關於c++的對象模型,在構造、拷貝虛函數上重點分析,也包含了c++11class的新用法和特性,如有不當,還請指教!
c++三大特性
- 訪問許可權
在c++中通過public、protected、private三個關鍵字來控製成員變數和成員函數的訪問許可權,它們分別表示為公有的、受保護的、私有的,稱為成員訪問限定符
在類的內部,無論成員被聲明為public、protected還是private,並沒有訪問許可權的限制,都可以互相訪問;在類的外部,只能通過對象訪問成員,且只能訪問public許可權的,不可訪問private、protected
無論繼承是public、private還是protected,base class的private成員都不能被derived class成員訪問,base class中的public和protected成員能被派生類訪問
對於public繼承,derived class對象只能訪問base class中的public成員;對於protected 和 private繼承,derived class不能訪問base class的所有成員,而derived class的成員可以通過derived class對象來訪問base class的derived成員
private繼承意為"根據某物實現出",且private繼承只繼承實現,忽略介面,這純粹只是繼承了實現細節,也就是說它在軟體設計上沒有意義,意義在於軟體實現.若派生類需要訪問基類private的成員,或需要重新定義繼承而來的虛函數,則採用private繼承
class Base
{
public:
int num3;
protected:
int num1;
};
class derived : public Base
{
void func(Base&);
void func(derived&);
int num2;
};
void derived::func(Base& b) { b.num1 = 0; } //不合法
void derived::func(derived& d) //合法
{
d.num1 = 0;
d.num3 = num1;
}
- 繼承
定義:讓某類獲得另一個類的屬性和方法
功能:使用現有類的所有功能,並可以在無需重新編寫原來的類的情況下對功能進行擴展
三種繼承方式:
- 實現繼承(非虛函數):使用基類的屬性和方法而無需額外編寫
- 介面繼承(純虛函數):僅使用基類的屬性和方法的名稱,但子類須重新編寫對應的方法
- 可視繼承(虛函數):子窗體(類)使用基窗體(類)的外觀和實現代碼的能力
- 封裝
定義:數據和代碼捆綁在一起,避免外界干擾和不確定性訪問
功能:將客觀事物封裝成抽象的類,且類可以把自己的數據和方法只讓可信的類或對象操作,對不可信的進行隱藏。比如public修飾公共的數據和方法,private修飾那些進行隱藏的數據和方法
c++對於結構體和函數(不包含virtual和non-inline)的封裝並沒有增加佈局成本,在佈局以及存取時間上主要的額外負擔是由virtual引起
- 多態
定義:一個public 基類的指針或引用,定址出一個派生類對象
功能:允許將子類類型的指針賦值給父類類型的指針
實現多態的兩種方式:override和overload(c++常見關鍵字總結 - 愛莉希雅 - 博客園 (cnblogs.com))
c++用以下三種方法支持多態:
- 經由隱式轉化。將derived class指針類型轉化為public基類類型
- 經由虛函數機制
- 經由dynamic_cast和typeid運算符
虛函數:當基類希望派生類定義適合自己版本的函數,就將對應的函數聲明為虛函數
虛函數依靠虛函數表工作,表中保存虛函數地址,當用基類指針指向派生類時,虛表指針指向對應派生類的虛函數表,如此保證派生類中的虛函數被準確調用
虛函數是動態綁定的
使用虛函數的指針和引用是去尋找目標類的對應函數,而不是執行類的函數,且發生在運行期,所對應的函數和屬性依賴於對象的動態類型
多態中,調用函數是通過指針或引用的,這個被調用的函數必須是虛函數且進行了重寫
同一個class的所有對象都使用同一個虛表
派生類的override虛函數的返回類型和形參必須和父類完全一致,除非父類中返回值為一個指針或引用,子類的返回值可以返回這個指針或引用的派生
那麼虛函數機制是如何分辨指針類型的不同
的呢?比如以下例子,指向A類的指針如何與指向int的指針或指向模板Array的指針有所不同呢?
A* px;
int* px;
Array<String>*pta;
首先,一個指針不管它指向哪個類型,它的大小是固定的。在記憶體需求來講,它們三個都需要有足夠的記憶體來放置一個機器地址(通常為word)
這三者之間的不同並不在表示法、地址,而是在其定址出的對象類型不同
,也就是說,指針類型會告訴編譯器如何解釋某個特定地址的記憶體內容及其大小
這引出一個問題void類型的指針如何解釋呢?答案是我們不知道將涵蓋多大的地址空間.這也說明瞭另一件事——一個void指針只能持有一個地址,而不能通過它來操作所指的object.轉換是一種編譯器指令,大部分情況並不改變一個指針所含的真正地址,隻影響被指出的記憶體大小和內容
虛函數實例:
class A
{
public:
A();
virtual ~A();
virtual void Afunc();
protected:
int numA;
std::string strA;
};
class B : public A
{
public:
B();
~B();
void Afunc() override;
virtual void Bfunc();
int numB;
};
B b;
//兩個指針都指向B對象的起始地址,差別是pa涵蓋的地址只包含子對象A,而pb涵蓋整個B
A* pa = &b;
B* pb = &b;
pa->numB; //不合法
//顯式的downcast即可
(static_cast<B*>(pa) )->numB;
//以下方式更安全,但成本較高,是一個運行期運算符
if (B* pb2 = dynamic_cast<B*>(pa))
{
pb2->numB;
}
//以下pa的類型將在編譯期決定以下兩點
//1.固定的可用介面。pa只能調用A的public
//2.該介面的訪問級。此處函數為public
pa->Afunc();
//以下行為有兩個問題
//初始化將一個對象的內容完整拷貝到另一個對象去,為什麼vptr沒有指向b的vtbl?因為編譯器必須確定如果某個對象含有一個及以上的vptrs,這些vptrs的內容不會被基類對象初始化或改變
//為什麼a調用的Afunc函數的版本是A的?多態雖然"支持一個以上的類型",但不能在"直接存取對象"這方面做支持,因為面向對象並不支持對 對象的直接處理,且前面說過指針或引用支持多態是因為它們只是改變所指向的記憶體的"大小和內容解釋方式",並不改變對象記憶體大小
//這裡以派生類對象對基類對象進行初始化或賦值,派生類對象會被切割塞進基類記憶體中,而派生類的類型並不其中,這會導致多態不再起作用
A a = b; //造成切割
a.Afunc();
純虛函數:將類定義為抽象類,不可實例化對象。純虛函數一般來說沒有定義體,但也可以有
形式:virtual 數據類型 函數名(形參) = 0;
抽象類:抽象類描述了類的行為和功能,而不需要完成類的實現
類中至少有一個函數被聲明為純虛函數,則此類就是抽象類
抽象類不可實例化,只能作為介面使用
虛基類最有效的運用方式是一個抽象基類,且沒有任何數據成員
c++的三種對象模型
class Point
{
public:
Point(float xval);
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream& print( ostream & os ) const;
float _x;
static int _point_count;
}
對於以上封裝,c++通過三種對象模型來表示:
-
A Simple Object Model
一個對象包含一系列槽slots,每一個slots指向一個成員,各個成員按其聲明順序,各被指定一個slot。每一個數據成員和函數成員都有自己的一個slot
儘量減低C++ complier的設計複雜度,但會損失空間和執行器的效率
這個模型後來被應用到"指向成員的指針"觀念中
-
A Table-driven Object Model
將所有成員的信息抽離出來放在一個 數據成員表 和一個 成員函數表 中,而class則含有指向這兩個表的指針
這個模型後來成為虛函數的一個方案
-
The C++ Object Model
Nonstatic 數據成員存放在於每一個class對象內,static數據成員、static成員函數、 nonstatic成員函數則被存放在class對象外,虛函數用兩個步驟來支持此model:
- 每個class生成指向虛函數的指針,其中一個指針對應一個虛函數,這些指針放在表格中,這個表格被稱為虛函數表virtual table(vtbl)
- 每個class對象中安插一個指針,這個指針指向相關的虛函數表,這個指針被稱為虛指針vptr。vptr的設定和重置都由class里的構造函數、析構函數、拷貝賦值運算符自動完成。每個class關聯的type_info object(支持runtime type identification,RTTI)亦由虛函數表指出,通常放於表格中的第一個slot
class和struct
在c中,數據與處理數據的操作(函數)是分開聲明的,struct是被看作一個結構體,其中包含多個變數或數組,這些成員的數據類型可以不同;而在c++中,數據和處理數據的操作應該被視為一個整體,使用abstract data type(ADT)或class hierarchy的數據封裝,struct被看作一個類,其中包含數據和處理數據的方法,這是希望自定義類型更加健全
為什麼struct看起來似乎和class並無太大區別?那是因為c++為了維護與c之間的相容性,所以c++需要做到向下相容。若非如此,c++完全可以摒棄struct,直接用關鍵字class支持類的觀念
那麼什麼時候應該用struct取代class呢?答案是你認為struct好則用struct,否則是class.struct和class並無太大區別,struct也可以支持public、protected、private,virtual以及單一繼承、多重繼承、虛擬繼承等等
//以下定義,你說他是struct或class都可以
{
public:
operator int();
virtual void foo();
//...
protected:
static int i;
//...
}
class和struct的微小區別:
- 預設的訪問和繼承許可權。class預設private,struct預設public,這很有道理,因為c中struct很明顯是公有的
- 模板並不打算和c相容,因此模板中只能使用class作為類類型,使用struct代替class是不合法的
c風格的struct在c++的一個合理的用法:傳遞複雜的class對象的全部或部分到c函數時,struct聲明可以將數據封裝起來,並保證擁有與c相容的空間佈局,不過只用在組合
情況下
class和struct的佈局:c++對於結構體和函數(不包含virtual和non-inline)的封裝並沒有增加佈局成本,主要的額外負擔是由virtual引起的(之前已用圖說明過)
現有如下片段:
typedef struct
{
float x, y, z;
}Point;
Point global;
Point foobar()
{
Point local;
Point* heap = new Point;
*heap = local;
delete heap;
return local;
}
對於Point這樣的聲明,在c++會被貼上Plain OI' Data標簽。編譯器並不會為其聲明default constrcutor、destructor、copy constructor、copy assignment operator
對於 Point global; 這樣的定義,在c++中members並沒有被定義或調用,行為和c如出一轍。編譯器並不會調用constructor和destructor。除非在c中,global被視作臨時性定義
臨時性定義:因為沒有顯示初始化操作,一個臨時性定義可以在程式多次發生,但編譯器最終會將這些實例鏈接摺疊起來,只留下一個實例,放在data segment中"保留給未初始化的global object使用的"空間 但在c++中並不支持臨時性定義,對於此例,會阻止後續的定義
對於 Point* heap = new Point;編譯器並不會調用default constructor,只是 Point* heap = __new( sizeof(Point) )。delete亦是如此
對於*heap = local;編譯器並不會調用copy assignment operator做拷貝,但只是像c那樣做簡單的bitwise
return操作也是,只是簡單的bitwise,並沒有調用copy constructor
構造函數
定義:類通過一個或多個特殊的成員函數來控制其對象的初始化過程,這些函數叫做構造函數(constructor)
構造函數名字和類名相同,但沒有返回類型,除此以外和普通函數沒啥區別.構造函數支持重載,並支持普通函數的重載規則,但構造函數不可聲明為const
,因為構造函數任務是進行初始化,聲明為const無法修改
無論何時只要類的對象被創建,就會執行構造函數
預設構造函數:不用實參進行調用的構造函數。這包含兩種情況:
- 沒有明顯的形參
- 提供預設實參
合成的預設構造函數:當我們沒有定義構造函數時,編譯器在需要構造函數時,會合成預設構造函數,這種合成的預設構造函數執行滿足編譯器所需要的行動(不負責初始化數據成員),但為了程式繼續執行下去,編譯器有時候還是會初始化所需數據成員,若數據成員含有類內的初始值,則用此來初始化成員,否則預設初始化該成員
"= default"要求編譯器合成預設構造函數.若=default和聲明一起出現在類的內部,則合成的預設構造函數是inline的;出現在類外部,則不是inline的
對於一個類,如果開發人員沒有聲明任何一個構造函數,則會隱式聲明一個預設構造函數,這種被隱式聲明出的構造函數若不滿足後面所講的四種情況且又沒有聲明任何構造函數的class,是沒有用的(trivial)構造函數。那麼什麼是有用的(nontrivial)呢?一個有用的預設構造函數是編譯器需要的那種,必要的話會由編譯器合成出來
在討論有用的預設構造函數有哪些情況前,我們需要知道:在c++各個不同的編譯模塊(文件)中,編譯器將合成的default constructor、copy constructor、destructor、assignment copy operator都用inline
方式完成來避免合成多個default constructor;但若函數太複雜,用inline不合適,而是會合成出一個顯式的non-inline static實例
四種有用的nontrivial預設構造函數
以下四種情況預設的構造函數會被視為有用的:
帶有預設構造函數的成員類對象
如果一個class沒有任何構造函數,但其內含的成員對象擁有預設構造函數,編譯器需要為該class合成預設構造函數。不過這個合成操作只有在構造函數真正需要被調用時才發生;不過若若類中包含一個其他類類型的成員且這個成員的類型沒有構造函數,編譯器不會合成預設構造函數,將無法初始化該成員,這種情況需要開發者自己定義構造函數
例子:
//假設B包含A
class A
{
public:
A();
A(int);
//...
};
class B
{
public:
A a;
char* str;
//...
};
void func()
{
B b; //A::a必須在此初始化。合成的預設構造函數內涵必要代碼,能夠調用A的預設構造函數來處理成員對象B::a
if (str) //之前說過編譯器不負責初始化數據成員,但程式想要執行下去,編譯器在此處必須初始化str
{
//...
}
}
上面例子種合成的預設構造函數可能是這個樣子:
inline B::B()
{
a.A::A();
}
在這個例子中如果開發者已經為class B顯示定義一個預設構造函數,但這個構造函數並沒有調用所需要的 對象的構造函數,這時編譯器會在當前的預設構造函數中進行擴張,在顯式的開發者定義的代碼前安插一些代碼,以滿足編譯器需求。如果有多個class成員對象,則按照這些成員對象在class中的聲明順序來調用對應的構造函數。內部可能長這樣:
B::B() { str = 0;} //開發者定義的
//擴張後的預設構造函數
B::B()
{
//編譯器定義的代碼
a.A::A();
//顯式的代碼
str = 0;
}
那如果顯式的定義了構造函數而沒有預設構造函數,還需要合成預設的構造函數嘛?並不會,會根據編譯器要求擴張現有的構造函數,並不會再合成預設的構造函數
帶有預設構造函數的基類
這種情況下有用的預設構造函數道理和上面的類似,如果一個沒有構造函數的類派生自一個有預設構造函數的基類,那麼此時派生類的預設構造函數會被視為有用的併合成出來,這個構造函數將上一層基類的預設構造函數(根據他們的聲明順序)
帶有虛函數的類
以下兩種情況需合成預設構造函數:
- class聲明或繼承一個虛函數
- class派生自一個繼承串鏈,其中有一個及以上的虛基類(virtual base classes)
例子:
//B和C派生自A
class A
{
public:
virtual void func() = 0;
//...
};
void callfunc(const A& a) { a.func(); }
void foo()
{
B b;
C c;
callfunc(b);
callfunc(c);
}
這裡為了支持虛函數機制需要合成或對現有的構造函數進行擴張,兩個擴張行動在編譯期發生:
- 一個虛函數表vtbl被編譯器產出,其中放置對應類的虛函數地址
- 每個類對象中,編譯器產出一個虛表指針vptr,內含相關的class的虛函數表的地址
a.func()的虛擬調用會進行重寫,以支持虛函數機制
其中:
- 1表示虛函數表數組的索引(在前面的第三種對象模型可以看到佈局)
- 後面的參數其實就是this指針
//a.func()的轉變
( *a.vptr[1] )(&a)
對於構造函數的合成規則也是遵循之前的,沒有則合成,有則在每個構造函數中安插
帶有虛基類的class
對於每個class所定義的每一個構造函數,編譯器會安插"允許每個虛基類的執行期存取操作"的代碼;若沒有聲明構造函數,則編譯器為其合成一個預設構造函數
不同的編譯器對虛基類的實現有極大的差異,但每種實現的思想都是必須使虛基類在其每個派生類中的位置能夠在運行期確定
先來看個例子:
//菱形繼承
class X
{
public:
int nX;
};
class A : virtual public X
{
public:
int nA;
};
class B : virtual public X
{
public:
double nB;
};
class C : public A, public B
{
public:
int nK;
};
//無法在編譯期確定pa->X::nX的地址
void func( A* pa) { pa->nX = 1024; }
func(new A);
func(new C);
對於以上例子,編譯器無法固定經由pa存取的X::nx的實際偏移地址,因為虛擬派生類對象中的虛基類偏移位置是會隨著派生而變化的(詳細原因請移至虛擬繼承),編譯器必須改變執行存取操作的代碼,使X::nx可以延遲至運行期決定正確結果
在此我們提供cfront的做法:在派生類的每個虛基類中安插一個指針,所有經由指針或引用來存取一個虛基類的操作都可通過相關指針來完成
可能的轉變操作:
其中,__vbcX表示編譯器所產生的指針,指向所對應的虛基類
void func( A* pa) { pa->__vbcX->nX = 1024; }
成員初值列
我們知道由編譯器合成的構造函數不負責初始化數據成員,因此我們應該確保每一個構造函數都將對象的每個成員進行初始化,而這一保證通過成員初值列
(member initialization list)來完成
別混淆賦值和初始化.對於構造函數來說,類成員的初值可以通過member initialization list或在構造函數體內進行賦值
//賦值
class A
{
public:
A(int _i, int _j)
{
i = _i;
j = _j;
}
private:
int i, j;
};
//成員初值列
A( int _i, int _j) : i(_i), j(_j) {}
對於將class中成員設定常量值,使用explicit initialization list更有效率。因為當函數的活動記錄(activation record)被放進堆棧,initialization list的常量即可放入local1記憶體中
註:活動記錄過程的調用是過程的一次活動,當過程語句(及其調用)結束後,活動生命周期結束。變數的生命周期為其從被定義後有效存在的時間
對於以下四種情況,為確保程式順利編譯,必須使用member initialization list:
- 初始化一個
引用
成員時 - 初始化一個
const
成員時 - 調用
基類
的構造函數,且這個構造函數有一組參數時 - 調用
成員class
的構造函數,且此構造函數有一組參數時
對於賦值,在這四種情況下,編譯的效率並不高;相反成員初值列更有效率
來看一個例子:
class Word
{
String _name;
int _cnt;
public:
Word()
{
_name = 0;
_cnt = 0;
}
};
//編譯器會在構造函數中產生一個臨時性的String對象,然後再初始化,僅僅是提供給另一個對象進行拷貝賦值,最後會被摧毀
Word::Word()
{
_name.String::String();
String temp = String(0); //臨時對象
_name.String::operator=(temp);
temp.String::~String(); //摧毀臨時對象
_cnt = 0;
}
//成員初值列
Word::Word : _name(0), _cnt(0){}
//進行擴張
Word::Word()
{
_name.String::String();
_cnt = 0;
}
member initialization list不是一組函數調用,編譯器按照成員在class中的聲明順序
一一操作member initialization list,會在任何顯式的用戶代碼前以適當順序安插初始化操作。若不註意聲明順序,產生的bug很難觀察出來,因此儘量把一個成員的初始化和另一個放在一起
來看一個例子:
class A
{
int i;
int j;
public:
//i比j先聲明,因此是先初始化i再初始化j
A( int val ) : j(val), i(j) { }
}
//改善
A::A(int val) : j(val)
{
i = j;
}
c++規定,對象的成員變數的初始化動作發生在進入構造函數本體前,也就是這些成員的構造函數被自動調用時。也就是說上面改善的方案,i == j並不會有問題(因為理論上是i先初始化)
你可能會問,在member initialization list中實參傳回函數的返回值可以嗎?當然可以,但是最好是使用"存在構造函數體內的一個成員",而非"member initialization list內的成員",因為你並不知道這個函數是否需要class的數據成員,所以將這種初始化放在構造體內就完全沒有問題
來看一個例子:
X::X(int val) : i( func(val) ), j(val) { } //萬一func需要數據成員q,而q的聲明順序在i之後
//擴張
X::X()
{
i = this->func(val);
j = val;
}
如果一個派生類成員函數被調用,不要將其返回值做為基類構造函數的一個參數
來看一個例子:
class AA : public A
{
int _AAval;
public:
int AAval() { return _AAval; } //派生類的成員函數
AA( int val ) : _AAval(val), A( AAval() ) { }
};
//擴張
AA()
{
A::A(this, this->AAval() ); //先構造基類,但需要數據成員,可是這個數據成員在後面進行初始化
_AAval = val;
}
explicit initialization list也有不足:
- class成員需要為public
- 只能指定常量,因為其常量在編譯器即可求值
- 編譯器並沒有自動施行它,初始化很可能失敗
編譯器對構造函數的擴充
定義一個對象,編譯器會對於構造函數進行如下擴充操作:
- 記錄在member initialization list的數據成員初始化會被放進構造函數本體,以成員聲明順序為順序。若有一個成員沒有出現在member initialization list,但其有預設構造函數,那麼該default constructor必須被調用
- 在那之前,若class對象有virtual table pointers,其需指定初值
- 在那之前,所有上層的base class constructors必須被調用,以base class的聲明順序
- 若class被列於member initialization list,如果有任何顯示指定的參數,都應傳過去;若沒有列於list,而class有預設構造函數,則調用之
- 若基類是多重繼承下的第二或後繼基類,那麼this指針需調整
- 在那之前,所有的虛基類的構造函數必須被調用,從左到右,從深到淺
- 若class被列於member initialization list,如果有任何顯示指定的參數,都應傳過去。若沒有列於list,而class有default constructor,則調用此
- 此外,class中的每個虛基類 subobject的offset必須在執行期可被存取
- 若 class對象是最底層的class,其構造函數可能被調用;某些用以支持這一行為的機制必須被放進來
拒絕編譯器合成的預設函數
對於一個空類,編譯器會為我們自動合成預設的構造函數、拷貝構造函數、拷貝賦值運算符、析構函數(非虛函數)
有時候我們想禁止一個class對象的拷貝操作,就需要進位拷貝構造函數和拷貝複製運算符。問題是,不顯式聲明他們,編譯器可能會為我們自動合成一個預設的;但是想要避免編譯自動生成,又得自己定義一個,屬於是陷入惡性迴圈了。那麼如何解決這個問題呢?
有兩種解決方案:
-
將拷貝構造函數、拷貝賦值運算符聲明為private屬性的函數,如此便不能調用這兩個函數
class A { public: A() = default; private: A( const A& ); A& operator=( const A& ); }
其實這樣問題並沒有完全解決,因為A的其他成員函數和友元函數依舊可以調用private
-
定義一個基類專門阻止拷貝
class unA { protected: unA() {} ~unA() {} private: unA( const unA& ) {} unA& operator=( const unA& ) {} }; class A : private unA { public: A() = default; }
現在,就算是A的成員函數或友元函數,也無法調用,編譯器的嘗試合成的動作將被基類阻止
別在構造過程調用虛函數
class A
{
public:
A();
virtual fFunc() const = 0;
//...
};
A::A()
{
fFunc();
}
class derivedA : public A
{
public:
virtual void fFunc() const;
}
//先構造基類
A a;
這個時候會先進行基類的構造,fFunc()的調用將是基類那個版本的,這就意味著我們多調用了一個函數,永遠不可能下降到當前這個子類的層級(因為父類構造先於子類,此時子類的成員並未初始化,若調用子類版本的虛函數,程式將報錯)
最根本的原因是子類對象在調用基類構造函數期間,對象類型是基類而不是子類,不僅虛函數會使用基類版本,此時dynamic_cast和typeid也會將對象視為基類類型
令operator=返回⼀個綁定到*this的引⽤
也就是如下形式:
class A {
public:
...
A& operator=(const A& other) { ... return *this; }
}
//目的是為了連鎖賦值
int x, y, z;
x = y = z = 100;
//原理如下
x = (y = (z = 100))
也並非需要一定讓operator=返回⼀個綁定到*this的引⽤,不遵守這個規則代碼一樣可以通過。但是這樣效率更高,可以調用更少的構造和析構函數
拷貝構造
拷貝構造函數(copy constructor):在創建對象時,使用同一類型的class對象來初始化新創建的對象
拷貝構造函數的形式,採用ClassName&
(類的名稱)類型作為參數:
class A
{
public:
A( const A& );
}
以下三種情況會以一個類對象的內容作為另一個類對象的初值:
-
顯式地以一個類對象的內容作為另一個類對象的初值
class A { ... }; A a; A aa = a;
-
對象被當作參數傳給某函數
void do( A a ); void do2() { A aa; func( aa ); }
-
函數傳回類對象
A do2() { A a; return a; }
設計一個class,並以一個class object指定給另一個class object,我們有三種選擇:
- 什麼都不做,實施預設行為
- 提供一個explicit 拷貝賦值運算符
- 顯示拒絕把class對象指定給另一個class對象。也就是將拷貝賦值運算符聲明為private,且不提供定義
只有在預設的memberwise copy行為不安全或不正確時,才需要設計一個拷貝賦值運算符。且如果class有bitwise copy,隱式的賦值運算符不會合成
class對於default copy assignment operator,在以下情況,不會表現bitwise copy:
- 當class 內含成員對象,而其class有一個拷貝賦值運算符
- 當class的基類有一個拷貝賦值運算符
- 當class聲明瞭任何虛函數。一定別拷貝右邊class對象的vptr地址,它很有可能是派生類對象
- 當class繼承自虛基類
即使賦值由bitwise copy完成,並沒有調用copy assignment operator,但還是需要提供一個copy constructor(編譯器合成的也算),以此打開NRV優化
儘可能不要允許一個虛基類的拷貝操作。不要在任何虛基類中聲明數據
對於單一繼承和多重繼承,若class使用bitwise copy,一般不會合成拷貝構造,就不會增加效率成本
對於虛擬繼承,bitwise copy不再支持,而是合成拷貝賦值運算符r和inline 拷貝構造,導致成本大大增加。且繼承體系複雜度增加,對象拷貝和構造的成本也會增加
memberwise initialization
若一個類未定義顯式的拷貝構造函數,並一個類對象以另一個類對象作為初值時,其內部以default memberwise initialization(成員初始化)方式完成,也就是將每個內部的或派生的數據成員的值,從某個對象拷貝一份到另一個對象身上,不過並不會拷貝成員類對象
,而是以遞歸的方式施行memberwise initialization
memberwise initialization有兩種方式:
- 展現bitwise copy semantics,進行位逐次拷貝
- 未展現bitwise copy semantics,編譯器合成預設的拷貝構造函數,調用內部的類的拷貝構造函數
當一個類未定義顯式的拷貝構造函數,若類展現出"bitwise copy semantics",編譯器將不會合成預設的拷貝構造函數;若未展現,則合成。也就是說,和構造函數類似,是否合成預設的構造函數也是看編譯器的需求
位逐次拷貝(bitwise copy semantics ):對 源類中的成員變數 中的每一位 都逐次 複製到 目標類中,編譯器只是直接將數據成員的地址拷貝過來,並不拷貝其值
class A
{
public:
A( const char* );
~A() { delete []str; }
//...
int cnt;
char* str;
};
//進行位逐次拷貝,不合成預設的構造函數
A a2("c++");
A a1 = a2;
當然這種方式會導致一個問題,比如當前例子中的char*指針,會造成a1的指針和a2的指針指向同一記憶體地址,調用兩次析構函數,必將報錯。因此對於這類情況,只有靠設計者實現一個顯式的拷貝構造函數
那什麼情況時沒有展現bitwise copy semantics呢?class含有另一個class,後面這個class定義了顯式的拷貝構造函數
//將char*改為String
class A
{
public:
A( const std::String&);
//...
int cnt;
const std::String str;
};
//string聲明瞭顯式的拷貝構造函數
class String
{
public:
String( const String& );
//...
}
//這個時候簡單的位逐次拷貝無法滿足編譯器需求,因為需要調用String的拷貝構造函數,因此編譯器需要合成預設的拷貝構造函數
A a2("c++");
A a1 = a2;
//合成的預設拷貝構造函數
inline A::A( const A& a )
{
str.String::String( a.str );
cnt = a.cnt;
}
不展現bitwise copy semantics的四種情況
其實不展現"bitwise copy semantics"有四種情況:
-
class
內含
一個成員對象,而後者的class聲明瞭一個拷貝構造時(無論是顯式的,還是編譯器合成的) -
class
繼承
自一個基類而這個基類存在一個拷貝構造時(無論是顯式的,還是編譯器合成的)第一點和第二點在上面已經解釋過了
-
class聲明一個及以上的
虛函數
若class含有虛函數,在構造函數中會安插一個vtbl和一個vptr,調用拷貝構造函數時需要正確的處理其初值,因此,當編譯器在class中導入一個vptr時,某些情況下,bitwise copy semantics將不在生效
來看一個例子:
class ZooAnimal { public: ZooAnimal(); virtual ~ZooAnimal(); virtual void animate(); virtual void draw(); //... }; class Bear : public ZooAnimal { public: Bear(); void animate() override; void draw() override; virtual void dance(); //... }; Bear yogi; Bear winnie = yogi;
在這裡,
兩個類型相同,bitwise copy semantics依舊生效(動態轉換的指針除外)
,將yogi的vptr的賦值給winnie是安全的但是要註意,當一個基類對象以派生類對象的內容初始化時,vptr的複製操作需保證安全
來看一個例子:
void draw( const ZooAnimal& zoey ) { zoey.draw(); } void fun() { ZooAnimal franny = yogi; //造成切割 draw(yogi); draw(franny); }
事實上franny只有ZooAnimal那一部分,Bear那部分已經被切割掉,這裡調用的是ZooAnimal的版本。那麼問題來了,vptr不是會進行賦值操作嘛,為什麼vptr還是指向ZooAnimal的vbtl?答案是,對於這種情況,
合成出來的ZooAnimal的預設拷貝構造函數不再進行簡單的拷貝賦值操作,而是顯式設定vptr指向ZooAnimal class的vbtl,這也就是為什麼包含虛函數的情況下bitwise copy semantics會失效
-
class派生自一個繼承串鏈,其中有一個及以上的
虛基類
在一個類對象以其派生類對象作為初值的情況下,bitwise copy semantics依然會失效,因為虛基類的位置並不確定,簡單的進行bitwise copy semantics可能會破壞這個位置,所以編譯器必須合成預設的拷貝構造函數做出判斷;不過若是相同的類型之間進行賦值,bitwise copy semantics依舊生效(動態轉換的指針除外)
來看一個例子:
class Raccoon : public virtual ZooAnimal
{
public:
Raccoon();
Raccoon(int val);
//...
};
class RedPanda : public Raccoon
{
public:
RedPanda();
RedPanda(int val);
//...
};
//兩個相同類對象,bitwise copy semantics依舊生效
Raccoon rocky;
Raccoon little_critter = rocky;
//一個類對象以其派生類對象作為初值,bitwise copy semantics不生效
//這個時候編譯器必須合成一個拷貝構造函數,已初始化虛基類指針
RedPanda little_red;
Raccon little_critter = little_red;
這種情況下,編譯器必須合成預設的拷貝構造函數,額外任務是安插必要代碼來設定虛基類的指針的初值
對於以下這樣的情況,因為不知道指針真正指定的對象類型,因此編譯器並不知道bitwise copy semantics是否生效
Raccoon* ptr;
Raccoon little_critter = *ptr;
程式轉化
先來看一段代碼:
A func()
{
A aa;
//...
return aa;
}
對於以上代碼,你可能會認為每次func被調用,就傳回aa的值;且如果class A定義了一個拷貝構造,那麼當func被調用時,保證該拷貝構造也會被調用。這可能成立也可能不成立,具體如何視編譯器的優化來定
分析這個問題之前,我們先來看幾個基礎轉化:
-
顯式的初始化
來看一個例子:
A a0; void func1() { //定義a1、a2、a3 A a1(a0); A a2 = a0; A a3 = A(a0); }
這裡程式會進行轉化,而這轉化可以分為兩個階段:
- 重寫每一個定義,去除初始化操作
- 安插class的拷貝構造調用
可能的轉化如下:
void func1() { //重寫定義 A a1; A a2; A a3; //安插拷貝構造調用 a1.A::A( a0 ); a2.A::A( a0 ); a3.A::A( a0 ); }
-
參數的初始化
將一個class對象作為參數傳給一個函數或作為函數的返回值,相當於以下的初始化操作:
A aa; void func( A a0 ); A a0 = aa;
這會要求局部對象a0以memberwise的方式將aa作為初值.編譯器針對這個要求有不同的做法,其中一種做法是導入所謂的臨時對象,並調用拷貝構造函數將其初始化,再將此臨時對象交給函數
以上例子可能的轉化:
A __temp0; __temp0.A::A( aa ); //需要將函數參數改為&,否則這會多進行一次bitwise foo( A& a0 ); //隨後便對臨時對象進行析構
-
返回值的初始化
A func() { A aa; //... return aa; }
對於以上片段中,你可能會問函數的返回值如何從局部對象aa中拷貝而來?其中一種做法是進行雙階段轉化:
- 首先加上一個額外參數,類型是class對象的引用,放置進行構造後的對象
- 在return前安插一個拷貝構造函數的調用操作,將想要傳回的對象的內容當作新增參數的初值
可能進行以下轉化:
void func( A& __result ) { A aa; aa.A::A(); __result.A::A(aa); return; } //編譯器必須轉換每個func的調用 A a0 = func(); //可能的轉化 A a0; func( a0 ); func().mem(); //對於以上調用可能的轉化 A __temp0; ( func( _temp0 ), __temp0 ).mem(); //對於函數指針 A (*pa) (); pa = func; //可能的轉化 void (*pa)(A&); pa = func;
以上三種轉化其實效率並不算好,我可以在兩方面進行優化:
-
開發人員層面
//不再是以下這樣 A func( const T& y, const T&z ) { A aa; //...用y和z處理aa return aa; } //而是定義一個用於計算的構造函數,這樣減少了拷貝次數 A func( A& __result, const T& y, const T&z ) { __result.A::A(y,z); return; }
-
編譯器層面
以__result參數取代named return value(NRV)。主要註意的是NRV優化需要copy constructor編譯器才可實施
//用__result取代aa void func( A& __result ) { __result.A::A(); return; }
不過NRV優化,也有缺點:
-
優化由編譯器完成,但是我並不知道這項優化是否真的完成
-
一旦函數變得複雜,優化會難以實施
因為對於函數體內若嵌套了塊block,塊里又包含return語句,此時NRV優化大概率會關閉
-
某些程式員不喜歡自己的代碼被優化,因為很可能打亂本來正確的順序
來看一個例子:
void callFunc() { //調用拷貝構造 A aa = func(); //調用析構 }
這種情況下,雖然程式優化了速度很快,但卻是錯誤的,因為在前面的例子可以看到NRV優化會剔除構造函數
-
現在讓我們回到最初的兩個問題:
-
每次func被調用,就傳回aa的值
很明顯,編譯器對函數進行了轉化,通過引用__result傳回aa
-
如果class A定義了一個拷貝構造,那麼當func被調用時,保證該拷貝構造也會被調用
不一定,視編譯器優化而定,NRV優化施行了的話,便不會調用拷貝構造
A func()
{
A aa;
//...
return aa;
}
是否需要拷貝構造?
是否需要拷貝構造視情況而定,若bitwise copy即可滿足,則不定義顯式的拷貝構造函數,因為編譯器想得比你周到,已經為你施行了最好的方案;但若需要大量的memberwise操作,也就是說bitwise copy不再滿足開發者要求,這種情況需要提供一個顯式的構造函數且編譯器提供NRV優化,當然難度非常高,因此推薦禁止拷貝構造函數
class Point3d
{
public:
//都符合NRV優化
Point3d operator+(const Point3d&);
Point3d operator-(const Point3d&);
Point3d operator*(const Point3d);
//...
private:
float _x, _y, _z;
};
//合理
Point3d::Point3d( const Point3d &rhs )
{
_x = rhs._x;
_y = rhs._y;
_z = rhs._z;
}
//但是可以更有效率
Point3d::Point3d( const Point3d &rhs )
{
memcpy( this, &rhs, sizeof(Point3d) ); //memset也可以
}
不過這裡如果存在編譯器生成的內部成員如虛指針,memcpy或memset將使得編譯器生成的成員的值被改寫
class A
{
public:
A() { memset( this, 0, sizeof(A) ); }
//這裡有虛函數或虛基類
};
//擴張
A()
{
__vptr__A = __vbtl__A;
memset( this, 0, sizeof(A) );
}
值得一提的是,當⼀個class內含有reference/const成員時,編譯器不會提供拷⻉賦值運算符的補充,只能由開發人員⾃⼰編寫.因為C++不允許改變reference成員的指向,也不允許更改const成員
賦值對象時勿忘其每個成員
copy函數包含:拷貝構造函數,拷貝賦值運算符
當定義了copy函數後,編譯器將不再生成預設版本,若編譯器有額外需求,編譯器會在copy函數中安插必要的代碼,不過這並不意味著編譯器會為你的遺漏服務
當為class添加⼀個成員變數,必須同時修改copy函數
為派生類定義copy函數時,要註意對基類成員的複製。基類成員通常是private,派生類⽆法直接訪問,應該讓派生類的複製函數調⽤相應的基類複製函數
不能為了簡化代碼,就在拷貝構造函數中調⽤拷貝賦值運算符,也不能在拷貝賦值運算符中調⽤拷貝構造函數。因為拷貝構造函數⽤來初始化新對象,⽽賦值運算符只能⽤於已初始化的對象
要想消除copy函數的重覆代碼,可以建⽴⼀個新的成員函數給copy函數調⽤,這個函數通常是private且命名為init
移動構造
右值引用
左值:指表達式結束後依然存在的持久對象,可以取地址,具名變數或對象
右值:表達式結束後就不再存在的臨時對象,不可以取地址,沒有名字
變數和文字常量都有存儲區,並且有相關的類型。區別在於變數是可定址的(addressable).對於每一個變數都有兩個值與其相聯:
- 它的
數據值
,存儲在某個記憶體地址。有時這個值也被稱為對象的右值(rvalue,讀做are-value).也可認為右值是被讀取的值(read value). 文字常量和變數都可 被用作右值 - 它的
地址值
。有時被稱為變數的左值(lvalue,讀作ell-value)。也可認為左值是位置值location value.文字常量不能被用作左值
也就是說,有些變數可作左值和右值;而文字常量只能作右值
例子:
//a++.a為右值
int temp = a;
a = a + 1;
return temp;
//++a.a為左值
a = a + 1;
return a;
純右值:臨時變數值、不跟對象關聯的字面量值.右值是純右值
將亡值:將要被移動的對象、T&&函數返回值、std::move返回值和轉換為T&&的類型的轉換函數的返回值。可以理解為“盜取”其他變數記憶體空間。在確保其他變數不再被使用、或即將被銷毀時,通過“盜取”的方式可以避免記憶體空間的釋放和分配,能夠延長變數值的生命期
左值引用:對一個左值進行引用的類型,必須進行初始化.左值引用是有名變數
的別名
常量左值引用是一個“萬能”的引用類型,可以接受左值、右值、常量左值和常量右值
右值引用
:右值引用必須立即進行初始化操作,且只能使用右值進行初始化.右值引用是不具名變數
的別名
右值引用可以對右值進行修改.c++支持定義常量右值引用
定義
的右值引用並無實際用處。右值引用主要用於移動語義
和完美轉發
,其中前者需要有修改右值的許可權
通過右值引用,這個將亡的右值又“重獲新生”,它的生命周期與右值引用類型變數的生命周期一樣,只要這個右值引用類型的變數還活著,那麼這個右值臨時量就會一直活著。可利用這一點會一些性能優化,避免臨時對象的拷貝構造和析構
右值引用通常不能綁定到任何的左值,要想綁定一個左值到右值引用,通常需要std::move()將左值強制轉換為右值
T&&是什麼,一定是右值嗎?
來看個例子:
template<typename T>
void f(T&& t){}
f(10); //t是右值
int x = 10;
f(x); //t是左值
T&&表示的值類型不確定,可能是左值又可能是右值
右值引用獨立於左值和右值。意思是右值引用類型的變數可能是左值也可能是右值
來看個例子:
int&& var1 = 1; //var1類型為右值引用,但var1本身是左值,因為具名變數都是左值
例子:
//常量左值
int num = 10;
const int &b = num;
const int &c = 10;
//右值引用
int num = 10;
int && a = num; //不合法。右值引用不能初始化為左值
int && a = 10;
//對右值引用對右值進行修改
int && a = 10;
a = 100;
cout << a << endl;
//常量右值引用
const int&& a = 10;//合法
移動構造函數
-
為什麼需要移動構造函數?
在之前的拷貝構造函數中我們學到了,對於指針這種進行淺拷貝,而後很容易進行兩次析構導致程式崩潰,而深拷貝或編譯器自行合成預設的拷貝構造函數,又會生成臨時對象,這會導致效率大大降低,雖然有NRV優化,但NRV優化也有限制。因此,c++針對這一狀況引進了移動構造函數
-
什麼是移動構造函數?
c++引入右值引用,藉助它可以實現移動語義
什麼是移動語義?將資源(如動態分配的記憶體)從一個對象轉移到另一個對象,也就是說允許從臨時對象(無法在程式中的其他位置引用)轉移資源
舉個例子:
struct A { A(); A(const A& a); ~A(); }; A GetA() { return A(); } int main() { A a = GetA(); return 0; } //生成一個臨時對象,最後還會多調用一次析構函數,一次拷貝 //可能的轉化如下 void GetA( A& __result ) { A aa; aa.A::A(); __result.A::A(aa); aa.~A::A(); return; }
可以看到這樣效率不高,但是通過移動語義,就可以提高效率
改動如下:
//少調用一次拷貝構造和析構 A&& a = GetA();
通過右值引用,比之前少了一次拷貝構造和一次析構,原因在於右值引用綁定了右值,讓臨時右值的生命周期延長了。我們可以利用這個特點做一些性能優化,即避免臨時對象的拷貝構造和析構
事實上,在c++98/03中,通過
常量左值引用
也經常用來做性能優化改動如下:
const A& a = GetA();
我們來看一個移動構造函數的例子:
class A { public: A() :m_ptr(new int(0)){} A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷貝的拷貝構造函數 { cout << "copy construct" << endl; } A(A&& a) :m_ptr(a.m_ptr) //移動構造 { a.m_ptr = nullptr; cout << "move construct" << endl; } ~A(){ delete m_ptr;} private: int* m_ptr; }; int main(){ A a = Get(); } 輸出: construct move construct move construct
這個構造函數並沒有做深拷貝,僅僅是將指針的所有者轉移到了另外一個對象,同時,將參數對象a的指針置為空,這裡僅僅是做了淺拷貝,因此,這個構造函數避免了臨時變數的深拷貝問題。這個構造函數其實就是移動構造函數,參數是一個右值引用類型.為什麼會匹配到這個構造函數?因為這個構造函數只能接受右值參數,而函數返回值是右值
值得一提的是,提供移動構造函數的同時提供一個拷貝構造函數,以防止移動不成功的時候還能拷貝構造,更有嚴謹性
移動拷貝構造函數的定義
:定義一個空的構造函數方法,該方法採用一個對class類型的右值引用作為參數MemoryBlock(MemoryBlock&& other) : _data(nullptr) , _length(0) { } //在移動構造函數中,將源對象中的class數據成員添加到要構造的對象 _data = other._data; _length = other._length; //將源對象的數據成員分配給預設值。 這可以防止析構函數多次釋放資源(如記憶體) other._data = nullptr; other._length = 0;
移動賦值運算符的定義
:定義一個空的賦值運算符,該運算符採用一個對class類型的右值引用作為參數並返回一個對class類型的引用MemoryBlock& operator=(MemoryBlock&& other) { } //在移動賦值運算符中,如果嘗試將對象賦給自身,則添加不執行運算的條件語句 if (this != &other) { }
你可能會疑惑在拷貝構造函數中的轉化,是構造一個臨時對象,再進行拷貝,這個臨時對象是個左值;而在這裡移動構造參數接受的是右值,如何實現呢?可能是使用了std::move,將這個左值轉為右值
std::move():將左值轉換為右值,從而方便應用移動語義.move是將
對象資源的所有權從一個對象轉移到另一個對象
,只是轉移,沒有記憶體的拷貝.也就是說,使用move幾乎沒有任何代價如果一個對象內部有較大的對記憶體或者動態數組時,很有必要寫move語義的拷貝構造函數和賦值函數;如果是基本類型比如int和char[10]定長數組等,使用move仍然會發生拷貝(因為沒有對應的移動構造函數)
std::move()的定義
:template <class Type> constexpr typename remove_reference<Type>::type&& move(Type&& Arg) noexcept;
委托構造
為什麼引入委托構造?
如下,現有三個class含有執行類似操作的多個構造函數:
class class_c {
public:
int max;
int min;
int middle;
class_c() {}
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < max ? my_min : 1;
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
};
我們可以通過添加一個包含所有驗證的函數來減少重覆的代碼,但是如果一個構造函數可以將部分工作委托給其他構造函數,則這樣的代碼更易於瞭解和維護。對於這種情況,c++11引入了委托構造函數,目的是簡化構造函數的書寫,提高代碼的可維護性,避免代碼冗餘膨脹
什麼是委托構造函數?
一個委托構造函數使用它所屬的類的其他構造函數執行自己的初始化過程,或者說它把自己的一些或全部職責委托給了其他構造函數
委托構造函數的語法:constructor (. . .) : constructor (. . .)
例子:
class class_c {
public:
int max;
int min;
int middle;
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) : class_c(my_max) {
min = my_min > 0 && my_min < max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) : class_c (my_max, my_min){
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
};
int main() {
class_c c1{ 1, 3, 2 };
}
現在,構造函數 class_c(int, int, int)
首先調用構造函數 class_c(int, int)
,該構造函數再來調用 class_c(int)
和其他構造函數一樣,一個委托構造函數也有一個成員初始化列表和一個函數體,成員初始化列表只能包含一個其它構造函數,不能再包含其它成員變數的初始化,且參數列表必須與構造函數匹配
調用的第一個構造函數將初始化對象,以便此時初始化其所有成員。 不能在委托給另一個構造函數的構造函數中執行成員初始化
例子:
class class_a {
public:
class_a() {}
// member initialization here, no delegate
class_a(string str) : m_string{ str } {}
//can't do member initialization here
// error C3511: a call to a delegating constructor shall be the only member-initializer
class_a(string str, double dbl) : class_a(str) , m_double{ dbl } {}
// only member assignment
class_a(string str, double dbl) : class_a(str) { m_double = dbl; }
double m_double{ 1.0 };
string m_string;
};
如果構造函數還將初始化給定的數據成員,則將重寫成員初始值
例子:
class class_a {
public:
class_a() {}
class_a(string str) : m_string{ str } {}
class_a(string str, double dbl) : class_a(str) { m_double = dbl; }
double m_double{ 1.0 };
string m_string{ m_double < 10.0 ? "alpha" : "beta" };
};
int main() {
class_a a{ "hello", 2.0 }; //expect a.m_double == 2.0, a.m_string == "hello"
int y = 4;
}
構造函數委托語法不會阻止構造函數的遞歸. - Constructor1 將調用 Constructor2(其調用 Constructor1),在出現堆棧溢出之前不會出錯,應當避免這個遞歸
例子:
class class_f{
public:
int max;
int min;
// don't do this
class_f() : class_f(6, 3){ }
class_f(int my_max, int my_min) : class_f() { }
};
如果在委托構造函數中使用try,可以捕獲目標構造函數中拋出的異常
析構函數
析構函數(destructor)是成員函數的一種,它的名字與類名相同,但前面要加~,沒有參數和返回值
析構函數在對象消亡時即自動被調用,可以定義析構函數在對象消亡前做善後工作.也就是說,在對象超出範圍或通過調用 delete 顯式銷毀對象時,會自動調用析構函數
一個類有且僅有一個析構函數
若class沒定義destructor,只有在class內含member object含有destructor時,編譯器才會合成destructor;對許多類來說,這就足夠了。 只有當類存儲了需要釋放的系統資源的句柄
,或擁有其指向的記憶體的指針
時,你才需要定義自定義析構函數
聲明析構函數的規則:
- 不接受自變數
- 沒有返回值(或 void)
- 不能聲明為 const、volatile 或 static。 但是,可以為聲明為 const、volatile 或 static的對象的析構調用它們
- 可以聲明為 virtual。 通過使用虛擬析構函數,無需知道對象的類型即可銷毀對象(使用虛函數機制調用該對象的正確析構函數)。析構函數也可以聲明為抽象類的純虛函數
析構函數在對象消亡時即自動被調用,可以定義析構函數在對象消亡前做善後工作.也就是說,在對象超出範圍或通過調用 delete 顯式銷毀對象時,會自動調用析構函數
當符合以下條件時,將調用析構函數:
- 具有塊範圍的本地(自動)對象超出範圍
- 使用 delete顯式解除分配了使用 new運算符分配的對象
- 臨時對象的生存期結束
- 程式結束,並且存在全局或靜態對象
- 使用析構函數的完全限定名顯式調用了析構函數
若base class不含desturctor,那麼derived class也不需要desturctor
destructor被擴展的方式。與constructor相似,但順序相反:
- destructor函數本體先被執行
- 若class含有member class object,而後者含有destructors,他們會以其聲明順序的相反順序被調用
- 若object內含vptr,現需被重新指定,指向適當的base class的virtual table
- 若有任何直接的nonvirtual base classes含有destructor,它們會以其相反的聲明順序被調用
- 若由任何virtual base classes含有destructor,如之前的PVertex例子,會以其原來的構造順序的相反順序調用
很少需要顯式調用析構函數。 但是,對置於絕對地址的對象進行清理會很有用。 這些對象通常使用採用位置參數的用戶定義的 new運算符進行分配。delete運算符不能釋放該記憶體,因為它不是從自由存儲區分配的
一般而言,constructor和destructor的安插都如預期那樣:
{
Point point;
//point.Point::Point() 安插於此
...
//point.Point::~Point() 安插於此
}
但有些情況desctructor需要放在每一個離開點(此時object還存活)前,例如swith,goto:
{
Point point;
//point.Point::Point() 安插於此
swith ( int(point.x() ) )
{
case -1 :
...
//point.Point::~Point() 安插於此
return;
case 0 :
...
//point.Point::~Point() 安插於此
return;
case 1 :
...
//point.Point::~Point() 安插於此
return;
default :
...
//point.Point::~Point() 安插於此
return;
}
//point.Point::~Point() 安插於此
}
數據成員的記憶體佈局與繼承
影響class記憶體大小的因素
我們由一個例子引入記憶體佈局的話題:
//64位系統
class A{ }; //sizeof(A)為1
class B : virtual public A{ }; //sizeof(B)為8
class C : virtual public A{ }; //sizeof(C)為8
class D : public B, public C{ }; //sizeof(D)為16
四個類的記憶體佈局:
接下來我們一一分析為什麼會產生這樣的結果
class A明明是一