ANSI/ISO C++ Professional Programmer's Handbook(4)

  摘自:http://sttony.blogspot.com/search/label/C%2B%2B

4


特殊成員函數:默認構造器,拷貝構造器,銷燬器和賦值運算符


by Danny Kale




簡介


對象是面向對象程序設計的基本抽象單元。廣義的講,對象是一片內存區域。在類創建的時候就決定了類的屬性。一般的每一個類都有四個特殊成員函數:默認構造器,拷貝構造器,賦值運算符和銷燬器。如果程序員不顯式申明這些成員,編譯器隱含的申明他們。這一章總攬的特殊成員函數的語法和他們在類設計和實現中的規則。這一章也考察了有效使用特殊成員函數的技術和制導方針。


構造器


構造器用於初始化對象。默認構造器可以不帶任何參數的調用。如果沒有用戶定義的類構造器,如果類不包括const或引用數據成員,編譯器會隱含的申明一個默認構造器。


隱含申明的默認構造器是類的inline public成員;它在創建這種類型對象時執行初始化操作。但是,注意,這些操作不包括對用戶申明的數據類型的初始化,或分配空閒內存。例如



class C
{
private:
int n;
char *p;
public:
virtual ~C() {}
};
void f()
{
C obj; // 1隱含定義的構造器被調用
}

程序員在類C中沒有申明一個構造器——隱含的構造器被編譯器申明定義,以在行1中創建類C的一份實例。合成的構造器不初始化成員數據np,之後也不會分配內存。在對象obj創建之後這些數據沒有確定的值。


這是因爲合成的默認構造器只是執行編譯器構造一個對象必須的初始化操作——不是程序員的初始化操作。在這種情況下,C是一個多態的類。這種類型的對象有一個指向類虛函數列表的指針。這個虛指針由隱含定義的構造器初始化。


其他由隱含構造器執行的所需的操作是調用基類的構造器和調用構造器內部對象的構造器。如果程序員沒有定義構造器,編譯器就不會申明隱含 構造器。例如



class C
{
private:
int n;
char *p;
public:
C() : n(0), p(NULL) {}
virtual ~C() {}
};
void f2()
{
C obj; // 1 用戶定義的構造器被調用
}

現在對象obj的數據成員被初始化了,因爲用戶定義的構造器初始化了他們。但是,注意,用戶定義的構造器只初始化了成員np。顯然,虛指針也必須被初始化——否則,程序將有問題。但是什麼時候虛指針的初始化發生呢?編譯器增大了用戶定義的構造器,它在用戶代碼之前插入了附加代碼以執行初始化虛指針的操作。


從類的構造器調用對象的成員函數


因爲虛指針在構造器中的任何用戶代碼之前被初始化,所以在構造器中調用成員函數是安全的(無論是虛函數還是非虛函數)。這保證了調用的虛函數是當前對象定義的(或來自基類,如果在當前類中沒有覆蓋虛函數)。然而,基類的構造器中派生類的虛函數是不會被執行的。例如



class A
{
public:
virtual void f() {}
virtual void g() {}
};
class B: public A
{
public:
void f () {} //覆蓋了overriding A::f()
B()
{
f(); //調用B::f()
g(); //在B中g()沒有被覆蓋,因此調用A::g()
}
};
class C: public B
{
public:
void f () {} //覆蓋了B::f()
};

請注意,如果對象的成員函數使用了對象的數據成員,首先初始化這些成員數據是程序員的責任——通常方便的方法是成員初始化列表(member-initialization list)(成員初始化列表等一會討論)。例如



class C
{
private:
int n;
int getn() const { cout<<n<<endl; }
public:
C(int j) : n(j) { getn(); } //正確:n在調用getn()之前初始化
};

不需要的構造器(Trivial Constructors)


如你所見,編譯器會爲每一個類或結構合成一個默認構造器,如果用戶沒有定義構造器的話。但是在有些情況,這樣一個構造器是
不需要的:



class Empty {}; //類沒有基類,虛函數
//或內部對象
struct Person
{
int age;
char name[20];
double salary;
};
int main()
{
Empty e;
Person p;
p.age = 30;
return 0;
}

程序可以實例化EmptyPerson對象而不需要構造器。在這種情況下,顯式申明的構造器就是
trivial,這意味着程序不需要一個構造器來創建類的實例。當下列情況成立時,類的構造器被認爲是不需要的:




  • 類沒有虛函數並沒有虛基類。





  • 類的所有直接基類都有不需要的構造器。





  • 類中所有的內部類都有不需要的構造器。





可以看到EmptyPerson都完全符合這些條件;因此,他們都有不需要的構造器。編譯器不會自動合成不需要的構造器,因此產生的代碼在尺寸和速度上和C編譯器產生代碼有一樣的效率。


