🔥博客主頁: 小羊失眠啦.
🎥系列專欄:《C語言》 《數據結構》 《C++》 《Linux》 《Cpolar》
??感謝大家點贊👍收藏?評論??
文章目錄
- 一、默認成員函數
- 二、構造函數
- 構造函數的概念及特性
- 三、析構函數
- 析構函數的特性
- 四、拷貝構造函數
- 拷貝構造函數的特性
一、默認成員函數
上一章中我們談到,如果一個類中什么成員也沒有,那么這個類就叫作空類
。其實這么說是不太嚴謹的,因為一個類不可能什么都沒有
。
當我們定義好一個類,不做任何處理時,編譯器會自動生成以下6個默認成員函數
:
默認成員函數
:如果用戶沒有手動實現,則編譯器會自動生成
的成員函數。
構造函數
:主要完成初始化
工作;析構函數
:主要完成清理
工作;拷貝構造
:使用一個同類的對象初始化創建一個對象;賦值重載
:把一個對象賦值
給另一個對象;取地址重載
:普通對象
取地址操作;取地址重載
(const):const對象
取地址操作;
本章我們將學習四個默認成員函數——構造函數
與析構函數
——拷貝構造
與賦值重載
二、構造函數
在C語言階段,我們實現棧
的數據結構時,有一件事很苦惱,就是每當創建一個stack對象(之前叫作定義一個stack類型的變量)后,首先得調用它的專屬初始化函數StackInit
來初始化對象。
typedef int dataOfStackType;typedef struct stack
{dataOfStackType* a;int top;int capacity;
}stack;void StackInit(stack* ps);
//...int main(){stack s;StackInit(&s);//...return 0;}
這不免讓人覺得有點麻煩。在C++中,構造函數
為我們很好的解決了這一問題。
構造函數的概念及特性
構造函數
是一個特殊的成員函數
。構造函數雖然叫作構造,但是其主要作用并不是開辟空間創建對象,而是初始化對象
。
構造函數之所以特殊,是因為相比于其它成員函數,它具有如下特性
:
- 函數名與類名相同;
- 無返回值;
- 對象實例化時,編譯器
自動調用
對應的構造函數; - 構造函數可以重載;
舉例
class Date
{
public://無參的構造函數Date(){};//帶參的構造函數Date(int year,int month,int day){_year = year;_month = month;_day = day;}private:int _year;int _month;int _day;
};void TestDate()
{Date d1;//調用無參構造函數(自動調用)Date d2(2023, 3, 29);//調用帶參構造函數(自動調用)
}
特別注意
- 創建對象時編譯器會自動調用構造函數,若是
調用無參構造函數
,則無需在對象后面使用()
。否則會產生歧義:編譯器無法確定你是在聲明函數還是在創建對象
。
錯誤示例
//錯位示例
Date d3();
- 如果類中沒有顯式定義構造函數,則C++編譯器會自動生成一個無參的默認構造函數,一旦用戶顯式定義編譯器將不再生成。
class Date
{
public://若用戶沒有顯示定義,則編譯器自動生成。/*Date(int year,int month,int day){_year = year;_month = month;_day = day;}*/private:int _year;int _month;int _day;
};
- 默認生成構造函數,對內置類型成員不作處理;對自定義類型成員,會調用它的默認構造函數;
- C++把類型分成
內置類型
(基本類型)和自定義類型
。內置類型就是語言提供的數據類型,如:int、char、double…,自定義類型就是我們使用class、struct、union等自己定義的類型。
舉例
默認構造函數對內置類型
class Date
{
public://此處不對構造函數做顯示定義,測試默認構造函數/*Date(){}*/void print(){cout << _year << "-" << _month << "-" << _day << endl;}private:int _year;int _month;int _day;
};
void TestDate1()
{Date d1;d1.print();
}
- 如圖所示,默認構造函數的確未對內置類型做處理。
默認構造函數對自定義類型
class stack
{
public://此處對stack構造函數做顯示定義stack(){cout <<"stack()" << endl;_a = nullptr;_top = _capacity = 0;}
private:int* _a;int _top;int _capacity;
};class queue
{
public://此處不對queue構造函數做顯示定義,測試默認構造函數/*queue(){}*/
private://自定義類型成員stack _s;
};void TestQueue()
{queue q;
}
- 如圖所示,在創建
queue
對象時,默認構造函數對自定義成員_s
做了處理,調用了它的默認構造函數stack()
。
這一波蜜汁操作讓很多C++使用者感到困惑與不滿,為什么要針對內置類型和自定義類型做不同的處理呢?終于,在C++11中針對內置類型成員不初始化的缺陷,又打了補丁,即:
- 內置類型成員變量在類中聲明時可以給默認值;
舉例
class Date
{
public:
//...void print(){cout << _year << "-" << _month << "-" << _day << endl;}
private://使用默認值int _year = 0;int _month = 0;int _day = 0;
};
void TestDate2()
{Date d2;d2.print();
}
默認值
:若不對成員變量做處理,則使用默認值。
- 無參的構造函數和全缺省的構造函數都稱為默認構造函數,并且默認構造函數只能有一個;
舉例
class Date
{
public://無參的默認構造函數//Date()//{//}//全缺省的默認構造函數Date(int year = 0, int month = 0, int day = 0){_year = year;_month = month;_day = day;}void print(){cout << _year << "-" << _month << "-" << _day << endl;}private:int _year = 0;int _month = 0;int _day = 0;
};
默認構造函數:
- 無參的構造函數
- 全缺省的構造函數
- C++編譯器生成的無參的構造函數
即三種必須要有一種,如果沒有默認的構造函數(寫的構造函數不是無參的,也不是全缺省的)就會報錯
三、析構函數
析構函數
與構造函數
的特性相似,但功能有恰好相反。構造函數是用來初始化對象的,析構函數是用來銷毀對象
的。
- 需要注意的是,
析構函數并不是對對象本身進行銷毀
(因為局部對象出了作用域會自行銷毀,由編譯器來完成),而是在對象銷毀時會自動調用析構函數,對對象內部的資源做清理
(例如stack _s中的int* a)。
同樣,有了析構函數,我們再也不用擔心創建對象(或定義變量)后由于忘記釋放內存而造成內存泄漏
了。
舉例
class Stack
{
public:Stack(){//...}void Push(int x){//...}bool Empty(){// ...}int Top(){//...}void Destory(){//...}
private:// 成員變量int* _a;int _top;int _capacity;
};void TestStack()
{Stack s;st.Push(1);st.Push(2);//過去需要手動釋放st.Destroy();
}
析構函數的特性
- 析構函數名是在類名前加上字符
~
; - 無參數;
- 無返回值;
- 一個類只能有一個析構函數。若未顯式定義,系統會自動生成默認的析構函數;
- 析構函數不能重載;
舉例
class Date
{
public:Date(){cout << "Date()" << endl;}~Date(){cout << "~Date()" << endl;}
private:int _year = 0;int _month = 0;int _day = 0;
};void TestDate3()
{Date d3;//d3生命周期結束時自動調用構造函數
}
- 編譯器生成的默認析構函數,對自定類型成員調用它的析構函數;
舉例
class stack
{
public://此處對stack構造函數做顯示定義stack(){cout <<"stack()" << endl;_a = nullptr;_top = _capacity = 0;}~stack(){cout << "~Stack()" << endl;free(_a);_a = nullptr;_top = _capacity = 0;}
private:int* _a;int _top;int _capacity;
};
class queue
{
public://此處不對queue構造函數做顯示定義,測試默認構造函數/*queue(){}*/
private://自定義類型成員stack _s;
};void TestQueue1()
{queue q;
}
- 這里可能有小伙伴會好奇:
為什么析構函數不像構造函數那樣區分內置類型與自定義類型呢
?
答案是:因為內置類型壓根不需要我們擔心清理工作,在其生命周期結束時會自動銷毀。而自定義類型需要擔心,因為自定義類型里可能含有申請資源(例如:malloc申請內存須手動釋放)。
- 如果類中沒有申請資源時,析構函數可以不寫,直接使用編譯器生成的默認析構函數,比如
Date
類;有資源申請時,一定要寫,否則會造成資源泄漏,比如stack
類。
四、拷貝構造函數
同樣,拷貝構造函數
也屬于6個默認成員函數,而且拷貝構造函數
是構造函數
的一種重載形式
。
- 拷貝構造函數的功能就如同它的名字——拷貝。
我們可以用一個已存在的對象來創建一個與已存在對象一模一樣的新的對象
。
舉例
class Date
{
public://構造函數Date(){cout << "Date()" << endl;}//拷貝構造函數Date(const Date& d){cout << "Date()" << endl;_year = d._year;_month = d._month;_day = d._day;}//析構函數~Date(){cout << "~Date()" << endl;}
private:int _year = 0;int _month = 0;int _day = 0;
};void TestDate()
{Date d1;//調用拷貝構造創建對象Date d2(d1);
}
拷貝構造函數的特性
拷貝構造函數作為特殊的成員函數同樣也有異于常人的特性:
- 拷貝構造函數是構造函數的重載;
- 拷貝構造函數的參數只有一個且必須是
類類型對象的引用
。若使用傳值
的方式,則編譯器會報錯,因為理論上這會引發無窮遞歸
。
錯誤示例
class Date
{
public://錯誤示例//如果這樣寫,編譯器就會直接報錯,但我們現在假設如果編譯器不會檢查,//這樣的程序執行起來會發生什么Date(const Date d){_year = d._year;_month = d._month;_day = d._day;}
private:int _year = 0;int _month = 0;int _day = 0;
};void TestDate()
{Date d1;//調用拷貝構造創建對象Date d2(d1);
}
- 當拷貝構造函數的參數采用
傳值
的方式時,創建對象d2
,會調用它的拷貝構造函數
,d1
會作為實參
傳遞給形參d
。不巧的是,實參傳遞給形參本身又是一個拷貝
,會再次調用形參的拷貝構造函數…如此便會引發無窮的遞歸。
- 若未顯式定義,編譯器會生成默認的拷貝構造函數。 默認的拷貝構造函數對象按
內存存儲按字節序
完成拷貝,這種拷貝叫做淺拷貝
或者值拷貝
;
舉例
class Date
{
public://構造函數Date(int year = 0, int month = 0, int day = 0){//cout << "Date()" << endl;_year = year;_month = month;_day = day;}//未顯式定義拷貝構造函數/*Date(const Date& d){_year = d._year;_month = d._month;_day = d._day;}*/void print(){cout << _year << "-" << _month << "-" << _day << endl;}
private:int _year = 0;int _month = 0;int _day = 0;
};void TestDate()
{Date d1(2023, 3, 31);//調用拷貝構造創建對象Date d2(d1);d2.print();
}
- 有的小伙伴可能會有疑問:編譯器默認生成的拷貝構造函數貌似可以很好的完成任務,那么還需要我們手動來實現嗎?
答案是:當然需要。Date
類只是一個較為簡單的類且類成員都是內置類型
,可以不需要。但是當類中含有自定義類型時
,編譯器可就辦不了事兒了。
- 類中如果沒有涉及資源申請時,拷貝構造函數寫不寫都可以;一旦涉及到資源申請時,則拷貝構造函數是一定要寫的,否則就是淺拷貝;
錯誤示例
class stack
{
public:stack(int defaultCapacity=10){_a = (int*)malloc(sizeof(int)*defaultCapacity);if (_a == nullptr){perror("malloc fail");exit(-1);}_top = 0;_capacity = defaultCapacity;}~stack(){cout << "~Stack()" << endl;free(_a);_a = nullptr;_top = _capacity = 0;}void push(int n){_a[_top++] = n;}void print(){for (int i = 0; i < _top; i++){cout << _a[i] << " ";}cout << endl;}
private:int* _a;int _top;int _capacity;
};void TestStack()
{stack s1;s1.push(1);s1.push(2);s1.push(3);s1.push(4);s1.print();stack s2(s1);s2.print();s2.push(5);s2.push(6);s2.push(7);s2.push(8);s2.print();
}
如圖所示,這段程序的運行結果是程序崩潰了
,且通過觀察發現,是在第二次析構
時出現了錯誤。其實出現錯誤的原因是在第二次析構時對野指針
進行free
了。
一個小tip
- 多個對象進行析構的順序如同
棧
一樣,先創建的對象后析構,后創建的對象先析構
。
為什么會出現對野指針進行free呢?
- 原因是,對象
s1
與對象s2
中的成員_a
,指向的是同一塊空間
。在s2
析構完成后,這塊空間已經被釋放
,此時的s1._a
就是野指針
。這就是淺拷貝導致的后果。
理解淺拷貝
編譯器默認生成的拷貝構造函數是按字節序拷貝的,在創建s2
對象時,僅僅是把s1._a
的值賦值給s2._a
,并沒有重新開辟一塊與s1._a所指向的空間大小相同內容相同的空間
。我們把前者
的拷貝方式稱為淺拷貝
,后者
稱為深拷貝
。
當開啟監視窗口來觀察這一過程,我們可以看到s2
在進行push
時,s1
的內容也在跟著改變,且s1._a=s2._a
:
正確的做法
class stack
{
public:stack(int defaultCapacity=10){_a = (int*)malloc(sizeof(int)*defaultCapacity);if (_a == nullptr){perror("malloc fail");exit(-1);}_top = 0;_capacity = defaultCapacity;}//用戶自己定義拷貝構造函數stack(const stack& s){_a= (int*)malloc(sizeof(int) * s._capacity);if (_a == nullptr){perror("malloc fail");exit(-1);}memcpy(_a, s._a, sizeof(int) * s._capacity);_top = s._top;_capacity = s._capacity;}~stack(){cout << "~Stack()" << endl;free(_a);_a = nullptr;_top = _capacity = 0;}void push(int n){_a[_top++] = n;}void print(){for (int i = 0; i < _top; i++){cout << _a[i] << " ";}cout << endl;}
private:int* _a;int _top;int _capacity;
};
- 拷貝構造函數典型調用場景:
- 使用已存在對象創建新對象;
- 函數參數類型為類類型對象;
- 函數返回值類型為類類型對象。
為了提高程序效率
,一般對象傳參時,盡量使用引用類型
,返回時根據實際場景,能用引用盡量使用引用
。