07【C++ 初階】類和對象(中篇) --- 類的默認成員函數

文章目錄

  • 前言
  • 類的6個默認成員函數
  • 1.構造函數
    • 1.1 構造函數特性
      • 1.1.1 函數名與類名相同
      • 1.1.2 無返回值
      • 1.1.3 對象實例化時編譯器自動調用對應的構造函數
      • 1.1.4 構造函數可以重載
      • 1.1.5 默認構造只能有一個
      • 1.1.6 默認構造的必要性
    • 1.2 構造函數的初始化列表
  • 2.析構函數
    • 2.1 析構函數特性
      • 2.1.1 函數名與類名相同但是在類名前加上字符 “~”
      • 2.1.2 無參數無返回值類型
      • 2.1.3 對象生命周期結束時編譯器自動調用析構函數
      • 2.1.4 析構函數只能有一個,析構函數不能重載
      • 2.1.5 默認析構函數的必要性
  • 構造和析構的總結
  • 3.拷貝構造函數
    • 3.1 拷貝構造特性
      • 3.1.1 拷貝構造函數的參數只有一個且必須是同類對象的引用,使用傳值方式編譯器直接報錯,因為會引發無窮遞歸調用
      • 3.1.2 拷貝構造函數是構造函數的一個重載形式
      • 3.1.3 默認拷貝構造的必要性
      • 3.1.4 默認拷貝構造的行為
    • 3.2 深拷貝
  • 4.賦值運算符重載
    • 4.1 運算符重載
      • 4.1.1 運算符重載的使用
    • 4.2 賦值重載的特性
      • 4.2.1 賦值運算符重載細節
      • 4.2.2 賦值運算符只能重載成類的成員函數不能重載成全局函數
      • 4.2.3 默認賦值重載函數的必要性
      • 4.2.4 默認賦值重載函數的行為
    • 4.3 深拷貝
  • 5.取地址重載(普通對象)
    • 5.1取地址重載的特性
      • 5.1.1 默認取地址重載的必要性
      • 5.1.2 默認取地址重載的行為
  • 6.取地址重載(const對象)
    • 6.1 const修飾的成員函數
  • 全文重點提煉


前言

通過00【C++ 入門基礎】前言得知,C++是為了解決C語言在面對大型項目的局限而誕生:

C語言面對的現實工程問題(復雜性、可維護性、可擴展性、安全性)

C語言面臨的具體問題:

1.struct 數據公開暴露,函數數據分離,邏輯碎片化。(復雜性、安全性)
2.修改數據結構,如 struct 新增字段,可能導致大量相關函數需要修改。(可維護性)
3.添加新功能常需修改現有函數或結構體,易引入錯誤。(可擴展性)
4.資源(內存、文件句柄)需手動管理,易泄漏或重復釋放。(安全性)

前文《06【C++ 初階】類和對象(上篇) — 初步理解/使用類》中C++的類通過封裝和抽象,使我們的類,更加貼近現實,獨立性更強,解決了:

  1. struct 數據公開暴露,函數數據分離(復雜性、安全性)。
  2. 面向過程耦合高(可維護性)

也就是我們上面所說的C語言面臨的第1和第2點問題,接下來的內容,我們要深化類的抽象,使其更加貼近我們的內置類型,使用難度再降低,同時解決資源管理的安全性問題(通過RAII),即進一步解決C語言的復雜性和安全性問題:

“1. struct 數據公開暴露,函數數據分離(復雜性、安全性)。”
“4. 資源(內存、文件句柄)需手動管理,易泄漏或重復釋放。(安全性)”

我們的代碼中,經常會出現內存泄漏的情況(比如忘記初始化,忘記銷毀等)。
我們C++類的解決方法,就是讓初始化和銷毀自動進行(RAII),即規定特殊的成員函數—構造和析構,它們在類對象的創建和銷毀處自動調用,如果我們將資源的初始化和銷毀對應的寫在構造和析構中,就可以保證資源的初始化和銷毀自動進行。


類的6個默認成員函數

在上一篇中,我們提了一嘴,如果一個類中沒有成員,那么它稱為空類,如class Date {};
但是它真的空嗎?-- 如空。

其實任何類在什么都不寫時,編譯器會自動為類生成以下6個默認成員函數。(這里我們先不討論C++11)在這里插入圖片描述

我們的六大成員函數的功能,是:
“生命周期管理(構造/析構)、對象復制控制(拷貝構造/賦值)、資源轉移優化(移動構造/賦值)。”
六大成員函數共同構建了C++對象的確定性生命周期框架——從誕生(構造)到復制傳遞(拷貝/移動),最終到消亡(析構),確保所有對象行為可預測、資源可管控,這是C++高效性與安全性的基石。
所以如果我們的用戶沒有顯式實現,但是代碼又有使用的話,我們的編譯器就會在需要時自動添加這些函數的默認形式(即默認成員函數),為的是保證基本的構造確定性原則,即確保所有的對象行為時可預測的。


1.構造函數

是一個特殊的成員函數,由編譯器自動在對應的位置調用,用來初始化對象的內存空間,初始化類中資源的函數,其名字與類名相同,創建類對象時像給函數傳參一樣去調用,編譯器會遵循邏輯自動的調用構造函數,以保證每個數據成員都有一個合適的初始值,并且在對象整個生命周期內只調用一次。

了解編譯器行為可以讓我們更加理解對象的結構:(看完特性之后回顧更佳)

編譯器在編譯時根據內存對齊規則計算類的大小和成員偏移量,并生成匯編代碼用于運行時在內存上開辟對象空間(如堆或棧分配)。隨后,在構造函數調用時(通過匯編call函數地址),對象的地址作為函數參數,和成員的偏移量一起被用于類內的成員變量尋址。初始化列表中對特定成員的初始化操作會和成員變量的地址一起,被編譯成匯編指令,嵌入到構造函數的代碼體中,在函數執行時完成成員初始化。然而,這僅保證被初始化列表覆蓋的成員被正確設置;未初始化的成員可能保留垃圾值。所以只要調用了構造函數,那么就會對對象的內存區域做初始化,就實現了一次對象的內存開辟和成員初始化。
此外,對于有基類或虛函數的類,編譯器還會插入基類構造函數調用和虛表設置代碼。

1.1 構造函數特性

1.1.1 函數名與類名相同

class Date
{
public:Date(){}  //最簡單的構造函數
};

1.1.2 無返回值

我們的構造函數不需要返回值。

1.1.3 對象實例化時編譯器自動調用對應的構造函數

class date
{
public:// 1.無參構造函數(默認構造函數)date(){}private:int _year;int _month;int _day;
};
void testdate()
{date d1;             // 當我們用一個類去實例化對象時,它會自動調用默認構造函數(無參構造)// 注意:如果通過無參構造函數創建對象時,對象后面不要跟括號!否則就成了函數聲明// 以下代碼的函數:聲明了d3函數,該函數無參,返回一個日期類型的對象//date d3();           // warning c4930: “date d3(void)”: 未調用原型函數(是否是有意用變量定義的?)//d3();                // 甚至可以定義完之后當成函數去調用.
}
//對于d3函數的定義:
//Date d3()
//{
//	Date da;
//	return da;
//}int main()
{date d3();testdate();return 0;
}

1.1.4 構造函數可以重載

class Date
{
public:// 1.無參構造函數Date(){}// 2.帶參構造函數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(2015, 1, 1); // 當我們用一個類去實例化對象時,我們想調用函數一樣傳參,會顯示的調用對應參數的構造函數(有參構造)// 以上調用的默認構造和我們實現的有參構造,其實是形成函數重載的,函數重載的條件: 相同作用域中相同函數名的函數的參數數量/類型/類型順序不同.// 注意:如果通過無參構造函數創建對象時,對象后面不用跟括號,否則就成了函數聲明// 以下代碼的函數:聲明了d3函數,該函數無參,返回一個日期類型的對象//date d3();           // warning c4930: “date d3(void)”: 未調用原型函數(是否是有意用變量定義的?)//d3();                // 甚至可以定義完之后當成函數去調用.
}
//對于d3函數的定義:
//Date d3()
//{
//	Date da;
//	return da;
//}int main()
{Date d3();TestDate();return 0;
}