避免在構造器中重複一樣的代碼


爲類定義多個構造器是很普通的。例如,string類可以定義一個接受const char *參數的構造器,另一個接受size_t類型的參數以擴展string的初始化能力,當然還有默認構造器。



class string
{
private:
char * pc;
size_t capacity;
size_t length;
enum { DEFAULT_SIZE = 32};
public:
string(const char * s);
string(size_t initial_capacity );
string();
//...其他成員函數和重載運算符
};

三種構造器都單獨執行自己的初始化操作。雖然如此,一些相同的任務——比如分配內存並初始化分配的內存,或賦值給反映分配內存容量的變量——在每一個構造器中都會執行。替代在構造器中重複相同代碼的作法是,將相同的代碼放在一個非公用的成員函數中。這個函數被每個構造器調用。好處是短的編譯時間和更簡單的維護:



class string
{
private:
char * pc;
size_t capacity;
size_t length;
enum { DEFAULT_SIZE = 32};
//下面的函數被每一個用戶定義的構造器調用
void init( size_t cap = DEFAULT_SIZE);
public:
string(const char * s);
string(size_t initial_capacity );
string();
//...其他成員函數和重載運算符
};
void string::init( size_t cap)
{
pc = new char[cap];
capacity = cap;
}
string::string(const char * s)
{
size_t size = strlen (s);
init(size + 1); //爲兼容以NULL結尾的字符串留出空間
length = size;
strcpy(pc, s);
}
string::string(size_t initial_capacity )
{
init(initial_capacity);
length=0;
}
string::string()
{
init();
length = 0;
}

默認構造器是必需的嗎?


類可以沒有默認構造器。例如



class File
{
private:
string path;
int mode;
public:
File(const string& file_path, int open_mode);
~File();
};

File有一個用戶定義的接受兩個參數的構造器。用戶定義的構造器的存在阻止類隱含默認構造器的合成。因爲程序員也沒有定義默認構造器,類File沒有默認構造器。沒有默認構造器的類限制了使用類的使用。例如,當對象數組初始化的時候,每個數組成員的默認構造器——並且只有默認構造器——被調用。因此,如果你不使用完整的初始化列表,你不能實例化數組:



File folder1[10]; //錯誤,數組需要默認構造器
File folder2[2] = { File("f1", 1)}; //錯誤,f2[1]也需要
//一個默認構造器
File folder3[3] = { File("f1", 1), File("f2",2), File("f3",3) }; //OK,
//完整的初始化數組
存儲沒有默認構造器的對象到STL容器時同樣的困難將產生:#include <vector>
using namespace std;
void f()
{
vector <File> fv(10); //錯誤,File沒有默認構造器
vector <File> v; //OK
v.push_back(File("db.dat", 1)); //OK
v.resize(10); //錯誤,File沒有默認構造器
v.resize(10, File("f2",2)); //OK
}

File是故意缺少默認構造器的嗎?可能。或許程序員認爲File數組是不需要的,因爲數組的每一個對象需要不同的路徑和打開方式。然而,缺乏默認構造器影響對大多數類十分重要的通用性。


對STL容器的適用性


爲了作爲STL容器中的一個元素,對象必須有公用的一個拷貝構造器,一個賦值運算符,和銷燬器(詳細參見第十章“STL和泛型程序設計”)。


默認的構造器也是一些STL容器操作的需要,如你在前面的例子中看到的。


許多操作系統將同一目錄下的文件作爲一個文件對象的鏈表存儲。由於忽略File的默認構造器,程序員嚴重影響了File的用戶實現一個象std::list<File>的文件系統的能力。


對於象File這樣的類——它的構造器必須用用戶提供的值初始化對象,它仍然可以定義一個默認構造器。默認構造器可以從一個連續數據庫文件中讀取必要的路徑和打開方式,以代替從構造器的參數中獲得。


什麼時候不需要默認構造器?


儘管如此,默認構造器有些時候也是不需要的。一個孤立對象就是這樣一個例子。因爲孤立有且僅有一個實例,建議你通過使默認構造器不能使用以阻止創建孤立對象的數組和容器。例如



#include<string>
using namespace std;
int API_getHandle(); //系統API函數
class Application
{
private:
string name;
int handle;
Application(); //使默認構造器不可用
public:
explicit Application(int handle);
~Application();
};
int main()
{
Application theApp( API_getHandle() ); //ok
Application apps[10]; //錯誤,默認構造器不可用
}

Application沒有默認構造器;因此,創建Application對象的數組和容器是不可能的。這種情況下,缺乏默認構造器是有意的(程序員仍需要保證Application有且僅有一個實例被創建。但是,使默認構造器不可用也是實現細節之一)。


