本文為 C++ 學習筆記,參考《Sams Teach Yourself C++ in One Hour a Day》第 8 版、《C++ Primer》第 5 版、《代碼大全》第 2 版。
面向對象程式設計有四個重要的基礎概念:抽象、封裝、繼承和多态。本文整理 C++ 中類與對象的基礎内容,涉及抽象和封裝兩個概念。《C++基礎-繼承》一文講述繼承概念。《C++基礎-多态》一文講述多态概念。這些内容是 C++ 中最核心的内容。
抽象
抽象是一種忽略個性細節、提取共性特征的過程。當用“房子”指代由玻璃、混凝土、木材組成的建築物時就是在使用抽象。當把鳥、魚、老虎等稱作“動物”時,也是在使用抽象。
基類是一種抽象,可以讓使用者關注派生類的共同特性而忽略各派生類的細節。類也是一種抽象,使用者可以關注類的接口本身而忽視類的内部工作方式。函數接口、子系統接口都是抽象,各自位于不同的抽象層次,不同的抽象層次關注不同的内容。
抽象能使人以一種簡化的觀點來考慮複雜的概念,忽略繁瑣的細節能大大降低思維及實作的複雜度。如果我們在看電視前要去關注塑膠分子、玻璃分子、金屬原子是如何組成一部電視機的、電與磁的原理是什麼、圖像是如何産生的,那這個電視不用看了。我們隻是要用一台電視,而不關心它是怎麼實作的。同理,軟體設計中,如果不使用各種抽象層次,那麼這一堆代碼将變得無法了解無法維護甚至根本無法設計出來。
封裝
抽象是從一種高層的視角來看待一個對象。而封裝則是,除了那個抽象的簡化視圖外,不能讓你看到任何其他細節。簡言之,封裝就是隐藏實作細節,隻讓你看到想給你看的。
在程式設計中,就是把類的成員(屬性和行為)進行整合和分類,确定哪些成員是私有的,哪些成員是公共的,私有成員隐藏,公共成員開放。類的使用者(調用者)隻能通路類的公共接口。
1. 類與對象
// 類:人類
class Human
{
public:
// 成員方法:
void Talk(string textToTalk); // 說話
void IntroduceSelf(); // 自我介紹
private:
// 成員屬性:
string m_name; // 姓名
string m_dateOfBirth; // 生日
string m_placeOfBirth; // 出生地
string m_gender; // 性别
...
};
// 對象:具體的某個人
Human xiaoMing;
Human xiaoFang;
對象是類的執行個體。語句
Human xiaoMing;
和
int a;
本質上并無不同,對象和類的關系,等同于變量和類型的關系。
不介意外部知道的資訊使用 public 關鍵字限定,需要保密的資訊使用 private 關鍵字限定。
2. 構造函數
2.1 構造函數
構造函數用于定義類的對象初始化的方式,無論何時隻要類的對象被建立,就會執行構造函數。
- 構造函數名字與類名相同
- 構造函數無傳回值
- 構造函數可以重載,一個類可有多個構造函數
- 構造函數不能被聲明為 const,因為一個 const 對象也是通過構造函數完成初始化的,構造函數完成初始化之後,const 對象才真正取得"常量"屬性。
構造函數形式如下:
class Human
{
public:
Human(); // 構造函數聲明
};
Human::Human() // 構造函數實作(定義)
{
...
}
2.2 預設構造函數
可不提供實參調用的構造函數是預設構造函數(Default Constructor)。在類的使用者看來,不提供實參就完成了對象的初始化,那就是這些對象執行了預設初始化,控制這個預設初始化過程的構造函數就叫預設構造函數。
預設構造函數包括如下兩種:
- 不帶任何函數形參的構造函數是預設構造函數
- 帶有形參但所有形參都提供預設值的構造函數也是預設構造函數,因為這種構造函數既可以攜帶實參調用,也可以不帶實參調用
2.3 合成的預設構造函數
當使用者未給出任何構造函數時,編譯器會自動生成一個構造函數,叫作合成的預設構造函數(Synthesized Default Constructor)。合成的預設構造函數對類的資料成員初始化規則如下:
- 若資料成員存在類内初始化值,則用這個初始化值來初始化資料成員
- 否則,執行預設初始化。預設值由資料類型确定。參 "C++ Primer 5th" 第 40 頁
下面這個類因為沒有任何構造函數,是以編譯器會生成合成的預設構造函數:
class Human
{
public:
// 成員方法:
void Talk(string textToTalk); // 說話
void IntroduceSelf(); // 自我介紹
private:
// 成員屬性:
string m_name; // 姓名
string m_dateOfBirth; // 生日
string m_placeOfBirth; // 出生地
string m_gender; // 性别
};
2.4 參數帶預設值的構造函數
函數可以有帶預設值的參數,構造函數當然也可以。
class Human
{
private:
string m_name;
int m_age;
public:
// overloaded constructor (no default constructor)
Human(string humansName, int humansAge = 25)
{
m_name = humansName;
m_age = humansAge;
...
};
可以使用如下形式的執行個體化:
Human adam("Adam"); // adam.m_age is assigned a default value 25
Human eve("Eve", 18); // eve.m_age is assigned 18 as specified
2.5 帶初始化清單的構造函數
初始化清單是一種簡寫形式,将相關資料成員的初始化清單寫在函數名括号後,進而可以省略函數體中的相應資料成員指派語句。
Human::Human(string humansName, int humansAge) : m_name(humansName), m_age(humansAge)
{
}
上面這種寫法和下面這種寫法具有同樣的效果:
Human::Human(string humansName, int humansAge)
{
m_name = humansName;
m_age = humansAge;
}
2.6 拷貝構造函數和移動構造函數
2.6.1 淺複制及其問題
複制一個類的對象時,隻複制其指針成員但不複制指針指向的緩沖區,其結果是兩個對象指向同一塊動态配置設定的記憶體。銷毀其中一個對象時,delete[] 釋放這個記憶體塊,導緻另一個對象存儲的指針拷貝無效。這種複制被稱為淺複制。
如下為淺複制的一個示例程式:
#include <iostream>
#include <string.h>
#include <stdio.h>
using namespace std;
class MyString
{
private:
char *m_buffer;
public:
MyString(const char *initString) // Constructor
{
m_buffer = NULL;
cout << "constructor" << endl;
if (initString != NULL)
{
m_buffer = new char[strlen(initString) + 1];
strcpy(m_buffer, initString);
}
}
~MyString() // Destructor
{
cout << "destructor, delete m_buffer " << hex << (unsigned int *)m_buffer << dec << endl;
delete[] m_buffer;
}
void PrintAddress(const string &prefix)
{
cout << prefix << hex << (unsigned int *)m_buffer << dec << endl;
}
};
void UseMyString(MyString str)
{
str.PrintAddress("str.m_buffer addr: ");
}
int main()
{
MyString test("12345678901234567890"); // 直接初始化,執行構造函數 MyString(const char* initString)
test.PrintAddress("test.m_buffer addr: ");
UseMyString(test); // 拷貝初始化,執行合成的預設拷貝構造函數,淺複制
return 0;
}
運作程式,輸出如下:
constructor
test.m_buffer addr: 0x1513280
str.m_buffer addr: 0x1513280
destructor, delete m_buffer 0x1513280
destructor, delete m_buffer 0x1513280
從運作結果中可以看到,test 對象和 str 對象的 m_buffer 指針指向同一記憶體區,兩個對象銷毀導緻這一記憶體區也被 delete 了兩次,會導緻難以預料的嚴重後果。
分析一下
UseMyString(test);
這一語句:
- test 對象執行直接初始化,根據參數比對規則調用了構造函數
。MyString(const char* initString)
函數的形參 str 對象執行拷貝初始化,是以将調用編譯器合成的拷貝構造函數。UseMyString(MyString str)
- 合成拷貝構造函數執行對象淺複制,将實參 test 複制給形參 str,複制了對象中資料成員(指針)的值,但未複制成員指向的緩沖區,是以兩個對象的資料成員(指針 m_buffer)指向同一記憶體區。
- UseMyString() 函數傳回時,str 析構(調用析構函數釋放記憶體區),記憶體區被回收
- main() 函數傳回時,test 析構(調用析構函數釋放記憶體區),再次回收記憶體區,導緻段錯誤
2.6.2 拷貝構造函數:確定深複制
拷貝構造函數函數文法如下:
class MyString
{
MyString(const MyString& copySource); // copy constructor
};
MyString::MyString(const MyString& copySource)
{
// Copy constructor implementation code
}
拷貝構造函數接受一個以引用方式傳入的目前類的對象作為參數,這個參數是源對象的引用。在拷貝構造函數中自定義複制代碼,確定對所有緩沖區進行深複制。
每當執行對象的拷貝初始化時,編譯器都将調用拷貝構造函數。
拷貝初始化包括如下幾種情形:
- 定義對象時同時使用等号進行初始化
- 将一個對象作為實參傳遞給一個非引用類型的形參
- 從一個傳回類型為非引用類型的函數傳回一個對象
- 用花括号清單初始化一個數組中的元素或一個聚合類中的成員
拷貝構造函數的參數必須按引用傳遞,否則拷貝構造函數将不斷調用自己,直到耗盡系統的記憶體為止。原因就是每當對象被複制時,編譯器都将調用拷貝構造函數,如果參數不是引用,實參不斷複制給形參,将生成不斷複制不斷調用拷貝構造函數。
示例程式如下:
#include <iostream>
#include <string.h>
#include <stdio.h>
using namespace std;
class MyString
{
private:
char *m_buffer;
public:
MyString(const char *initString) // Constructor
{
m_buffer = NULL;
cout << "constructor" << endl;
if (initString != NULL)
{
m_buffer = new char[strlen(initString) + 1];
strcpy(m_buffer, initString);
}
}
MyString(const MyString ©Source) // Copy constructor
{
m_buffer = NULL;
cout << "copy constructor" << endl;
if (copySource.m_buffer != NULL)
{
m_buffer = new char[strlen(copySource.m_buffer) + 1];
strcpy(m_buffer, copySource.m_buffer);
}
}
~MyString() // Destructor
{
cout << "destructor, delete m_buffer " << hex << (unsigned int *)m_buffer << dec << endl;
delete[] m_buffer;
}
void PrintAddress(const string &prefix)
{
cout << prefix << hex << (unsigned int *)m_buffer << dec << endl;
}
};
void UseMyString(MyString str)
{
str.PrintAddress("str.m_buffer addr: ");
}
int main()
{
MyString test1("12345678901234567890"); // 直接初始化,執行構造函數 MyString(const char* initString)
UseMyString(test1); // 拷貝初始化,執行拷貝構造函數 MyString(const MyString& copySource),深複制
MyString test2 = test1; // 拷貝初始化,執行拷貝構造函數 MyString(const MyString& copySource),深複制
MyString test3("abcdefg"); // 直接初始化,執行構造函數 MyString(const char* initString)
test3 = test1; // 指派,執行合成拷貝指派運算符,因未顯式定義指派運算符,是以是淺複制
test1.PrintAddress("test1.m_buffer addr: ");
test2.PrintAddress("test2.m_buffer addr: ");
test3.PrintAddress("test3.m_buffer addr: ");
return 0;
}
運作程式,結果如下:
constructor
copy constructor
str1.m_buffer addr: 0x1da22a0
str2.m_buffer addr: 0x1da2280
destructor, delete m_buffer 0x1da22a0
copy constructor
constructor
test1.m_buffer addr: 0x1da2280
test2.m_buffer addr: 0x1da22a0
test3.m_buffer addr: 0x1da2280
destructor, delete m_buffer 0x1da2280
destructor, delete m_buffer 0x1da22a0
destructor, delete m_buffer 0x1da2280
程式分析見注釋。拷貝初始化和指派兩種操作都涉及對象的複制,拷貝初始化會調用拷貝構造函數,指派則調用拷貝指派運算符。
程式中
MyString test2 = test1;
是拷貝初始化,是以拷貝構造函數起作用。
test3=test1
這一句是指派,是以拷貝指派運算符起作用。因為 MyString 類沒有提供複制指派運算符 operator=,是以将使用編譯器提供的預設拷貝指派運算符,進而導緻對象淺複制。
關于拷貝構造函數的注意事項如下:
- 類包含原始指針成員(char *等)時,務必編寫拷貝構造函數和複制指派運算符。
- 編寫拷貝構造函數時,務必将接受源對象的參數聲明為 const 引用。
- 聲明構造函數時務必考慮使用關鍵字 explicit,以避免隐式轉換。
- 務必将類成員聲明為 std::string 和智能指針類(而不是原始指針),因為它們實作了拷貝構造函數,可減少您的工作量。除非萬不得已,不要類成員聲明為原始指針。
2.6.3 移動構造函數:改善性能
class MyString
{
// 代碼同上一示例程式,此處略
};
MyString Copy(MyString& source)
{
MyString copyForReturn(source.GetString()); // create copy
return copyForReturn; // 1. 将傳回值複制給調用者,首次調用拷貝構造函數
}
int main()
{
MyString test ("Hello World of C++");
MyString testAgain(Copy(test)); // 2. 将 Copy() 傳回值作實參,再次調用拷貝構造函數
return 0;
}
上例中,參考注釋,執行個體化 testAgain 對象時,拷貝構造函數被調用了兩次。如果對象很大,兩次複制造成的性能影響不容忽視。
為避免這種性能瓶頸, C++11 引入了移動構造函數。移動構造函數的文法如下:
// move constructor
MyString(MyString&& moveSource)
{
if(moveSource.m_buffer != NULL)
{
m_buffer = moveSource.m_buffer; // take ownership i.e. 'move'
moveSource.m_buffer = NULL; // set the move source to NULL
}
}
有移動構造函數時,編譯器将自動使用它來“移動”臨時資源,進而避免深複制。增加移動構造函數後,上一示例中,将首先調用移動構造函數,然後調用拷貝構造函數,拷貝構造函數隻被會調用一次。
3. 析構函數
析構函數在對象銷毀時被調用。執行去初始化操作。
- 析構函數隻能有一個,不能被重載。
- 若使用者未提供析構函數,編譯器會生成一個僞析構函數,但是這個僞析構函數是空的,不會釋放堆記憶體。
每當對象不再在作用域内或通過 delete 被删除進而被銷毀時,都将調用析構函數。這使得析構函數成為重置變量以及釋放動态配置設定的記憶體和其他資源的理想場所。
4. 構造函數與析構函數的其他用途
4.1 不允許複制的類
假設要模拟國家政體,一個國家隻能有一位總統,則 President 類的對象不允許複制。
要禁止類對象被複制,可将拷貝構造函數聲明為私有的。為禁止指派,可将指派運算符聲明為私有的。拷貝構造函數和指派運算符聲明為私有的即可,不需要實作。這樣,如果代碼中有對對象的複制或指派,将無法編譯通過。形式如下:
class President
{
private:
President(const President&); // private copy constructor
President& operator= (const President&); // private copy assignment operator
// … other attributes
};
4.2 隻能有一個執行個體的單例類
前面讨論的 President 不能複制,不能指派,但存在一個缺陷:無法禁止通過執行個體化多個對象來建立多名總統:
President One, Two, Three;
要確定一個類不能有多個執行個體,也就是單例的概念。實作單例,要使用私有構造函數、私有指派運算符和靜态執行個體成員。
将關鍵字 static 用于類的資料成員時,該資料成員将在所有執行個體之間共享。
将關鍵字 static 用于成員函數(方法)時,該方法将在所有成員之間共享。
将 static 用于函數中聲明的局部變量時,該變量的值将在兩次調用之間保持不變。
4.3 禁止在棧中執行個體化的類
将析構函數聲明為私有的。略
4.4 使用構造函數進行類型轉換
略
5. 對象的拷貝控制
對類的對象的複制、移動、指派和銷毀操作可以稱為拷貝控制。類通過如下五種成員函數來控制拷貝控制行為:拷貝構造函數(copy constructor), 拷貝指派運算符(copy-assignment operator), 移動構造函數(move constructor), 移動指派運算符(move-assignment operator), 和析構函數(destructor)。我們将這些成員函數簡稱為拷貝控制成員。如果類沒有定義某種拷貝控制成員,編譯器會自動生成它。
在 C++ 中,初始化和指派是兩個不同的操作,盡管都使用了等号 "="。初始化的含義是建立對象進賦予一個初始化值;而指派的含義是把對象的目前值擦除,以一個新值覆寫。
以 string 類對象為例,我們看一下直接初始化和拷貝初始化的差別:
string dots(10, '.'); // direct initialization
string s(dots); // direct initialization
string s2 = dots; // copy initialization
string null_book = "9-999-99999-9"; // copy initialization
string nines = string(100, '9'); // copy initialization
直接初始化時,編譯器使用普通的函數比對來選擇與實參最比對的構造函數。拷貝初始化時,編譯器将右側對象拷貝到正在建立的對象中,拷貝初始化通常使用拷貝構造函數來完成。
構造函數控制類對象的初始化,其中拷貝構造函數用于控制拷貝初始化,其他構造函數用于控制直接初始化。指派運算符用于控制類對象的指派。如下:
string s1(10, '.'); // 直接初始化,s1 調用比對的構造函數
string s2 = s1; // 拷貝初始化,s3 調用拷貝構造函數
string s3; // 直接初始化,s2 調用預設構造函數
s3 = s2; // 指派,将使用 string 類的指派運算符操作
6. this 指針
在類中,關鍵字 this 包含目前對象的位址,換句話說, 其值為 &object。在類成員方法中調用其他成員方法時, 編譯器将隐式地傳遞 this 指針。
調用靜态方法時,不會隐式地傳遞 this 指針,因為靜态函數不與類執行個體相關聯,而由所有執行個體共享。要在靜态函數中使用執行個體變量,應顯式地聲明一個形參,并将實參設定為 this 指針。
7. sizeof 用于類
sizeof 用于類時,值為類聲明中所有資料屬性占用的總記憶體量,機關為位元組。是否考慮對齊,與編譯器有關。
8. 結構與類的不同之處
結構 struct 與類 class 非常相似,差别在于程式員未指定時,預設的通路限定符(public 和 private)不同。是以,除非指定了,否則結構中的成員預設為公有的(而類成員預設為私有的);另外,除非指定了,否則結構以公有方式繼承基結構(而類為私有繼承)。
修改記錄
2019-05-16 V1.0 初稿
2020-02-28 V1.1 增加拷貝控制一節,優化拷貝構造函數相關内容