1.1.5 默認構造只能有一個

因為無參構造函數、全缺省構造函數、我們沒寫編譯器默認生成的構造函數,都可以認為是默認構造函數。
所以如果我們定義了多個默認構造,那么就會編譯報錯。

class Date
{
public:// 1.無參構造函數Date(){_year = 1900;_month = 1;_day = 1;}// 2.帶參構造函數Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};void Test()
{Date d1;   //“Date::Date”: 對重載函數的調用不明確
}

1.1.6 默認構造的必要性

如果類中沒有顯式定義構造函數,則C++編譯器會自動生成一個無參的默認構造函數。 為的是保證構造確定性,保證類的行為可預測。

反之如果我們手動去實現了任意構造函數(默認構造或者普通帶參的構造),那么編譯器將不再自動生成默認構造。這又是為了保證我們的:程序員掌控,即提供給程序員最大的掌控力,如果我們手動的實現了(如構造函數),那么編譯器將不再干預任何操作,它默認程序員會將一切處理好。

class Date
{
public:/*// 如果用戶顯式定義了構造函數,編譯器將不再生成Date(int year, int month, int day){_year = year;_month = month;_day = day;}*/void Print(){cout << _year << "-" << _month << "-" << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{// 將Date類中構造函數屏蔽后,代碼可以通過編譯,因為編譯器生成了一個無參的默認構造函數// 將Date類中構造函數放開,代碼編譯失敗,因為一旦顯式定義任何構造函數,編譯器將不再生成// 無參構造函數,放開后報錯:error C2512: “Date”: 沒有合適的默認構造函數可用Date d1;return 0;
}

默認構造的必要性:
如果我們顯示的實現了構造函數,編譯器將不再給我們生成默認構造函數,但是我們還是避免不了還是有必須要使用默認構造的場景,所以如果我們自己實現了構造函數,那么大概率還要去重載一個默認構造函數。

注意:無參構造函數、全缺省構造函數、我們沒寫編譯器默認生成的構造函數,都可以認為是默認構造函數。

必須要使用默認構造的場景(不可以顯示傳參的場景):

A.動態分配的自定義類的數組:

Date* arr = new Date[5]; // 數組每個元素都需默認構造,報錯: 類 "Date" 不存在默認構造函數

B.容器類(STL)的隱式構造:

std::vector<Date> vec;
vec.resize(3); // 擴容時會自動給新對象初始化,所以 -> 需要默認構造 ,報錯: “Date::Date”: 沒有合適的默認構造函數可用

C.類對象作為成員變量:

class Time
{int time;
};
class Date
{
public:Date(int year, int month, int day){_year = year;_month = month;_day = day;}
private:Time _t;int _year;int _month;int _day;
};
int main()
{Date d;return 0;
}

我們知道:

  1. 編譯器會在,類對象被創建空間的那一刻馬上調用它的構造函數,為對象做初始化。
  2. 在定義類的時候,其實是在聲明該類中的成員變量(包括對象),只有實例化對象時,創建好該類的空間了,我們才說是對該類中的成員的定義,而成員定義過后,就需要調用其默認構造函數去為其初始化。
  3. 構造函數是對類對象的初始化,其實就是對類對象中的成員變量做初始化。

那么其實當我們對Date類實例化的時候,編譯器為我們Date類對象d創建好了空間,那么作為Date類中的成員Time類對象_t也就被順便創建好空間了,然后我們要初始化類對象d,就要調用Date類的構造去初始化它的成員變量,那么也就要對它其中的成員變量_t做初始化,而對于一個類對象做初始化,就又要調用它的構造函數(也就是Time類的構造函數)。

當一個類包含另一個類作為成員變量時,我們稱作為成員變量的那個類對象為成員對象,?類的構造函數不會直接實現成員類對象的構造邏輯,而是通過調用成員對象的默認構造函數來完成對它的初始化。

那么如果我們的成員對象沒有默認構造函數,即我們自己實現了成員對象的構造,但是又沒有重載其默認構造的情況,就會報錯:

class Hour
{
public:Hour(int _a){a = _a;}int a;int b;int c;
};class Date
{
public:Date(){   //“Hour”: 沒有合適的默認構造函數可用}Hour _h;};
int main()
{Date d;return 0;
}

疑問1:

  1. 類的構造函數如何調用自己的成員對象的默認構造去為它初始化?難道用自己的構造去調用成員對象的構造嗎?
  2. 類的構造函數對自己其他的內置類的成員變量是否有初始化?
  3. 如果對于內置類的成員變量也有初始化,那么類中所有成員的初始化是否有順序?
  4. 為什么成員對象沒有默認構造函數,卻會在類的構造函數處報錯?

答案都在構造函數的初始化列表中。

1.2 構造函數的初始化列表

初始化列表,是構造函數的一部分,是用來對類的所有成員變量初始化的,以確保類的所有成員變量在進入函數體時之前都已經被初始化了(構造確定性)。

初始化列表的行為:

  1. 類對于自己的成員類,通過初始化列表去調用成員對象的構造函數,讓成員類初始化自己的變量,
    因為我們的初始化列表也是類的構造函數的一部分,
    所以說是類的構造函數調用了成員類的構造函數也沒有錯。

  2. C++把類型分成內置類型(基本類型)和自定義類型。內置類型就是語言提供的數據類型,
    如:int/char…,自定義類型就是我們使用class/struct/union等自己定義的類型。
    C++11之前類的初始化列表不會對內置類型的成員變量做初始化,
    C++11 中針對內置類型成員不初始化的缺陷,打了補丁,
    即:
    內置類型成員變量在類中聲明時可以給默認值,然后編譯器會隱式的在初始化列表處用該值為內置類型也初始化。

  3. 初始化順序是聲明順序,我們編譯器在編譯階段會為每個類維護一個聲明順序表,
    它提供了成員變量的初始化的順序,而我們的初始化列表中提供了成員變量初始化的方法。

以上行為,無論是我們自己實現的構造函數,還是編譯器自動生成的默認構造函數,編譯器都會在初始化列表處自動的去做,如果沒有對應的自動調用的條件,如類中有引用的成員,或者類的成員對象成員沒有默認構造函數,都會報錯,這么做是為了維持構造確定性,讓所有變量在進入構造函數體之前,都經過初始化列表的強制初始化,防止函數體對沒有初始化的變量操作。

初始化列表對于內置類型的成員變量:

class Time
{
public:Time(){cout << "Time()" << endl;_hour = 0;_minute = 0;_second = 0;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:Date()//顯示寫的初始化列表是在這個位置,//不顯示寫它也會存在,編譯器會自動在里面插入對應變量的初始化方法,對有缺省值的內置類型成員做初始化,也為有默認構造函數的自定義類成員做初始化.//自動生成://:_t()//,_year(1970)//,_month(1)//,_day(1){cout << "Date()" << endl;cout << _year << " " << _month << " " << _day;}
private:// 基本類型(內置類型)int _year = 1970;int _month = 1;int _day = 1;// 自定義類型Time _t;
};
int main()
{Date d;    //輸出:	//Time()//Date()//1970 1 1return 0;
}

初始化列表,是可能會自動調用我們類中內置類的構造的,自動調用就需要默認構造,也是一種不可以傳參的場景,又證明了我們默認構造函數的必要性。

注意:無論是實例化一個類對象的時候,其默認構造的自動調用;還是類構造函數的初始化列表對于它的對象成員的默認構造的自動調用,都是編譯器的自動行為,都是編譯器通過在AST中自動插入對應的隱式節點,并在最后生成匯編,去實現的。

編譯器自動生成的默認構造和構造的初始化列表?保證了?:

  1. 所有自定義類成員在進入構造函數體前已調用其默認構造函數?
  2. 所有內置類型成員完成默認初始化?(即使是沒有操作)
  3. 所有const/引用/無默認構造的成員獲得有效初始狀態?(因為類中可能會有const/引用/無默認構造的成員變量)

總之,默認構造和我們初始化列表的配合,就是為了遵循C++的一個核心原則:構造確定性,即保證了所有類對象的初始化是可預測的。但程序員仍需警惕:內置類型的默認初始化不保證數據安全!?

疑問1解答:

  1. 初始化列表,也是構造函數的一部分,所以說是我們類的構造函數去調用成員對象的默認構造也沒錯,只不過通常交給編譯器去自動調用的。
  2. 無論是類中用戶實現的構造函數還是編譯器自動生成的默認構造函數,其對內置類型都有做初始化,只是并不對內置類執行操作罷了。
  3. 內置類成員和成員對象成員,都依據類中的聲明順序,再根據我們的初始化列表中的方法,去給成員變量初始化。
  4. 因為類構造函數的初始化列表處,要調用成員對象的默認構造,這是初始化列表和默認構造的無聲默契,遵循了構造確定性原則。

2.析構函數

析構函數也是類的一個特殊的成員函數,也是會被編譯器自動調用的成員函數。

與構造函數功能相反,析構函數不是完成對對象本身的銷毀,局部對象銷毀工作是由編譯器完成的。而對象在銷毀時會自動調用析構函數,完成對象中資源的清理工作。

了解編譯器行為可以讓我們更加理解對象的結構:(看完特性之后回顧更佳)

和之前的構造函數一樣,它也是編譯器自動在對象(在AST中的)應該被析構的位置插入的函數調用節點,當調用執行它的函數體時(機器碼),執行其中的命令,對管理的資源進行釋放(如果有),利用對象的地址和成員變量的偏移量尋址,然后將地址作為參數,去調用它所有的成員對象成員的析構函數,因為要防止成員對象有管理的需要手動釋放的資源導致內存泄漏,而對于內置類,析構函數不用操作,只是釋放其使用權,直接將內存使用權交換給系統。

2.1 析構函數特性

2.1.1 函數名與類名相同但是在類名前加上字符 “~”

class Date
{
public:~Date() //顯示實現析構函數.{}Date(){_year = 1900;_month = 1;_day = 1;}private:int _year;int _month;int _day;
};
int main()
{Date d;   return 0;
}            

2.1.2 無參數無返回值類型

析構函數不需要被顯示調用,由編譯器在對象生命周期結束時自動調用(棧對象離開作用域、堆對象被delete、全局對象程序退出等)(編譯器自動行為,是RAII實現的關鍵)。程序員無法傳遞參數,也不需要傳參,不需要返回值。

2.1.3 對象生命周期結束時編譯器自動調用析構函數

class Date
{
public:~Date(){cout << "析構函數:  ~Date();";}Date(){cout << "構造函數:  Date();";_year = 1900;_month = 1;_day = 1;}private:int _year;int _month;int _day;
};
int main()
{Date d;  //構造函數:  Date();析構函數:  ~Date();//類對象的定義處,申請對應類大小的內存空間,然后編譯器自動調用構造函數.return 0;
}            //在域的結束位置,變量的生命周期結束,編譯器自動調用析構函數.

2.1.4 析構函數只能有一個,析構函數不能重載

  1. 對象銷毀路徑唯一,不需要通過參數指定不同銷毀方式;
  2. 還有就是析構無法傳參,所以不能重載,因為我們函數重載的條件,就是需要參數不同最終來生成不同的符號表。

2.1.5 默認析構函數的必要性

一個類只能有一個析構函數。若未顯式定義,系統會自動生成默認的析構函數,那么關于編譯器自動生成的默認析構函數,會做什么呢?

因為我們的類中可能會管理資源,所以編譯器需要在對象將銷毀的時候自動的去調用它的析構函數去釋放,這就是我們RAII的實現方式;而成員對象中也有可能會管理資源,比如它申請了一片堆的空間(需要手動釋放),如果只調用了類的析構,而不調用其成員對象的析構,就會內存泄漏,所以我們的析構函數需要去自動調用其成員對象的析構函數。

而對于內置類型成員,銷毀時不需要資源清理,最后系統直接將其內存回收即可。
下面的程序我們會看到,編譯器生成的默認析構函數,對它的成員對象成員調用它的析構函數:

class Time
{
public:~Time(){cout << "~Time()" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
private:// 基本類型(內置類型)int _year = 1970;int _month = 1;int _day = 1;// 自定義類型Time _t;
};
int main()
{Date d;return 0;
}

程序運行結束后輸出:~Time()
在main方法中根本沒有直接創建Time類的對象,為什么最后會調用Time類的析構函數?

因為:main方法中創建了Date對象d,而d中包含4個成員變量,其中_year, _month, _day三個是內置類型成員,銷毀時不需要資源清理,最后系統直接將其內存回收即可;
而_t是Time類對象,所以在d銷毀時,要將其內部包含的Time類的_t對象銷毀,所以要調用Time類的析構函數。但是:main函數中不能直接調用Time類的析構函數,實際要釋放的是Date類對象,所以編譯器會調用Date類的析構函數,而Date沒有顯式提供,則編譯器會給Date類生成一個默認的析構函數,目的是在其內部調用Time類的析構函數,即當Date對象銷毀時,要保證其內部每個自定義對象都可以正確銷毀main函數中并沒有直接調用Time類析構函數,而是顯式調用編譯器為Date類生成的默認析構函數

所以當前階段,如果類中有資源申請,那么就顯示的寫析構去釋放資源,如果類中沒有資源管理,我們可以不寫,讓編譯器自動生成,這樣即使有成員對象管理了資源,系統生成的默認析構也可以自動去調用其成員對象的析構函數。


構造和析構的總結

構造函數和析構函數,是C++類生命周期管理的手段,會被編譯器自動調用,無論是棧上的臨時對象還是堆上的動態開辟的對象,編譯器都會在這個對象的內存被開辟的后一刻,插入該對象的構造函數和構造函數需要的一些參數,然后在我們對象快要銷毀的前一刻,插入該對象的析構函數,釋放類中對應的資源,(編譯器插入的函數、參數,其實都是匯編指令)

  1. 對于臨時對象來說,創建的時候就是執行到創建臨時變量的匯編代碼處,銷毀的時候就是所在函數的域將要結束的時候;
  2. 對于動態開辟在堆(這里是指New的)對象,創建就是執行到我們的New函數的匯編代碼處,銷毀就是執行到delete函數的匯編代碼處。

自動添加的方式:

其實自動調用構造或者析構,都是編譯器在自己的抽象語法樹中自動插入對應函數的節點,最后根據語法樹生成匯編代碼中帶有對應的函數了。


3.拷貝構造函數

拷貝構造函數是為了讓自定義類可以像內置類一樣的初始化,讓類的使用更貼近內置類型,使類的抽象程度進一步加深。

內置類型可以做:

//1.內置類同類型變量的初始化
int a = 100;
int b = a;//2.內置類型變量傳參、內置類型變量返回
int func(int a)
{return a++;
}

內置類型可以做像類似于上面這種,用一個類型的變量去拷貝出另一個同類型的變量的行為,我們的自定義類,也當然要有,實現原理就是當自定義類有這種初始化形式出現時,讓編譯器自動的調用拷貝構造函數,實現直接用一個類對象去拷貝出另一個類對象的效果(深化抽象),其實底層經過了復雜賦值過程,正因為它也是在做初始化操作,所以它也是我們的構造函數。
在這里插入圖片描述

了解編譯器行為可以讓我們更加理解對象的結構:(看完特性之后回顧更佳)

拷貝構造最終也是一條函數跳轉,當出現拷貝行為時,如果沒有手動實現拷貝構造函數,那么編譯器就會在對應的AST樹位置插入拷貝構造節點,最終生成的匯編將拷貝對象的地址和被拷貝的對象的地址作為我們拷貝構造函數的參數,根據類中變量的聲明順序和變量在對象中的偏移量,去生成執行成員對象成員變量的拷貝構造和對內置變量去按照內存值一一拷貝的匯編代碼。

拷貝構造函數的經典使用場景:

  • 使用已存在對象創建新對象
  • 函數參數類型為類類型對象
  • 函數返回值類型為類類型對象
class Date
{
public:Date(int year, int minute, int day){cout << "Date(int,int,int):" << this << endl;}Date(const Date& d){cout << "Date(const Date& d):" << this << endl;}~Date(){cout << "~Date():" << this << endl;}
private:int _year;int _month;int _day;
};
Date Test(Date d)
{Date temp(d);return temp;
}
int main()
{Date d1(2022, 1, 13);Test(d1);return 0;
}

在這里插入圖片描述

3.1 拷貝構造特性

拷貝構造其實就是我們構造函數的一個函數重載,為的就是實現自定義類之間的同類初始化問題。

其特征如下:

3.1.1 拷貝構造函數的參數只有一個且必須是同類對象的引用,使用傳值方式編譯器直接報錯,因為會引發無窮遞歸調用

我們剛才說了,拷貝是針對所有自定義類型同類之間的拷貝問題,所以拷貝構造的接收參數,無疑是要接受一個同類型的變量,但是還不能傳值傳參,因為我們的傳值傳參的過程,也是一次同類型之間的拷貝場景之一,也會觸發編譯器的自動調用拷貝構造的行為,所以如果傳值給我們的拷貝構造,就會無限套娃:
在這里插入圖片描述
而傳引用我們知道,其實就是傳一個指針,我們的指針只是一個變量,就不會觸發拷貝構造,也就不會無窮遞歸:

class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// Date(const Date d) // 錯誤寫法:編譯報錯,會引發無窮遞歸Date(const Date& d) // 正確寫法{cout << "Date(const Date d)" << endl;_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;
int _day;
};
int main()
{Date d1;Date d2(d1);return 0;
}

3.1.2 拷貝構造函數是構造函數的一個重載形式

拷貝構造也是和類的名字同名,不需要返回值,唯一不同的就是參數不同,而同名不同參的函數,是構成重載的,所拷貝構造其實是我們構造函數的重載。

3.1.3 默認拷貝構造的必要性

若未顯式定義,編譯器會生成默認的拷貝構造函數。

表象上:

為的是,就算我們沒有手動實現拷貝構造,這個類也可以由編譯器自動去生成,然后實現像我們的內置類int a = b;這樣的初始化方式,即保證了構造確定性,確保所有的對象行為是可預測的。

這種行為更加的深化了抽象,使我們的類更加貼近內置類型的使用,讓自定義類型可以使用同類型變量去初始化。

深層里:

拷貝構造,其實涉及了類對象的身份與狀態管理,處理資源復制控制,和析構函數(負責結束時的資源釋放)和構造函數(負責開始時的資源創建)一起,共同構成對資源的安全管理。

3.1.4 默認拷貝構造的行為

為了語義完整性,所以:

  1. 編譯器自動生成的默認構造函數要實現完整的拷貝,所以會在初始化列表的位置自動調用成員對象的拷貝構造。
  2. 且編譯器自動生成的默認構造函數也會在初始化列表的位置對內置類型的成員做拷貝初始化。

我們的拷貝構造既然是構造,就也有初始化列表,但是我們知道,編譯器自動生成的默認構造函數的初始化列表,是不會對我們的內置類做操作的。
為什么這里我們編譯器自動生成的默認拷貝構造的初始化列表,會對內置類的成員做初始化?

class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void print(){cout << _year << " " << _month << " " << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d;d.print();     //1900 1 1Date d2(d);    //編譯器會自動生成默認拷貝d2.print();    //1900 1 1return 0;
}

原因:

默認構造:?? 它不知道應該用哪個值來初始化內置類型成員!是0嗎?是42嗎?還是沒有具體值?編譯器無法猜測用戶的意圖,所以語言標準規定默認構造不進行值初始化?(保持未定義),除非成員在類內聲明時有缺省值(C++11及以后)或在初始化列表中顯式列出。這是為了效率和靈活性。

拷貝構造:?? 它有明確的初始化信息源——要復制的那個對象(other)!編譯器知道需要精確復制other的成員。因此,對于內置類型成員,唯一合理的初始化方式就是other.member的值直接復制過來。這個信息是明確已知且必須使用的。

疑問,但是我們由編譯器自動生成的默認拷貝構造函數一定好嗎?深拷貝部分揭曉。

為了程序員掌控力原則,所以:

  1. 一但我們顯示的實現了拷貝構造函數,那么編譯器將默認我們會將一切處理好,也會不會為我們自動的去調用成員對象的拷貝構造了,也不會對內置類做操作了。
class Hour
{
public:Hour(){a = 1;b = 2;c = 3;}Hour(const Hour & h){}int a;int b;int c;
};
int main()
{Hour h1;Hour h2(h1);cout << h2.a; // -858993460return 0;
}

為了構造確定性原則,所以:

  1. 我們顯示的實現了拷貝構造函數,編譯器沒有生成默認拷貝,它雖然不會去調用其成員對象的拷貝構造,但是會去調用它的默認拷貝構造。

總結就是:

1.因為語意完整性原則,默認構造函數要強制完整的拷貝,所以需要自動調用成員對象的拷貝構造,且要對內置類做拷貝.
2.因為程序員掌控力原則,一旦我們手動的寫了拷貝構造,編譯器將不再介入,所以它不會自動的去調用成員對象的拷貝構造.
3.因為構造確定性原則,所有值在進入構造函數函數體的時候,必須經過初始化,所以我們自定義的拷貝構造的初始化列表中就算沒有顯示的寫,它也會自動調用我們成員對象的默認構造函數.

3.2 深拷貝

前面我們已經知道,編譯器生成的默認拷貝構造函數已經可以完成自動調用成員對象的拷貝構造,并且還可以對內置類成員做拷貝,既然已經這么完善,為什么還需要我們手動的去實現呢?

看一下下面的場景:

// 這里會發現下面的程序會崩潰掉?
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");return;}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2(s1);return 0;
}

以上代碼會崩潰,因為代碼中使用了拷貝初始化,所以Stack類由編譯器自動生成了默認的拷貝構造函數,但是由于我們Stack內部管理的資源是從堆中申請的空間,實際上默認拷貝構造拷貝的只是我們的指針,這種單純對成員變量的值的拷貝,叫做淺拷貝。
在這里插入圖片描述
淺拷貝只拷貝值,沒有將我們類中真正管理的資源拷貝下來,所以就會導致重復釋放資源而崩潰。
但是我們編譯器自動生成的拷貝構造又只能是淺拷貝,所以,如果我們想要拷貝構造可以將類管理的資源也一起拷貝復制下來,我們需要自己去實現拷貝構造函數。

typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");return;}_size = 0;_capacity = capacity;}Stack(const Stack& s){_array = (DataType*)malloc(s._capacity * sizeof(DataType));_capacity = s._capacity;for (int i = 0; i < s._size; i++){_array[i] = s._array[i];}_size = s._size;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2(s1);return 0;
}

所以總結就是:類中如果沒有涉及資源申請時,拷貝構造函數是否寫都可以;一旦涉及到資源申請時,則拷貝構造函數是一定要寫的,否則就是淺拷貝。


4.賦值運算符重載

4.1 運算符重載

運算符重載,也是我們C++為了增加類的抽象程度,使類的使用更貼近內置類,使類的可讀性增加的手段。

重載我們知道,就是讓同一個名字的函數,根據不同的參數,可以實現不同的邏輯,那么運算符重載,其實就是給我們一般的運算符去賦予不同的邏輯,讓我們自定義的類,也可以使用運算符(如+、-、*、<<等)。

4.1.1 運算符重載的使用

運算符重載是具有特殊函數名的函數,也具有其返回值類型,函數名字以及參數列表,其返回值類型與參數列表與普通的函數類似。
函數名字為:關鍵字operator后面接需要重載的運算符符號。
函數原型:返回值類型 operator操作符(參數列表)

class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// bool operator==(Date* this, const Date& d2)// 這里需要注意的是,左操作數是this,指向調用函數的對象bool operator==(const Date & d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}
private:int _year;int _month;int _day;
};

注意:

不能通過連接其他符號來創建新的操作符:比如operator@
重載操作符必須有一個類類型參數
用于內置類型的運算符,其含義不能改變,例如:內置的整型+,不 能改變其含義
作為類成員函數重載時,其形參看起來比操作數數目少1,因為成員函數的第一個參數為隱藏的this
“.*” “::” “sizeof? 和 ?typeid” “?:” “.” 注意以上5個運算符不能重載。這個經常在筆試選擇題中出現。

運算符重載除了重載成類的成員函數,還可以重載成全局的,只要保證函數的參數中有一個類類型即可,不過有一個例外:賦值運算符重載不可以重載成全局,詳細看后面。

運算符重載通過為類定義類似內置類型的操作語義,讓用戶能夠使用熟悉的運算符(如+、-、*、<<等)來操作對象,從而隱藏底層實現細節。這使得代碼更貼近問題領域的自然表達,用戶無需關心具體實現,只需理解操作在領域內的抽象含義。

賦值運算符重載:
賦值運算符重載,就是給類實現一個賦值“=”的功能。

那么賦值和拷貝有什么區別呢?

拷貝構造函數是從無到有創建新對象。
賦值是將一個對象的狀態拷貝給一個已經存在的對象。

4.2 賦值重載的特性

4.2.1 賦值運算符重載細節

  • 參數類型:const T&,傳遞引用可以提高傳參效率
  • 返回值類型:T&,返回引用可以提高返回的效率,有返回值目的是為了支持連續賦值
  • 檢測是否自己給自己賦值
  • 返回*this :要符合連續賦值的含義a = b = c;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(const Date& d){_year = d._year;_month = d._month;_day = d._day;}Date& operator=(const Date& d){if (this != &d){_year = d._year;_month = d._month;_day = d._day;}return *this;}
private:int _year;int _month;int _day;
};

4.2.2 賦值運算符只能重載成類的成員函數不能重載成全局函數

賦值運算符如果不顯式實現,編譯器會生成一個默認的。此時用戶再在類外自己實現一個全局的
賦值運算符重載,就和編譯器在類中生成的默認賦值運算符重載沖突了,故賦值運算符重載只能是類的成員函數。

class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}int _year;int _month;int _day;
};
// 賦值運算符重載成全局函數,注意重載成全局函數時沒有this指針了,需要給兩個參數
Date& operator=(Date& left, const Date& right)
{if (&left != &right){left._year = right._year;left._month = right._month;left._day = right._day;}return left;
}
// 編譯失敗:
// error C2801: “operator =”必須是非靜態成員

