> 類作為C++中重要的概念之一,有著眾多的特性,也是最迷人的部分! 類是一個加工廠,開發者使用C++提供的各種材料組裝這個工廠,使得它可以生產出符合自己要求的數據,通過對工廠的改造,可以精細控制對象從出生到死亡的各種行為,真正達到我的代碼我做主的境界。 ### 類 我們經常說的面向對象三大特征:封 ...
類作為C++中重要的概念之一,有著眾多的特性,也是最迷人的部分!
類是一個加工廠,開發者使用C++提供的各種材料組裝這個工廠,使得它可以生產出符合自己要求的數據,通過對工廠的改造,可以精細控制對象從出生到死亡的各種行為,真正達到我的代碼我做主的境界。
類
我們經常說的面向對象三大特征:封裝,繼承和多態,其實說的是一種抽象維度。最簡單的就是具體類,它將數據打包在一起,提供操作數據的函數,使得開發者不再需要通過傳參的形式傳遞數據。它實現了事物的抽象,也就是所謂的封裝。第二層是在一堆數據中提取出共性的部分作為基類,然後將特性作為子類,充分利用繼承的優點,實現代碼復用。它不僅追求數據抽象,也追求行為上的相似性。而更進一步,一套演算法不關心實際的數據,只關心它可以用來完成什麼工作,甚至相互都不知道對方的存在,唯一的共同點就是都繼承自某個類,都能完成那個類指定的操作,至於細節都不關心,這就是多態,類只是一種規範流程。從第一層到第三層,抽象的事物從具體轉向抽象,重心也從數據轉向行為,只是為了更好的可維護性和解耦性。三者的關係可能是下圖這樣的:
為了能將跟高級的繼承和多態講明白,本篇我們將著重介紹他們的第一形態:封裝,也就是具體類。
類的基本組成
類是一種自定義類型,主要由兩部分組成:成員變數保存類管理的數據,成員函數操作數據。
和普通變數相比,類中的成員變數最大的不同是其生命周期。成員變數在類實例化後才占用空間,構造函數完成其初始化工作,在構造完成後,成員函數就可以無限制地使用成員變數,直到析構函數被調用。
成員函數和普通函數的不同之處是成員函數有個隱含的this
指針,這個指針指向成員變數的存儲位置,也就是可以很方便地完成成員變數的訪問。
由此可見,具體類研究的主體是數據。接下來我將圍繞著數據的生命周期完成對類特性的解析。
對象的創建和銷毀
類的第一大作用就是控制類怎麼生成和銷毀。和Java不同,不需要用new
也會涉及到構造函數的調用,哪怕只是個普通的局部變數,出了變數的作用範圍,對象就會被銷毀,記憶體就會被釋放。
class Sample{
public:
Sample(){
std::cout<<"Creating a Sample object"<<std::endl;
}
~Sample(){
std::cout<<"Destoring a Sample object"<<std::endl;
}
};
int main(){
// Sample的構造函數被調用
Sample a;
{
// 大括弧創建了一個局部作用域,對象b只存在大括弧範圍內,出了大括弧後,b就會被銷毀,調用Sample的析構函數
Sample b;
}
// 此時只有對象a還存活
}
// 輸出
// Creating a Sample object
// Creating a Sample object
// Destoring a Sample object
// Destoring a Sample object
上面的Sample是最簡單的類定義,我們只創建了類的構造函數和析構函數,在main
函數中,創建了兩個變數。通過檢查輸出,我們可以確定類的構造函數和析構函數都被調用了。
上面那個類從功能上毫無用處,我們只能創建一個它的對象,然後看著它死去,什麼也幹不了。接下來,我們來改造下Sample
類,讓它能在構造的時候告訴我們,哪一個對象在構造。
class Sample{
Sample(const std::string name){
std::cout<<"Creating a Sample object:name = "<<name<<std::endl;
}
//其餘不變
};
int main(){
// 由於創建對象a時,用到了string對象,所以要先創建一個string對象
std::string str{"a"};
// 此時構造類需要一個名字了,我們已經控制了類的初始化狀態
Sample a{str};
{
// Sample唯一給構造函數需要一個string的對象,但是編譯器推測出傳遞給Sample構造函數的參數類型是字元串常量
// 參數不匹配,但這還沒達到編譯失敗的條件,因為編譯器還沒檢查是否存在一種從字元串常量生成字元串對象的構造函數,
// 答案是有的,string類提供了這樣的構造函數
// 接下來編譯器用字元串常量構造出了string對象,自動完成了string對象的創建
// 並傳遞給Sample的構造函數,條件滿足,編譯順利完成
Sample b{"b"};
}
}
// 輸出
// Creating a Sample object:name = a
// Creating a Sample object:name = b
// Destoring a Sample object
// Destoring a Sample object
上面的例子有一個值得註意的地方,那就是對象b
直接從字元串常量創建出來了,省略了中間字元串對象,其實這一步是編譯器為我們完成了,它的創建過程和a
是完全一樣的,這種行為稱為隱式轉換。
這時的Sample
類還是什麼也做不了,甚至連哪一個對象被銷毀了我們都不知道。析構函數是函數,那麼給析構函數添加參數行不行呢?答案是不行,因為析構函數是編譯器自動幫我們調用的,它不知道調用時需要什麼參數,所以就只能是無參。那麼有什麼辦法能正確標記出是哪個對象被銷毀了呢,答案是成員變數。
成員變數和對象是同生共死的,它和對象使用同一塊記憶體。對象創建就為成員變數也分配了空間,但是沒有初始化,需要開發者在構造函數或者其他函數使用前初始化。在析構函數調用時,記憶體尚未被回收,這時候是使用成員變數的最後時機。成員變數還有另一個重要的特點,在類中定義的所有非static
函數都能使用它,不需要通過函數參數傳遞。這也是類設計的初衷之一,用類管理數據。
所以,接下來的析構函數可以這樣寫
class Sample {
private:
// 第一步,創建一個成員變數
std::string name;
public:
// 第二步,在構造函數中初始化成員變數
Sample(const std::string name) :name{ name } {
std::cout << "Creating a Sample object:name = " << name << std::endl;
}
~Sample() {
//第三步,使用成員變數
std::cout << "Destoring a Sample object:name = " << name << std::endl;
}
};
int main() {
std::string str{ "a" };
Sample a{ str };
{
Sample b{ "b" };
}
}
// 輸出
// Creating a Sample object:name = a
// Creating a Sample object:name = b
// Destoring a Sample object:name = b
// Destoring a Sample object:name = a
可以看到,創建成員變數也很簡單,關鍵在於第二步,這和Java又不一樣。第二步中,初始化成員變數使用了特殊的語法,在構造函數小括弧後面添加了:
,然後普通變數初始化的語法,稱之為成員變數初始化。這樣寫的關鍵原因在於,對象創建需要先申請記憶體,記憶體申請後使用:
後面的初始化方式初始化成員變數,最後才調用構造函數完成對象的創建,每一步都有它對應的位置和作用。假如像Java一樣寫在構造函數裡面,就相當於將第二步放到了第三步,打亂了它本來的順序。
為了說明成員函數確實在對象的整個生命周期都可以使用,我們再個它添加一個成員函數吧。
class Sample{
void print() {
std::cout << "Invoke print name = " << name << std::endl;
}
//其餘不變
}
int main() {
std::string str{ "a" };
Sample a{ str };
{
Sample b{ "b" };
b.print();
}
a.print();
}
// 輸出
// Creating a Sample object:name = a
// Creating a Sample object:name = b
// Invoke print name = b
// Destoring a Sample object:name = b
// Invoke print name = a
// Destoring a Sample object:name = a
我們添加了一個成員函數print
它沒有參數,但是它的函數體使用了成員變數name
,可以看到,它也能正常工作。
至此,對象的創建和銷毀就說得差不多了。還沒說到的是構造函數可以有很多個,在創建對象的時候可以選擇使用哪種方式創建,編譯器會根據傳遞的參數來推導出實際使用的構造函數,開發者需要考慮的是提供的構造函數都能完成成員函數的正確初始化,以便在調用成員函數時,成員函數都能按預期工作。如Sample
,我們還可以提供一個無參的構造函數,然後將name
初始化為空字元串,這樣print
和析構函數也能正常工作。
總結一下,類是管理數據的容器,它的數據隨著對象的創建而創建,併在對象存在的整個生命周期都可用。構造函數需要保證數據的初始化,並可以控制它構造的方式,成員函數可以隨時使用,析構函數是對象銷毀時最後一個調用,它需要保證數據到此都被清理。
數據的轉移和共用
數據拷貝
數據創建之後,不僅可以供成員函數使用,還可能被轉移到其他對象中去。或者和其他對象共用。複製構造函數可以控制數據以怎樣的方式和其他對象共用。
class Sample {
private:
int value;
public:
Sample(const int value) :value{ value } {
std::cout << "Create value = " << value << std::endl;
}
// 以Sample命名,是構造函數,函數參數是自己的類型,說明是複製構造函數
// 這個複製構造函數選擇用賦值的形式共用value數據
Sample(const Sample& sample) :value{ sample.value } {
std::cout << "Copy create object" << std::endl;
}
};
void use(Sample sample) {
//函數返回,sample對象被銷毀
}
int main() {
Sample a{ 1 };
// a的數據被分享給一個臨時對象了,此時出現了兩個對象,它們的value都是1
use(a);
}
// 輸出
// Create value = 1
// Copy create object
複製構造函數有以下幾個特征
- 會出現至少兩個同類型的對象。因為複製需要先有一個存在的對象,再用這個存在的對象數據初始化另一個正在創建的對象的成員變數。這也是複製構造函數參數是自己的原因。
- 存在變數從無到有初始化的情況都會調用複製構造函數。函數調用,形參需要初始化為實參,參數本來不存在,調用函數會傳遞一個已存在的對象,就會調用到複製構造函數。這也是為什麼複製構造函數參數是引用的類型。假如是普通變數,調用複製構造的時候需要產生臨時變數,臨時變數又需要調用複製構造函數,程式就會陷入無限遞歸中。
- 除了函數調用,函數返回值,用對象初始化新變數的情況也會調用到複製構造函數。函數返回後,函數體中所有的局部變數都會被銷毀,返回值也屬於一種局部變數肯定也要被銷毀,但是返回後的值卻需要被 外部使用,它們的生命周期是不一樣的,由此我們就知道肯定創建了一個新的對象,這個對象被局部返回值初始化,但是有著和外部一樣的生命周期。用對象初始化變數就更直觀了,初始化的對象是從無到有創建的。符合構造函數出現的特點。
我們可以來驗證一下
//其餘不變
Sample returnSample() {
// 用普通構造函數初始化的
Sample sample{ 2 };
return sample;
}
int main() {
Sample a{ 1 };
std::cout << "init local variable" << std::endl;
// b是新對象,用a初始化的,所以調用了複製構造函數
Sample b = a;
// use的形參被用來初始化
std::cout << "Use Sample as parameter" << std::endl;
use(a);
//返回的sample被用來初始化c
std::cout << "return sample" << std::endl;
Sample c = returnSample();
}
// 輸出
// Create value = 1
// init local variable
// Copy create object
// Use Sample as parameter
// Copy create object
// return sample
// Create value = 2
// Copy create object
可以看到,這三種情況都會造成複製構造函數的調用。
數據移動
數據拷貝雖然簡單易行,但是還是有個小瑕疵。考慮下麵這種場景:
void swap(Object& left,Object& right){
// 有新對象產生,拷貝構造,目前記憶體中有兩份一模一樣的left
Object temp=left;
// 賦值操作,生成了一個right的臨時對象
left=right;
// 賦值操作,生成了一個temp的臨時對象
right=temp;
// 三個臨時對象都被銷毀
}
int main(){
Object a;
Object b;
swap(a,b);
return 0;
}
一個簡單的交換邏輯,我們就生成了很多的臨時對象,假如我們操作的是列表,大對象,短時間內大量創建並銷毀對象,就會造成記憶體抖動,嚴重影響系統的穩定性,而且,我們的真正目的只是將兩個變數的值交換一下而已。所以相較於拷貝,我們還有更好的選擇:移動。
左值和右值
說起移動,就不得不提到左值和右值。這裡的左和右是相對於=
來說的。
我們知道=
是用來賦值的,這下麵隱藏著三個動作:生,取,寫。在記憶體中生成一個臨時數據,讀取變數保存位置,將臨時變數內容寫入保存位置。生就是指的右值,它保存在我們不知道的記憶體位置,在寫動作完成後,它就被回收了。而取對應的就是左值,我們用變數名保存了它的記憶體位置,在它作用域內可以反覆讀寫。所以右值最大的特點就是不知道地址,如i=i+1
就會先生成一個i+1
的臨時對象,我們不知道地址,所以它是右值。與之相對的左值,是可以通過&
讀到地址的。
接下來我們再來談一談引用。我們通常是用別名來理解引用的,但是可能會忽略一個小細節,別名也是需要有歸屬的,也就是它代表的地址在哪裡。基於這個前提,我們就可以推導出凡是存在記憶體中的數據,理都是有地址的,而右值是存在記憶體中的,它也應該需要一種方式來獲得地址,稱之為右值引用,相對的一般變數的引用就稱為左值引用。
說回到移動,前面的複製構造函數雖然能將數據和其他對象共用,但是大部分情況下,數據其實不需要共用的,只需要轉移,也就是將數據的所有權移動到另一個對象上,原始對象就不再有效。所以C++提供了移動構造函數來完成這個操作。
class Sample {
private:
int* value;
public:
Sample(const int value) :value{ new int{value} } {
std::cout << "Create value = " << value << std::endl;
}
Sample(const Sample& sample) :value{ new int {*sample.value} } {
std::cout << "Copy create object" << std::endl;
}
Sample(Sample&& sample) :value{ sample.value } {
sample.value = nullptr;
std::cout << "Move create object" << std::endl;
}
~Sample() {
delete value;
std::cout << "destory sample" << std::endl;
}
friend std::ostream& operator<<(std::ostream& os, const Sample& sample) {
os << "Sample value is " << sample.value;
return os;
}
};
void use(Sample sample) {
std::cout << "Use sample " << sample << std::endl;
}
int main() {
// 普通變數,1被使用後馬上銷毀了
int a = 1;
//左值引用
int& b = a;
//右值引用,引用的就是1那個暫存的地址
int&& c = 1;
//可以修改引用的值
c = 2;
Sample sample{ 1 };
use(std::move(sample));
std::cout << sample << std::endl;
}
// 輸出
// Create value = 1
// Move create object
// Use sample Sample value is 009B8E90
// destory sample
// Sample value is 00000000
// destory sample
在上面的代碼里,我們真正使用sample
對象的是函數use
,use
執行完後,sample
就沒用了。所以我們用std::move
將數據轉移到了函數實參中,外部的sample
不再擁有那塊記憶體的占用。很多場景其實都是類似的情況:外部配置參數後,傳遞給某個函數使用,所以這種情況下就沒必要構造一個新的對象出來,假如業務很長的話,sample
對象就會一直占用記憶體,但是它是早就沒用了的。所以移動構造函數就發揮了大作用。
數據共用
除了通過複製構造函數和成員函數共用數據外,還可以通過友元類和友元函數。它們都是一種特殊的訪問數據的形式,可以直接訪問到數據,不經過成員函數的調用。所以在有些時候友元能幫助減少函數調用的花銷,有些時候則會引入不可預期的行為。
class FriendClass {
public:
void useSample(const Sample& sample) {
std::cout << "Sample value is " << sample.value << std::endl;
}
};
上面的例子,如果按照常規是無法通過編譯的,因為sample
的value
是私有的。前面我們知道,成員函數是可以訪問私有變數的,但是這個類是定義在Sample
外的,這個函數是另一個類的成員函數,完全沒辦法完成這種訪問。當然,這種情況下,我們可以修改Sample
類的定義,添加一個成員函數就解決了。但是假如FriendClass
有多個成員函數都需要訪問Sample
的私有成員呢,這個時候添加成員函數的方式就不再適用,所以出現了友元類。
實現友元類很簡單,簡單到只需要添加一條聲明。首先友元類需要至少兩個類,一個類是想要訪問私有成員的源類,另一個是含有私有成員的目標類,然後我們把友元聲明放在目標類里,源類就可以順利訪問到目標類的私有成員了。在上面的例子FriendClass
想要訪問Sample
的私有成員,所以它是源類,是普普通通的類。Sample
含有FeiendClass
想訪問的私有成員value
,所以它是目標類,聲明需要添加到它的類定義裡面。
Class Sample{
private:
int value;
friend class FriendClass;
//其餘不變
}
加上這一條之後,前面的FriendClass
就可以正常通過編譯了。這一句的威力很大,大到FriendClass
的所有成員函數都能訪問到value
。假如這不是你的期望,但是還是想要直接訪問到value
,那麼就可以適用友元函數。
友元函數是普通的函數,雖然它聲明在類里,但是不能直接訪問到類的私有成員,而是通過函數參數的形式。為了和普通的成員函數區分開來,它的聲明最前面需要添加關鍵字friend
。friend
仿佛像打開了許可權控制的開關,可以使函數訪問到參數的私有成員。
class Sample{
friend std::ostream& operator<<(std::ostream& os,const Sample& sample) {
os << "Sample value is " << sample.value << std::endl;
return os;
}
//其餘不變
}
int main() {
Sample a{ 1 };
std::cout << a << std::endl;
}
// 輸出
// Create value = 1
// Sample value is 1
函數<<
是友元函數,因為函數聲明有關鍵字friend
。友元函數不是成員函數,想在函數體訪問到成員變數,需要添加函數參數。那麼函數參數有很多個,怎樣確定參數私有成員的可訪問性呢,這就得看這個友元函數聲明在哪個類裡面了,友元函數的聲明位置直接確定了它訪問私有成員的範圍。
特殊的成員函數
C++的類有極大的定製性,這種定製性不僅僅表現在數據上,還表現在成員函數上。我們知道一般的成員函數都是使用.
來調用的,但是出於特殊的場景,有些情況下這種調用形式不僅僅不直觀,還效率不高。所以C++提出了運算符的概念。之所以稱為運算符,是因為函數的調用和傳參形式和普通的成員函數不一樣。定義良好的運算符可大大提高代碼的可讀性。如
[]
操作符是下標運算符,有了它的幫助,我們就可以像obj[2]
這樣取容器中的元素了。()
則可以把對象當成函數一樣直接調用,實現函數式編程的效果。->
可以返回另外的對象,使得它可以表現出另一個對象的行為。
還有其他的諸如++
,--
等操作符,在定義特定類型的類時,提供合適的運算符函數能使我們的類更簡潔、好用。
總結
總的來說,類是一個數據管理器,構造函數控制數據生成,來源可以使其他類型,也可以是相同類型。用相同類型生成新數據的時候,有複製和移動兩種選擇。複製構造函數控制相同類型的數據共用行為,其主要目標就是實現兩個類型在構造函數完成那一刻,在記憶體中的數據是完全一致的。移動構造函數的目標則是將現有的數據轉移到當前構造的對象上來,然後使現有的數據失效,從而達到減少對象創建、銷毀,增加記憶體利用率的目的。除此之外,還能使用成員函數改變或者訪問數據,最終在析構函數中結束數據的生命。此外友元類或者友元函數也是一種數據訪問途徑。
具體類的主要矛盾是數據,設計類的關鍵還是要弄清數據流向。數據自身在內部能有什麼狀態,能實現什麼行為,是成員函數該完成的工作。此外還要考慮相同類型相互構造時數據的共用情況,是完全隔離,還是相互影響,這些都是應該考慮的問題。畢竟確保數據從創建到銷毀的可靠性和有效性是一個具體類應該完成的基本功能。