基本類型的構造器


charintfloat這樣的基本類型也有構造器就象用戶定義類一樣。你可以通過顯式調用
他們的默認構造器來初始化變量:



int main()
{
char c = char();
int n = int ();
return 0;
}

顯式調用默認構造器返回的值等於類的0。換句話說,



char c = char();

等價於



char c = char(0);

當然,初始化基本類型爲別的值也是可能的:



float f = float (0.333);
char c = char ('a');

通常,你使用短的記號:



char c = 'a';
float f = 0.333;

可是,語言的這個擴展使程序員能在模板中統一的對待基本類型和用戶定義類型。通過運算符new動態分配的基本類型變量能以同樣的方式初始化:



int *pi= new int (10);
float *pf = new float (0.333);

顯式的構造器


默認情況下,只帶一個參數的構造器是一個將參數轉換成類對象的隱含轉換運算符(見第三章“運算符重載”)。考慮下面具體的例子:



class string
{
private:
int size;
int capacity;
char *buff;
public:
string();
string(int size); //構造器和隱含轉換運算符
string(const char *); //構造器和隱含轉換運算符
~string();
};

string有三個構造器:一個默認構造器,一個接受int的構造器和一個從const char *構造字符串的構造器。第二個構造器用於創建一個只有指定大小緩衝區的空string對象。然而,在這種string類中,自動類型轉換是不明確的。將一個int轉換成string對象是不明智的,儘管構造器這樣做是正確的。考慮下面的:



int main()
{
string s = "hello"; //OK,將一個C字符串轉換成string對象
int ns = 0;
s = 1; // 1,程序員故意寫了ns = 1!?
}

在表達式s= 1;中,程序員只是簡單的將變量ns,寫錯爲s。一般編譯器檢測到了類型不一致,並且發出了錯誤的消息。一般情況,編譯器檢測到類型不一致並且發出錯誤消息。但是,在這之前,編譯器首先搜索允許這種表達式的用戶定義轉換;結果,它發現了接受int的構造器。因此,編譯器按程序員寫的解釋了表達式s= 1;



s = string(1);

你可能在調用一個接受string參數的函數時與到相同的情況。下面的例子即可能是一種含糊的編碼風格也可能是程序員的排字錯誤。然而,導致隱含轉換的類string的構造器將悄悄的被通過:



int f(string s);
int main()
{
f(1); //沒有一個顯式的構造器,
//這個調用將被擴展成:f ( string(1) );
//這是故意的還是程序員的筆誤?
}


爲了避免這種隱含的轉換,接受一個參數的構造器需要申明爲explicit



class string
{
//...
public:
explicit string(int size); //避免隱含轉換
string(const char *); //隱含轉換
~string();
};

一個explicit的構造器不象一個隱含的轉換運算符,前者使編譯器在這種情況下能檢測到排字錯誤:



int main()
{
string s = "hello"; //OK,將C字符串轉換成string對象
int ns = 0;
s = 1; //編譯期錯誤;編譯期檢測到了排字錯誤
}

所有的構造器爲什麼不自動申明爲explicit呢?在有些情況下,自動類型轉換很有用而且工作良好。一個好的例子就是string的第三個構造器:



string(const char *);

隱含的將const char *轉換成string對象的類型轉換運算符使的上面的代碼可以寫成:



string s;
s = "Hello";

編譯器隱含的轉換成



string s;
//僞C++ 代碼:
s = string ("Hello"); //建立臨時對象並將它賦值給s

另一方面,如果你將構造器申明爲explicit,你必須使用顯式的類型轉換:



class string
{
//...
public:
explicit string(const char *);
};
int main()
{
string s;
s = string("Hello"); //現在需要顯式的轉換
return 0;
}

遺留的衆多C++代碼依賴於構造器的隱含轉換。C++標準化委員會意識到這一點。爲了不讓現有的代碼報廢,隱含轉換被保留了。但是,新的關鍵字,explicit,被引如到語言中,以便讓程序員在不想要的時候避免隱含轉換。作爲規則,只有一個參數的構造器需要被申明爲explicit。當需要隱含類型轉換時,構造器可以用作隱含轉換運算符。


阻止不想要的對象實例化


有時阻止程序員從特定類實例化對象是十分有用的。例如,一個只用來被繼承的基類。一個protected構造器禁止創建類的實例,但是不阻止派生類產生實例:



class CommonRoot
{
protected:
CommonRoot(){}//該類不需要實例
virtual ~CommonRoot ();
};
class Derived: public CommonRoot
{
public:
Derived();
};
int main()
{
Derived d; //OK,d的構造器訪問
//基類的保護成員
CommonRoot cr; //編譯期錯誤:嘗試訪問
//CommonRoot的保護成員
}