4.2.3 默認賦值重載函數的必要性

用戶沒有顯式實現時,編譯器會生成一個默認賦值運算符重載
我們的運算符重載有很多,那么為什么偏偏是賦值重載,被列為我們的六大成員函數之一,而且我們沒有手動實現,編譯器還會自動生成呢?

表面上:

編譯器自動生成默認構造,可以讓我們就算沒有寫賦值重載,也可以使用賦值運算符,這種行為加深了類的抽象,不過后續我們知道,編譯器生成的總是淺拷貝,不一定總是好。

深層里:

因為賦值操作和拷貝構造一樣,涉及對象身份與狀態管理,是處理資源復制控制的部分,非常重要,賦值與析構函數(負責結束時的資源釋放)和拷貝構造(負責開始時的資源創建)共同構成對資源的安全管理。

4.2.4 默認賦值重載函數的行為

如果代碼中有使用賦值,且用戶沒有顯示實現賦值運算符重載,我們的編譯器會自動生成一個賦值運算符重載。

為了保證語義的完整性
賦值運算符重載,也會和拷貝構造一樣,對我們內置類的成員變量做逐字節的拷貝,并且會調用成員對象的賦值重載;

在維持語義完整方面,賦值運算符重載函數和拷貝構造函數的區別是:
我們的賦值重載并不是構造函數,它是對一個已經存在的對象賦值,所以編譯器生成的默認賦值運算符,它沒有初始化列表,所以它并不是在初始化列表處去自動調用其成員對象的賦值運算符,而是直接在函數體中調用。

