C++基礎-類和對象
本文為 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)。UseMyString(MyString str)函數的形參 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 增加拷貝控制一節,優化拷貝構造函數相關內容

浙公網安備 33010602011771號