通過申明純虛函數同樣可以達到禁止類實例化的效果。但是,使用純虛函數增加了運行時間和空間的開銷。當不需要純虛函數時,你可以使用protected構造器。


使用成員初始化列表


構造器可以有一個用來初始化類數據成員的成員初始化列表(簡稱mem-initialization)。例如



class Cellphone //1:mem-init
{
private:
long number;
bool on;
public:
Cellphone (long n, bool ison) : number(n), on(ison) {}
};

Cellphone的構造器也可以寫成如下形式:



Cellphone (long n, bool ison) //2 在構造器內部初始化數據成員
{
number = n;
on = ison;
}

Cellphone構造器的兩種形式沒有本質的不同。這是由編譯器對成員初始化列表的處理過程決定的。編譯器掃描成員初始化列表,然後在構造器的用戶代碼之前插入初始化代碼。因此,第一個例子中的構造器被編譯器擴展成第二個例子中的構造器。雖然如此,有下面四種情況的時候,選擇成員初始化列表還是在構造器內部初始化有重大意義:




  • const成員的初始化





  • 引用成員的初始化





  • 要傳遞參數給基類或內部對象的構造器





  • 成員對象的初始化





在1、2、3種情況下,成員初始化列表是強制性的;在第四種,它是可選擇的。考慮下面的具體例子。


const數據成員


類的const數據成員,包括基類和內部對象的const成員,必須在成員初始化列表中初始化。



class Allocator
{
private:
const int chunk_size;
public:
Allocator(int size) : chunk_size(size) {}
};

引用數據成員


引用數據成員必須在成員初始化列表中初始化。



class Phone;
class Modem
{
private:
Phone & line;
public:
Modem(Phone & ln) : line(ln) {}
};

調用基類或成員對象時傳遞參數


當構造器必須傳遞到基類的構造器後內部對象的構造器時,必須使用成員初始化列表。



class base
{
private:
int num1;
char * text;
public:
base(int n1, char * t) {num1 = n1; text = t; } //非默認構造器
};
class derived : public base
{
private:
char *buf;
public:
derived (int n, char * t) : base(n, t) //傳遞參數到基類構造器
{ buf = (new char[100]);}
};

內部對象


考慮下面的例子:



#include<string>
using std::string;
class Website
{
private:
string URL
unsigned int IP
public:
Website()
{
URL = "";
IP = 0;
}
};

Website有一個類型爲std::string的內部變量。語法規則並沒有強制必須使用成員初始化列表類初始化這個成員。但是,與在構造器內部初始化相比,選擇成員初始化列表能帶來較大的性能提升。爲什麼呢?在構造器內部初始化是非常低效的,因爲需要構造成員URL;從""將構造一個臨時的std::string對象,然後在賦值給URL。之後,臨時對象將被銷燬。另一方面,使用成員初始化列表將避免創建和銷燬臨時對象(使用成員初始化列表的性能提升將在第十二章“優化你的代碼”中進一步討論)。


成員初始化列表的順序必須符合類成員申明的順序


由於初始化內部對象的兩種形式有性能差距,有些程序員排斥在構造器內部初始化對象——甚至對基礎類型也是如此。但是必須注意,成員初始化列表中對象的順序必須和他們在類中申明的順序相同。這是因爲編譯器按類成員申明的順序轉化列表,不管程序員指定的順序。例如:



class Website
{
private:
string URL; //1
unsigned int IP; //2
public:
Website() : IP(0), URL("") {} //以倒序初始化
};

在成員初始化列表裏,成員先初始化IP然後是URL,儘管IPURL之後申明。編譯器將成員初始化列表的順序轉換成類中成員申明的順序。在這種情況下,倒序是無害的。但是當需要依賴成員初始化列表的順序的時候,這種轉換可能帶來意想不到的問題。例如



class string
{
private:
char *buff;
int capacity;
public:
explicit string(int size) :
capacity(size), buff (new char [capacity]) {}//未定義的行爲
};

string構造器的成員初始化列表不是類中成員申明的順序。因此,編譯器將列表轉換成



explicit string(int size) :
buff (new char [capacity]), capacity(size) {}

成員capacity指定了要new分配的內存大小;但是大小並沒有初始化。這種情況的結果是未知的。有兩種方法消除這個缺陷:改變成員的申明順序使capacitybuff之前申明,或將buff的初始化移到構造器內部。


隱含申明的默認構造器的異常說明


隱含申明的默認構造器有一個exception specification(異常說明)(異常說明在第六章“異常處理”討論)。隱含申明的默認構造器的異常說明包括構造器直接調用的特殊成員函數可能拋出的異常。例如