class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}Time& operator=(const Time& t){if (this != &t){_hour = t._hour;_minute = t._minute;_second = t._second;}return *this;}
private:int _hour;int _minute;int _second;
};
class Date
{
private:// 基本類型(內置類型)int _year = 1970;int _month = 1;int _day = 1;// 自定義類型Time _t;
};
int main()
{Date d1;Date d2;d1 = d2;return 0;
}

為了保證程序員掌控原則
如果我們顯示實現了,那么編譯器不會再生成,默認程序員會將一切處理好,也同時給于程序員最大操作權限。

class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}Time& operator=(const Time& t){cout << "Time& operator=(const Time& t)" << endl;if (this != &t){_hour = t._hour;_minute = t._minute;_second = t._second;}return *this;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:Date& operator=(const Date& d){if (this != &d){_year = d._year;_month = d._month;//_day = d._day;       //我們自己實現賦值重載,但是不給_day和_t做賦值,編譯器也不會自動幫我們做,因為程序員掌控原則,它默認我們會全部完成.//_t = d._t;}return *this;}void print(){cout << _year << " " << _month << " " << _day << endl;}// 基本類型(內置類型)int _year = 1970;int _month = 1;int _day = 1;// 自定義類型Time _t;
};
int main()
{Date d1;d1._day = 666;Date d2;d1 = d2;     //沒有打印d1.print();  //1970 1 666d2.print();  //1970 1 1return 0;
}

由于我們的賦值重載,是對已經存在的對象做賦值,那么也就無需討論構造確定性原則了,因為對象已經存在是給它賦值,而不需要構造。當然不討論不代表不遵循,這個已經存在的對象,肯定也是由構造函數或者默認構造函數去構造的。

為了保證語義的完整性,我們的賦值運算符重載,也會和拷貝構造一樣,對我們內置類的成員變量做逐字節的拷貝,并且會調用成員對象的賦值重載,只不過它沒有初始化列表,是編譯器直接通過函數體調用的;
為了保證程序員掌控原則,如果我們顯示實現了,那么編譯器不會再生成,默認程序員會將一切處理好,也同時給于程序員最大操作權限;
由于賦值重載,是對已經存在的對象做賦值,所以無需討論構造確定性原則

4.3 深拷貝

由于我們的賦值場景(賦值重載),和拷貝構造函數一樣,是處理資源復制控制的重要部分,所以一旦有使用到,但是我們又沒有實現,編譯器會自動生成,而編譯器自動生成的又只能做簡單的淺拷貝,所以就需要我們手動去實現深拷貝。

// 這里會發現下面的程序會崩潰掉?
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");return;}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2;s2 = s1;return 0;
}

在這里插入圖片描述

實現賦值重載的深拷貝:

typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");return;}_size = 0;_capacity = capacity;}Stack& operator=(const Stack& s){if (this != &s){_array = (DataType*)malloc(s._capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");exit(1);}_capacity = s._capacity;for (int i = 0; i < s._size; i++){_array[i] = s._array[i];}_size = s._size;}return *this;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}void print(){for (int i = 0; i < _size; i++){cout << _array[i] << " ";}cout << endl;}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2;s2 = s1;s1.print();s2.print();return 0;
}

5.取地址重載(普通對象)

取地址運算符重載,即是對“&”的重載,運算符“&”的本意,是取地址;而operator&本質是函數,返回一個地址,我們可以決定如果有用戶對該類取地址,我們的返回邏輯。

class Date
{
public:Date* operator&()  {return &_year;  //返回_year成員的地址.}private:int _year; // 年int _month; // 月int _day; // 日
};

5.1取地址重載的特性

5.1.1 默認取地址重載的必要性

如果有使用,但是我們沒有顯示實現,編譯器會自動生成取地址重載。
我們知道運算符可以重載,那么為什么取地址運算符也需要重載,而且還是六個默認成員函數之一?
表面上:

為了讓我們就算沒有顯示寫,也可以使用編譯器自動生成的 默認取地址重載,實現對類對象的取地址。

深層里:

C++的所有變量,無論是內置類型還是自定義類型,它都切實的存在內存中,C++作為系統級語言,其核心特性之一就是允許直接操作內存地址,因此,獲取對象地址是基本操作。

5.1.2 默認取地址重載的行為

編譯器自動生成的取地址運算符重載,返回的是這個類對象的地址:

class Date
{
public://編譯器自動生成的就像:Date* operator&()    {return this;}private:int _year; // 年int _month; // 月int _day; // 日
};

編譯器默認實現的取地址重載,作用是返回當前對象在內存中的物理地址(即 this 指針的值)。?? 在絕大多數情況下,這正是我們期望的行為,所以我們通常不需要自己重載它。


6.取地址重載(const對象)

還有就是對const類對象的取地址重載,它和我們的取地址重載一樣,只不過返回的是const類變量的地址:

6.1 const修飾的成員函數

當對象被const修飾,那么當它調用自己的成員函數的時候,其實就是給我們成員函數傳入的隱式this指針,帶上了const:const Date* this; 表示指針指向的內容不可以被修改。

而一般我們實現的成員函數,它默認都是接受的普通對象的this,所以我們要去為const類的對象添加一個對應參數類型的成員函數,那么我們就要重載它:(C++規定,在成員函數的參數列表后面加const,表示該函數匹配的是const類型對象的this指針,這樣該函數就可以匹配const類的對象)

class Date
{
public:Date* operator&(){return this;}const Date* operator&()const     //在函數的參數列表之后加const{return this;}
private:int _year; // 年int _month; // 月int _day; // 日
};

在這里插入圖片描述

一個類可以是普通類也可以被const修飾,所以函數接收的參數不同:Date* const thisconst Date* const this,因為參數不同,函數名相同,所以它們其實是構成函數重載的。

注意:既然我們知道了,被const修飾的對象調用自己的成員函數的時候,傳入的this指針類型不同(一個是普通指針,一個是const類指針),而我們平常實現的一些成員函數,比如構造函數:

Date::Date(int year, int month, int day)
{_year = year;_month = month;_day = day;
}

它默認都是接受的普通對象的this指針,那么const對象,傳入const類型的this指針,是不是我們自己實現的普通成員函數就不可以匹配了?

沒錯!
像我們自己實現的賦值運算符重載,const類對象就會因為參數類型不匹配而調用不到。
const類對象傳入的this指針是:const Date* const this
普通對象傳入的this指針是:Date* const this
我們實現的針對普通對象的成員函數,經過編譯器的自動添加this指針之后:

Date& Date::operator=(Date* const this, const Date& d)
{if (this != &d){_year = d._year;_month = d._month;_day = d._day; }return *this;
}

所以我們的const類對象時不可以調用到我們普通類的成員函數的,那么我們還是一樣,給這個構造函數的參數列表之后添加const類,然后就可以匹配到我們const類對象了吧?

const Date& Date::operator=(const Date& d) const
{if (this != &d){_year = d._year;    //由于正在通過常量對象訪問“_year”,因此無法對其進行修改_month = d._month;  //由于正在通過常量對象訪問“_month”,因此無法對其進行修改_day = d._day;      //由于正在通過常量對象訪問“_day”,因此無法對其進行修改}return *this;
}

為什么呢?

  1. 因為const的對象傳入的const類的this指針,意義就是不可以通過this指針修改我們類中的成員函數。
  2. 我們成員函數中所有操作成員變量的操作,都是通過this指針的去操作的。

但是有一個例外,我們的構造函數,即便是const類型,也可以匹配到,它會在使用構造初始化對象的時候,暫時的屏蔽掉const屬性,為成員變量賦值,然后初始化完成之后,恢復const屬性,因為一個對象初始化,是必須要經過我們構造函數的,所以這是編譯器給構造函數的特權:

class Date
{
public:Date(int year = 1, int month = 2, int day = 3){_year = year;_month = month;_day = day;}void print(){cout << _year << " " << _month << " " << _day << endl;}void print() const{cout << _year << " " << _month << " " << _day << endl;}private:int _year; // 年int _month; // 月int _day; // 日
};int main()
{Date d1;d1.print();const Date d2;d2.print();return 0;
}

但是如果想給構造函數手動添加const修飾this指針,是不允許的,不可以使用const修飾構造函數的this!!


全文重點提煉

  1. 資源管理需要手動,經常忘記釋放,C++類的解決方式是將資源的初始化和釋放自動進行。
  2. 六大成員函數:

“生命周期管理(構造/析構)、對象復制控制(拷貝構造/賦值)、資源轉移優化(移動構造/賦值)。”
六大成員函數共同構建了C++對象的確定性生命周期框架——從誕生(構造)到復制傳遞(拷貝/移動),最終到消亡(析構),確保所有對象行為可預測、資源可管控,這是C++高效性與安全性的基石。

  1. C++規定:如果用戶沒有在類中顯式實現,編譯器會生成六個默認成員函數,為的是保證基本的構造確定性原則,即確保所有的對象行為是可預測的。
  2. 類的構造函數和析構函數,是會在我們類對象創建的時候自動執行,去自動的初始化我們對象的內存中的成員變量的。
  3. 自動調用的原理,其實就是編譯器在需要的地方(如對象創建時需要調用構造、銷毀時需要調用析構、賦值時需要調用賦值重載),在AST抽象語法樹中自動的去插入、調用對應的成員函數的隱式節點。
  4. 而自動生成默認成員函數的原理,就是編譯器根據AST樹,去自動生成代碼,而前面編譯器在AST抽象語法樹中自動插入了對應默認成員函數的隱式節點,所以最后根據這個語法樹生成的代碼中,也就有對應的默認成員函數邏輯的匯編指令了。

構造函數:

  1. 構造函數是一個特殊的成員函數,名字與類名相同,創建類類型對象時由編譯器自動調用,以保證每個數據成員都有 一個合適的初始值,并且在對象整個生命周期內只調用一次。
  2. 構造函數雖然名稱叫構造,但其實構造函數并不是開空間創建對象的,而是初始化對象的。
  3. 構造函數是我們給類初始化的手段,它有兩部分,一個部分是初始化列表,一個部分是給我們用顯示賦值的區域,也就是構造函數的函數體。

編譯器在編譯時根據內存對齊規則計算類的大小和成員偏移量,并生成匯編代碼用于運行時在內存上開辟對象空間(如堆或棧分配)。隨后,在構造函數調用時(通過call指令),對象的地址和成員的偏移量被用于尋址。初始化列表中對特定成員的初始化操作會和成員變量的地址一起,被編譯成匯編指令,嵌入到構造函數的代碼體中,在函數執行時完成成員初始化。然而,這僅保證被初始化列表覆蓋的成員被正確設置;未初始化的成員可能保留垃圾值。所以只要調用了構造函數,那么就會對對象的內存區域做初始化,就實現了一次對象的內存開辟和成員初始化。

  1. 編譯器通過在類對象空間開辟后插入類的構造函數節點,實現自動初始化;
    也通過在類域中按照成員變量的聲明順序插入其成員類的默認構造函數節點,實現對成員對象的自動初始化。
  2. 構造函數的特性:

類名和構造函數名相同;
沒有返回值;
對象的空間創建好之后由編譯器自動調用構造函數;
如果我們沒有顯示實現,編譯器會自動生成默認構造函數,當我們顯示實現,編譯器將不會生成默認構造;
編譯器自動生成默認構造,默認構造不需要傳參,編譯器自動生成是因為有必要的不傳參場景;
構造函數可以函數重載;
但是默認構造只能有一個,因為多個會有調用歧義;

  1. 編譯器自動生成的默認構造和構造的初始化列表?保證了?:
  1. 所有類對象成員在進入構造函數體前已調用其默認構造函數?
  2. 所有內置類型成員完成默認初始化?(即使是沒有操作)
  3. 所有const/引用/無默認構造的成員獲得有效初始狀態?(因為類中可能會有const/引用/無默認構造的成員變量)

總之,默認構造和我們初始化列表的配合,就是為了遵循C++的一個核心原則:構造確定性原則,即保證了所有類對象的初始化是可預測的。但程序員仍需警惕:內置類型的默認初始化不保證數據安全!

  1. 為了遵循程序員掌控原則,一旦我們手動實現了構造函數,編譯器就不再自動生成了,這時候就需要我們去手動的重載一個默認構造函數,以應對需要默認構造的場景。

析構函數:

  1. 析構函數也是類的一個特殊的成員函數,也是會被編譯器自動調用的成員函數。
  2. 與構造函數功能相反,析構函數不是完成對對象本身的銷毀,局部對象銷毀工作是由編譯器完成的。而對象在銷毀時會自動調用析構函數,完成對象中資源的清理工作。
  3. 編譯器對析構函數:

和之前的構造函數一樣,它也是編譯器自動在對象應該被析構的位置(在AST中的)插入的函數調用節點,當調用執行它的函數體時(機器碼),執行其中的命令,對管理的資源進行釋放(如果有),利用對象的地址和成員變量的偏移量尋址,然后將地址作為參數,去調用它所有的成員對象成員的析構函數,因為要防止成員對象有管理的需要手動釋放的資源導致內存泄漏,而對于內置類,析構函數不用操作,只是釋放其使用權,直接將內存使用權交換給系統。

  1. 析構函數的特性:

函數名與類名相同但是在類名前加上字符 “~”
無參數無返回值類型
對象生命周期結束時編譯器自動調用析構函數
析構函數只能有一個,析構函數不能重載

  1. 默認析構函數的必要性:

類中可能會管理資源,所以編譯器需要在對象將銷毀的時候自動的去調用它的析構函數去釋放,這就是我們RAII的實現方式;而成員對象中也有可能會管理資源,比如它申請了一片堆的空間(需要手動釋放),如果只調用了類的析構,而不調用其成員對象的析構,就會內存泄漏,所以我們的析構函數需要去自動調用其成員對象的析構函數。
而對于內置類型成員,銷毀時不需要資源清理,最后系統直接將其內存回收即可。

拷貝構造函數:

  1. 拷貝構造函數是為了讓自定義類可以像內置類一樣的初始化,讓類的使用更貼近內置類型,使類的抽象程度進一步加深。
  2. 編譯器對拷貝構造:

拷貝構造最終也是一條函數跳轉,當出現拷貝行為時,如果沒有手動實現拷貝構造函數,那么編譯器就會在對應的AST樹位置插入拷貝構造節點,最終生成的匯編將拷貝對象的地址和被拷貝的對象的地址作為我們拷貝構造函數的參數,根據類中變量的聲明順序和變量在對象中的偏移量,去生成執行成員對象成員變量的拷貝構造和對內置變量去按照內存值一一拷貝的匯編代碼。

  1. 拷貝構造特性:

拷貝構造函數的參數只有一個且必須是同類對象的引用,使用傳值方式編譯器直接報錯,因為會引發無窮遞歸調用;
拷貝構造函數是構造函數的一個重載形式;

  1. 默認拷貝構造的必要性:

表象上:

為的是,就算我們沒有手動實現拷貝構造,這個類也可以由編譯器自動去生成,然后實現像我們的內置類int a = b;這樣的初始化方式,即保證了構造確定性,確保所有的對象行為是可預測的。

這種行為更加的深化了抽象,使我們的類更加貼近內置類型的使用,讓自定義類型可以使用同類型變量去初始化。

深層里:

拷貝構造,其實涉及了類對象的身份與狀態管理,處理資源復制控制,和析構函數(負責結束時的資源釋放)和構造函數(負責開始時的資源創建)一起,共同構成對資源的安全管理。

  1. 默認拷貝構造的行為:

1.因為語意完整性原則,默認構造函數要強制完整的拷貝,所以需要自動調用成員對象的拷貝構造,且要對內置類做拷貝.
2.因為程序員掌控力原則,一旦我們手動的寫了拷貝構造,編譯器將不再介入,所以它不會自動的去調用成員對象的拷貝構造.
3.因為構造確定性原則,所有值在進入構造函數函數體的時候,必須經過初始化,所以我們自定義的拷貝構造的初始化列表中就算沒有顯示的寫,它也會自動調用我們成員對象的默認構造函數.

  1. 深拷貝:
    淺拷貝只拷貝值,沒有將我們類中真正管理的資源拷貝下來,所以就會導致重復釋放資源而崩潰。
    但是我們編譯器自動生成的拷貝構造又只能是淺拷貝,所以,如果我們想要拷貝構造可以將類管理的資源也一起拷貝復制下來,我們需要自己去實現拷貝構造函數。

  2. 總結就是:類中如果沒有涉及資源申請時,拷貝構造函數是否寫都可以;一旦涉及到資源申請時,則拷貝構造函數是一定要寫的,否則就是淺拷貝。

賦值運算符重載函數:

  1. 運算符重載函數:
    運算符重載,也是我們C++為了增加類的抽象程度,使類的使用更貼近內置類,使類的可讀性增加的手段。
    重載我們知道,就是讓同一個名字的函數,根據不同的參數,可以實現不同的邏輯,那么運算符重載,其實就是給我們一般的運算符去賦予不同的邏輯,讓我們自定義的類,也可以使用運算符(如+、-、*、<<等)。
  2. 賦值運算符重載,就是給類實現一個賦值“=”的功能。
  3. 賦值和拷貝的區別:

拷貝構造函數是從無到有創建新對象。
賦值是將一個對象的狀態拷貝給一個已經存在的對象。

  1. 賦值重載的特性:

賦值運算符重載細節:

  • 參數類型:const T&,傳遞引用可以提高傳參效率
  • 返回值類型:T&,返回引用可以提高返回的效率,有返回值目的是為了支持連續賦值
  • 檢測是否自己給自己賦值
  • 返回*this :要符合連續賦值的含義a = b = c;

賦值運算符只能重載成類的成員函數不能重載成全局函數

  1. 默認賦值重載函數的必要性:

表面上:

編譯器自動生成默認構造,可以讓我們就算沒有寫賦值重載,也可以使用賦值運算符,這種行為加深了類的抽象,不過后續我們知道,編譯器生成的總是淺拷貝,不一定總是好。

深層里:

因為賦值操作和拷貝構造一樣,涉及對象身份與狀態管理,是處理資源復制控制的部分,非常重要,賦值與析構函數(負責結束時的資源釋放)和拷貝構造(負責開始時的資源創建)共同構成對資源的安全管理。

  1. 默認賦值重載函數的行為:

為了保證語義的完整性,我們的賦值運算符重載,也會和拷貝構造一樣,對我們內置類的成員變量做逐字節的拷貝,并且會調用成員對象的賦值重載,只不過它沒有初始化列表,是編譯器直接通過函數體調用的;
為了保證程序員掌控原則,如果我們顯示實現了,那么編譯器不會再生成,默認程序員會將一切處理好,也同時給于程序員最大操作權限;
由于賦值重載,是對已經存在的對象做賦值,所以無需討論構造確定性原則

  1. 深拷貝:
    由于我們的賦值場景(賦值重載),和拷貝構造函數一樣,是處理資源復制控制的重要部分,所以一旦有使用到,但是我們又沒有實現,編譯器會自動生成,而編譯器自動生成的又只能做簡單的淺拷貝,所以就需要我們手動去實現深拷貝。

取地址重載函數:

  1. 取地址運算符重載,即是對“&”的重載,運算符“&”的本意,是取地址;而operator&本質是函數,返回一個地址,我們可以決定如果有用戶對該類取地址,我們的返回邏輯。
  2. 默認取地址重載的必要性:

表面上:

為了讓我們就算沒有顯示寫,也可以使用編譯器自動生成的 默認取地址重載,實現對類對象的取地址。

深層里:

C++的所有變量,無論是內置類型還是自定義類型,它都切實的存在內存中,C++作為系統級語言,其核心特性之一就是允許直接操作內存地址,因此,獲取對象地址是基本操作。

  1. 默認取地址重載的行為:
    編譯器默認實現的取地址重載,作用是返回當前對象在內存中的物理地址(即 this 指針的值)。?? 在絕大多數情況下,這正是我們期望的行為,所以我們通常不需要自己重載它。

  2. 對于const類的取地址重載函數:
    因為const類對象在類中的this指針也是const修飾的,所以一般我們實現的普通函數無法匹配,需要重載對應的const類型,但是我們的構造函數是例外,編譯器會暫時屏蔽掉const屬性,使用構造函數為對象初始化,初始化結束之后,再恢復const屬性。


本文章為作者的筆記和心得記錄,順便進行知識分享,有任何錯誤請評論指點:)。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/bicheng/91566.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/91566.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/91566.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

第二次CISSP考試通過!

今天我終于臨時通過了 CISSP 考試&#xff01;這第二次的精神壓力一點也不比第一次小。我在第 101 道題 時通過&#xff0c;還剩大約 30 分鐘。我當時真的以為自己又要像上次那樣時間不夠了。第一次考試的失敗經歷&#xff1a;第一次考試是我剛參加完為期 5 天的強化 Boot Camp…

USRP捕獲手機/路由器數據傳輸信號波形(上)

目錄&#xff1a; USRP捕獲手機/路由器數據傳輸信號波形&#xff08;上&#xff09; USRP捕獲手機/路由器數據傳輸信號波形&#xff08;中&#xff09; USRP捕獲手機/路由器數據傳輸信號波形&#xff08;下&#xff09; 一、前期準備 1.1 場景與系統 手機、路由器與天線的…

基于STM32F103的FM1702驅動程序

基于STM32F103微控制器與復旦微電子FM1702SL射頻讀卡芯片的驅動開發方案&#xff0c;整合了硬件配置、寄存器操作和通信協議實現&#xff1a;一、硬件連接設計 1. 管腳映射表FM1702SL引腳STM32F103引腳功能說明VDD3.3V電源輸入GNDGND地線SCKPA5(SPI1_SCK)SPI時鐘MISOPA6(SPI1_M…

京東商品評論API指南

一、引言京東商品評論API(JD.item_review)是京東開放平臺提供的重要接口&#xff0c;允許開發者獲取商品的詳細評論數據。通過該接口可以獲取包括評論內容、評分、評論時間、用戶昵稱等信息&#xff0c;為商品分析、用戶行為研究等提供數據支持?。二、接口概述1. 接口基本信息…

網絡編程概述與UDP編程

一、 網絡編程概述 1.1 概述 在現代軟件開發與系統交互場景里&#xff0c;基于 Socket 的網絡多進程通信占據核心地位&#xff0c;其適用場景廣泛且深入到各類數字化交互中&#xff1a; 直播場景&#xff1a;主播端通過 Socket 建立的網絡連接&#xff0c;將音視頻流以數據包…

新手教程:用外部 PostgreSQL 和 Zookeeper 啟動 Dolphinscheduler

本文將帶你一步步通過外部PostgreSQL和Zookeeper來啟動Apache DolphinScheduler。無論你是新手還是有經驗的開發者&#xff0c;都能輕松跟著這些步驟在Linux/Unix環境中完成安裝和配置。除了常見的安裝步驟&#xff0c;我們還會分享一些集群部署的技巧&#xff0c;讓你輕松擴展…

安寶特案例丨AR+AI賦能軌道交通制造:破解人工裝配難題的創新實踐

在軌道交通裝備制造領域&#xff0c;小批量、多品種的生產特性與高度依賴人工經驗的作業模式長期并存&#xff0c;導致效率瓶頸與質量隱患并存。安寶特通過AR&#xff08;增強現實&#xff09;AI&#xff08;人工智能&#xff09;技術融合&#xff0c;在螺栓緊固、內飾裝配、制…

基于LSTM-GRU混合網絡的動態解析:美聯儲維穩政策與黃金單日跌1.5%的非線性關聯

摘要&#xff1a;本文通過構建多因子量化模型&#xff0c;結合自然語言處理&#xff08;NLP&#xff09;技術對美聯儲政策文本進行情緒分析&#xff0c;解析經濟數據、市場情緒及宏觀環境對黃金價格的復合影響機制。研究基于LSTM時間序列預測框架&#xff0c;驗證關鍵事件對金價…

RabbitMQ消息確認機制有幾個confirm?

RabbitMQ 的消息確認機制中&#xff0c;“confirm” 這個詞主要出現在兩個關鍵環節&#xff0c;對應兩種確認&#xff1a;? 兩種 confirm&#xff08;確認&#xff09;機制確認類型觸發方說明Publisher Confirm&#xff08;生產者確認&#xff09;生產者 → Broker消息是否成功…

vue項目啟動時因內存不足啟動失敗

可以使用increase-memory-limit跟npm install cross-env插件npm install increase-memory-limit npm install cross-env安裝后需要在package.json文件中加入如下代碼"scripts": {"fix-memory-limit": "cross-env LIMIT3072 increase-memory-limit&quo…

WEditor:高效的移動端UI自動化腳本可視化編輯器

WEditor&#xff1a;高效的移動端UI自動化腳本可視化編輯器前言一、核心特性與優勢1. 可視化操作&#xff0c;降低門檻2. 跨平臺支持3. 豐富的控件層級展示4. 快捷鍵高效操作5. 開源可擴展二、安裝與環境配置1. 環境準備Android 設備用戶需額外準備ADB 安裝與配置步驟2. 安裝依…

面試高頻題 力扣 283.移動零 雙指針技巧 原地修改 順序保持 C++解題思路 每日一題

目錄零、題目描述一、為什么這道題值得你花幾分鐘看懂&#xff1f;二、題目拆解&#xff1a;提取其中的關鍵點三、明確思路&#xff1a;雙指針的巧妙配合四、算法實現&#xff1a;雙指針的代碼演繹五、C代碼實現&#xff1a;一步步拆解代碼拆解時間復雜度和空間復雜度六、實現過…

arrch64架構下調用pyvista報錯

arrch64架構下調用pyvista報錯 問題 python編程使用到了pyvista&#xff0c;使用conda新建了環境&#xff0c;但是使用的時候報錯 Traceback (most recent call last):File "/home/ztl/MGGBSAR/src/trans_las_3D.py", line 16, in <module>import pyvista as p…

功能強大編輯器

時間限制&#xff1a;1秒 內存限制&#xff1a;128M題目描述你要幫助小可創造一個超級數字編輯器&#xff01;編輯器依舊運行在Linux下&#xff0c;因此你只能通過指令去操控他。指令有五種&#xff1a; In X 表示在光標左側插入一個數字 Del 表示刪除光標左側一個數字 …

【力扣】面試經典150題總結01-數組/字符串

1.合并兩個有序數組&#xff08;簡單&#xff09;要求直接在num1上操作&#xff0c;已經預留了空間&#xff0c;所以直接倒著從大到小插入。當其中一個數組遍歷完&#xff0c;就把另一個數組剩余的部分插入。2.移除元素&#xff08;簡單&#xff09;要求原地移除數組中所有val元…

基于 Hadoop 生態圈的數據倉庫實踐 —— OLAP 與數據可視化(一)

目錄 一、OLAP 與 Impala 簡介 1. OLAP 簡介 2. Impala 簡介 &#xff08;1&#xff09;Impala 是什么 &#xff08;2&#xff09;為什么要使用 Impala &#xff08;3&#xff09;適合 Impala 的使用場景 &#xff08;4&#xff09;Impala 架構 &#xff08;5&#xff…

PyTorch L2范數詳解與應用

torch.norm 是什么 torch.norm(dot_product, p=2, dim=-1) 是 PyTorch 中用于計算張量 L2 范數的函數, 1. 各參數解析 dot_product:輸入張量,在代碼中形狀為 [batch_size, seq_len](每個元素是 token 隱藏狀態與關注向量的點積)。 p=2:指定計算L2 范數(歐幾里得范數)…

循環神經網絡RNN原理精講,詳細舉例!

第一部分&#xff1a;為什么需要RNN&#xff1f;在了解RNN是什么之前&#xff0c;我們先要明白它解決了什么問題。傳統的神經網絡&#xff0c;比如我們常見的前饋神經網絡&#xff08;Feedforward Neural Network&#xff09;或者卷積神經網絡&#xff08;CNN&#xff09;&…

如何用USRP捕獲手機信號波形(中)手機/基站通信

目錄&#xff1a; 如何用USRP捕獲手機信號波形&#xff08;上&#xff09;系統及知識準備 如何用USRP捕獲手機信號波形&#xff08;中&#xff09;手機/基站通信 如何用USRP捕獲手機信號波形&#xff08;下&#xff09;協議分析 四、信號捕獲結果 4.1 時域波形 我懷疑下面…

(LeetCode 面試經典 150 題 ) 155. 最小棧 (棧)

題目&#xff1a;155. 最小棧 思路&#xff1a;棧&#xff0c;時間復雜度0(n)。 在插入棧元素val時&#xff0c;同時加入一個字段&#xff0c;維護插入當前元素val時的最小值即可。 C版本&#xff1a; class MinStack { public:stack<pair<int,int>> st;MinStac…