struct A
{
A(); //可能拋出任何類型的異常
};
struct B
{
B() throw(); //不允許拋出任何異常
};
struct C : public B
{
//隱含申明的C::C() throw;
}
struct D: public A, public B
{
//隱含申明的D::D();
};

類C中隱含申明的構造器不允許拋出任何類型的異常,因爲它直接調用類B的構造器,而後者不允許拋出任何異常。另一方面,類D中隱含申明的構造器允許拋出任何異常,因爲它直接調用了類AB的構造器。因爲類A的構造器允許拋出任何類型的異常,所以類D的隱含構造器有一個一樣的異常說明。換句話說,如果構造器直接調用的任何函數允許拋出任何異常的話,D的隱含申明的構造器就允許拋出任何異常;如果構造器直接調用的任何函數不允許拋出任何異常的話,它就不允許拋出任何異常。象你即將看到的,這條規則適用於其他隱含申明的特殊成員函數。


拷貝構造器


拷貝構造器用於從其他對象來初始化一個對象。如果構造器的第一個參數是C&const C&amp;volatile C&、或const volatile C&,且沒有其他參數或其他參數有默認值,那麼構造器就是類C的拷貝構造器。如果沒有拷貝構造器,編譯器會隱含申明一個。如果類C的所有基類都有第一個參數是自身對象的引用的拷貝構造器,並且類C所有的非靜態內部對象都有接受自身const對象引用的拷貝構造器,那麼隱含申明的拷貝構造器是一個類的inline public成員,有如下形式



C::C(const C&);

否則,隱含申明的拷貝構造器是下面的類型:



C::C(C&);

隱含申明的拷貝構造器有一個異常說明。拷貝構造器的異常申明包括拷貝構造器直接調用的其他特殊成員函數可能拋出的任何異常。


隱含定義的拷貝構造器


如果類沒有虛函數、沒有虛基類或它的直接基類和內部對象只有不需要的拷貝構造器,那麼類的拷貝構造器就是不需要的。編譯器隱含定義的拷貝構造器由本類型的對象的一個拷貝來初始化本類型的一個對象(或從其派生類對象的拷貝來初始化)。隱含申明的拷貝構造器執行從參數指定的對象拷貝到本對象以完成初始化的過程,如下面的例子:



#include<string>
using std::string;
class Website //沒有用戶定義的拷貝構造器
{
private:
string URL;
unsigned int IP;
public:
Website() : IP(0), URL("""") {}
};
int main ()
{
Website site1;
Website site2(site1); //調用隱含申明的拷貝構造器
}

程序員沒有爲類Website申明拷貝構造器。因爲Website有一個std::string類型的內部對象,這個對象恰好有用戶定義的拷貝構造器,所以編譯器爲類Website隱含定義了一個拷貝構造器,並且使用它將對象site1拷貝到site2。合成的拷貝構造器首先調用std::string的構造器,然後以位拷貝的方式將site1拷貝到site2


有時初學者被鼓勵爲自己的類定義四種特殊成員函數。就象在類Website中看到的一樣,這不僅沒有必要,而且有時甚至是不受歡迎的。合成的拷貝構造器(以及賦值運算符,就象你即將看到的)已經“正確工作”了。他們自動調用基類和子對象的構造器,他們初始化虛指針(如果它存在),而且他們執行基本類型的位拷貝。在許多情況下,這完全符合程序員的要求。此外,編譯器自動生成的拷貝構造器比用戶的代碼更有效率,因爲它總是使用最優化原則,而用戶代碼不可能總是使用這些原則。


實現需要的初始化


象普通的構造器一樣,拷貝構造器——無論是隱含申明的還是用戶定義的——都被編譯器擴展了,插入了調用基類和內部對象拷貝構造器的代碼。但是,保證虛基對象只被拷貝一次。


模仿虛構造器


與普通函數不一樣的是,構造器必須在編譯期知道它對象的確切類型,以便能正確構造它。因此,構造器不能被申明爲virtual。儘管如此,創建對象時不知道對象類型是很常見的。最簡單的模仿虛構造器的方法是通過定義返回本類對象的虛函數。例如



class Browser
{
public:
Browser();
Browser( const Browser&);
virtual Browser* construct()
{ return new Browser; } //虛默認構造器
virtual Browser* clone()
{ return new Browser(*this); } //虛拷貝構造器
virtual ~Browser();
//...
};
class HTMLEditor: public Browser
{
public:
HTMLEditor ();
HTMLEditor (const HTMLEditor &);
HTMLEditor * construct()
{ return new HTMLEditor; }//虛默認構造器
HTMLEditor * clone()
{ return new HTMLEditor (*this); } //虛拷貝構造器
virtual ~HTMLEditor();
//...
};

成員函數clone()construct()的多態性使你能用正確的類型初始化新對象,而不必知道源對象的確切類型。



void create (Browser& br)
{
br.view();
Browser* pbr = br.construct();
//...使用pbr和br
delete pbr;
}

pbr被賦予右邊對象的指針——不管是Browser還是任何從Browser公共派生的任何類。注意對象沒有刪除它創建的新對象;這是用戶的職責。如果這樣做,繁殖對象的生存週期將不依賴於它的創造者的生存週期——在是使用這種技術的一個顯著的安全問題。


虛成員函數的Covariance


虛構造器的實現依賴於C++最近的叫做虛函數的covariance的改進。代理虛函數必須匹配標誌和其所代理函數的返回類型。這個限制在近來被放寬了,以使代理虛函數的返回類型隨其所屬類的類型變化。因此,公共基類的返回類型可以變成派生類的類型。covariance僅適用於指針和引用。





警告:請注意有些編譯器還不支持虛成員函數的covariance。



賦值運算符


用戶定義的類C賦值運算符是一個非靜態的,非模板成員的,只接受類型爲CC&const C&amp;volatile C&const volatile C&的成員函數。


隱含定義的賦值運算符


如果用戶沒有定義類的賦值運算符,編譯器會隱含申明一個。隱含申明的賦值運算符是類的inline public成員。如果類C的每一個基類都有都有第一個參數爲基類const對象引用的的賦值運算符,如果類C的所有非靜態內部對象都有第一個參數是本身類型const對象引用的賦值運算符,那麼隱含申明的賦值運算符有如下形式:



C& C::operator=(const C&amp;);

否則,隱含申明的賦值運算符是下面類型:



C& C::operator=(C&);

隱含申明賦值運算符有異常說明。異常說明包括所有賦值運算符直接調用特殊成員函數可能拋出的異常。如果賦值運算符是隱含申明的,如果類沒有虛函數或虛基類,如果它的所屬類的直接基類和內部對象有不需要的賦值運算符,那麼這個賦值運算符就是不需要的。


賦值運算符的模擬繼承


因爲程序員不申明賦值運算符賦值運算符就是隱含申明的,基類的賦值運算符總是被派生類的賦值運算符所隱藏。爲了在派生類中擴展——而不是代理——賦值運算符,你必須先顯式調用基類的賦值運算符,然後在增加派生類需要的操作。例如



class B
{
private:
char *p;
public:
enum {size = 10};
const char * Getp() const {return p;}
B() : p ( new char [size] ) {}
B& operator = (const C&amp; other);
{
if (this != &other)
strcpy(p, other.Getp() );
return *this;
}
};
class D : public B
{
private:
char *q;
public:
const char * Getq() const {return q;}
D(): q ( new char [size] ) {}
D& operator = (const D& other)
{
if (this != &other)
{
B::operator=(other); //先顯式調用基類的賦值運算符
strcpy(q, (other.Getq())); //增加擴展
}
return *this;
}
};

什麼時候需要用戶寫拷貝構造器和賦值運算符呢?


合成的拷貝構造器和賦值運算符執行成員級別的拷貝。對大多數用法來說這是需要的。但是,它對於包含指針、引用、句柄的類來說這是災難性的。在很多情況下,你必須定義拷貝構造器和賦值運算符以避免混淆。當相同的資源被不同的對象同時使用時,混淆發生類。例如



#include <cstdio>
using namespace std;
class Document
{
private:
FILE *pdb;
public:
Document(const char *filename) {pdb = fopen(filename, "t");}
Document(FILE *f =NULL) : pdb{}
~Document() {fclose(pdb);} //錯誤,沒有定義拷貝構造器
//或賦值運算符
};
void assign(Document& d)
{
Document temp("letter.doc");
d = temp; //混淆;d和temp指向同一個文件
}//temp's銷燬器關閉了d正在使用的文件
int main()
{
Document doc;
assign(doc);
return 0;
//doc現在使用的文件已被關閉了。
}}//天啊!doc的銷燬器被調用,又關閉了‘letter.doc’一邊

因爲類Document沒有實現拷貝構造器和賦值運算符,所以編譯器隱含的申明他們。但是合成的拷貝構造器和賦值運算符導致混淆。試圖打開或關閉一個文件兩次是未定義的行爲。解決這個問題的方法是定義恰當的拷貝構造器和賦值運算符。然而請注意,混淆來自語言的底層構造(在這裏是文件指針),因此內部fstream對象可以自動執行必要的檢查。這時用戶定義的拷貝構造器和賦值運算符時不必要的。當用string對象代替暴露的char指針時,會發生同樣的問題。如果你在類Website中使用char指針而不是std::string,你同樣面對混淆的問題。


實現拷貝構造器和賦值運算符


另一個可以從前面的例子中得出的總結是,無論什麼時候你都自己定義拷貝構造器和賦值運算符。你只定義一個的話,編譯器創建另一個——但是它不會如你所願的工作。





“Big Three Rule”還是“Big Two Rule”?

著名的“Big Three Rule”說:如果類需要任何三個重要成員函數(拷貝構造器、賦值運算符和銷燬器)的一個,那麼它也必須要其他兩個。一般的,這條規則適用於需要動態分配內存的類。但是,許多其他的類僅需要兩個(拷貝構造器和賦值運算符)用戶定義的重要函數;銷燬器並不重是必需的。考慮下面的例子:



class Year
{
private:
int y;
bool cached; //對象是否緩存?
public:
//...
Year(int y);
Year(const Year& other) //緩存不能被拷貝
{
y = other.getYear();
}
Year& operator =(const Year&amp;other) //緩存不能被拷貝
{
y = other.getYear();
return *this;
}
int getYear() const { return y; }
};//類Year不需要銷燬器

Year沒有動態分配內存,在它的存在期內也沒又獲得其他資源。所以不需要銷燬器。但是,類需要用戶定義的拷貝構造器和賦值運算符以保證成員cached的值不被拷貝,因爲這個值是被對象分別處理的。



當用需要戶定義的拷貝構造器和賦值運算符時,避免自己賦值給自己和指針混淆是十分重要的。通常,完全實現其中的一個就行了,另一個可以按第一個的方法定義。例如



#include <cstring>
using namespace std;
class Person
{
private:
int age;
char * name;
public:
int getAge () const { return age;}
const char * getName() const { return name; }
//...
Person (const char * name = NULL, int age =0) {}
Person & operator= (const Person &amp; other);
Person (const Person& other);
};
Person & Person::operator= (const Person &amp; other)
{
if (&other != this) //警惕自己賦值給自己
{
size_t len = strlen( other.getName());
if (strlen (getName() ) < len)
{
delete [] name; //釋放當前緩衝區
name = new char [len+1];
}
strcpy(name, other.getName());
age = other.getAge();
}
return *this;
}
Person::Person (const Person & other)
{
*this=other; //OK,用戶定義的賦值運算符被調用
}

阻止對象拷貝


有些情況下希望禁止用戶拷貝或賦值到一個新對象。你可以通過顯式定義拷貝構造器和賦值運算符爲private來禁止這兩種行爲:



class NoCopy
{
private:
NoCopy& operator = (const NoCopy& other) { return *this; }
NoCopy(const NoCopy& other) {/*..*/}
public:
NoCopy() {}
//...
};
void f()
{
NoCopy nc; //正確,調用默認拷貝構造器 fine, default constructor called
NoCopy nc2(nc); //錯誤;試圖調用一個私有的拷貝構造器
nc2 = nc; //also a compile time error; operator= is private
}

銷燬器


銷燬器銷燬對象。它不接受參數也沒有返回類型(甚至沒有void)。constvolatile限制在對象銷燬後不在適用;因此,銷燬器不能被constvolatile、或const volatile對象調用。如果類沒有用戶定義的銷燬器,編譯器會隱含申明一個。隱含申明的銷燬器是類的inline public成員,並且有異常說明。異常說明包括它直接調用的特殊成員函數可能拋出的所有異常。


如果銷燬器是隱含申明的,如果類的全部直接基類和內部對象都有不需要的銷燬器,那麼類的銷燬器就是不需要的。否則,銷燬器就是必需的。銷燬器調用直接基類和成員對象的銷燬器。調用以他們構造順序的相反順序進行。所有的銷燬器以他們的名字調用,忽略任何可能的派生類的虛代理銷燬器。例如



#include <iostream>
using namespace std;
class A
{
public:
virtual ~A() { cout<<"destroying A"<<endl;}
};
class B: public A
{
public:
~B() { cout<<"destroying B"<<endl;}
};
int main()
{
B b;
return 0;
};

程序顯示



destroying B
destroying A

這是因爲編譯器將用戶定義的類B銷燬器變化成



~B()
{
//用戶代碼在下面
cout<<"destroying B"<<endl;
//編譯器插入的僞C++代碼
this->A::~A(); //銷燬器使用他們的名字調用
}

儘管類A的銷燬器是虛函數,但是插入類B銷燬器中的調用靜態的(通過名字調用函數繞過動態綁定機制)


顯式銷燬器調用


在下列情況中銷燬器隱含的被調用:




  • 程序終止時對靜態對象





  • 創建局域對象的代碼塊結束時對局域對象





  • 臨時對象結束生存時對臨時對象





  • 使用new分配的對象,在使用delete釋放時





  • 異常導致的堆棧溢出時,對引起異常的對象





銷燬器也可以顯式的調用。例如:



class C
{
public:
~C() {}
};
void destroy(C& c)
{
c.C::~C(); //顯式的銷燬器調用
}

銷燬器也可以被類的成員函數在對象中顯式調用:



void C::destroy()
{
this->C::~C();
}

特別的,對於通過placement new運算符創建的對象來說,顯式調用銷燬器是必要的(placement new在第十一章“內存管理”中討論)。


僞銷燬器


你知道,基本類型有構造器。另外,基本類型也有pseudo銷燬器。構造僞銷燬器的唯一目的是爲了適應泛型算法和容器的需要。它是對對象沒有實際作用的no-op代碼。如果你檢查編譯器爲僞銷燬器產生的彙編代碼,你可能發現編譯器簡單的忽略了他們。下面的例子演示僞銷燬器的調用:



typedef int N;
int main()
{
N i = 0;
i.N::~N(); //僞銷燬器調用
i = 1; //i不受調用僞銷燬器的影響
return 0;
}

變量i定義並初始化。在接下來的語句中,非class類型N的銷燬器被顯式調用,但是它對對象本身沒有任何影響。象基本類型的構造器一樣,僞銷燬器使用戶不必知道給出的類型是否真的有銷燬器,就可以調用銷燬器。


純虛銷燬器


不象普通成員函數,當它在派生類中被重新定義時,虛構造器不能被代替。恰當的說,它被擴展了。最底層的銷燬器首先調用它基類的銷燬器;然後再執行自己。因此,當你申明純虛銷燬器時,你可能碰到編譯期錯誤,或更糟的運行期錯誤。基於這種考慮,純虛銷燬器是純虛函數的例外——他們必需被定義。你可以不申明銷燬器爲純虛的,僅使它是虛函數。但是,這對於設計的安全性來說是不需要的。你可以喜歡兩個詞,只要迫使純虛的接口有銷燬器——而且這樣不會引起運行期崩潰。它是怎麼做的呢?


首先,抽象類僅包括一個純虛銷燬器的申明:



class Interface
{
public:
virtual void Open() = 0;
virtual ~Interface() = 0;
};

在類外面定義的話,純虛銷燬器必須這樣定義:



Interface::~Interface()
{} //純虛銷燬器的定義;必須總是空的

構造器和銷燬器必須是最小的


當你設計類時,記住它可能作爲派生類的基類。它也可能作爲一個更大類的成員對象。與其他成員函數可以被代替或完全不被調用不同,基類的構造器和銷燬器自動被調用。迫使它的派生類和包容它的類做其不需要做的事不是一個好主意,但是必須接受這種強迫。換句話說,構造器和銷燬器只需做構造和銷燬對象的基本工作。示範一個具體的例子:支持持久化的string類不需要在構造器中打開/關閉文件。這樣的工作留給專職的成員函數。當新的派生類——存儲固定長度字符串的ShortString——創建時,它的構造器就不需要被迫執行基類的構造器強加的文件I/O。


總結


構造器、拷貝構造器、賦值運算符和銷燬器自動完成創建、拷貝和銷燬對象時的煩人的操作。C++中構造器和銷燬器的對稱在面向對象程序設計語言中是不常見的,並且他們是高級設計術語的基礎(象你在下一章“面向對象的編程和設計”中看到的)。


每一個C+對象擁有程序員申明的或編譯器隱含申明的4個成員函數。隱含申明的特殊成員函數可能是不需要的,這意味着編譯器不會定義它。合成的特殊成員函數只執行實現類必須的操作。用戶寫的特殊成員函數也被編譯器自動擴展——保證基類和內部子對象正確的被初始化——然後在虛指針中加入相應的項。基本類型也有構造器和僞銷燬器,這是爲了泛型程序設計的需要。


在許多情況下,合成構造器工作正確。當默認行爲不正確時,程序員就必須自己顯式的定義一個或多個特殊成員函數。然而在一般情況下,用戶寫代碼的需要來自有高層接口的低層數據結構,而且可能是設計上的問題。申明構造器爲explicit保證它不被當作隱含的轉換運算符。


成員初始化列表對於const成員和引用數據成員是必需的,要傳遞參數給基類或內部子對象時它也是必需的。在其他情況下,成員初始化列表是可選的,但能提高性能。構造器和賦值運算符可以用來控制對象的實例化和對象的拷貝。銷燬器可以顯式的調用。申明爲純虛的銷燬器必需定義。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章