課程內容#
類型和變數#
可理解為類和物件的別名
- C++ 中只規定了類型的最小位數,所以有的編譯器可以實現更多位數的
- 參考 cppreference——基礎類型
類型#
= 類型數據 + 類型操作
- 例如:int=4 字節大小的數據 + 基本操作(
+-*/%
),可聯想數據結構,int、double 類型本質都是數據結構 - 類型數據 + 類型操作 ➡️成員屬性 + 成員方法
- 可理解為加強版的 C 語言 struct(可以放屬性,但不能放方法)
訪問權限
先區分類內和類外的概念
訪問權限在類內設置,它控制的是類外能不能訪問類內
- public:公共
- 類內和類外都可以訪問它
- private:私有
- 只有類內的方法可以訪問它
- protected:受保護
- 除了類內,繼承的類內也可以訪問它
- friendly:友元
- 它修飾的函數可以訪問類內的私有成員和受保護成員
構造函數和析構函數#
物件的生命週期:構造→使用→析構
三種基本的構造函數#
聯想局部變數的初始化,物件也需要初始化
構造函數類型 | 使用方式 | ⚠️注意 |
---|---|---|
默認構造函數 | People bob; 原型:People (); | 1、零參構造函數 2、編譯器自動生成的 |
轉換構造函數 | People bob("DoubleLLL"); 原型:People (string name); | 1、一個參數的有參構造函數 2、該參數傳遞給類的成員變量,且不是本類的 const 引用 [PS] 有點像隱式的類型轉換 |
拷貝構造函數 | People bob(hug); 原型:People (const People &a); | 1、特殊的有參構造函數,傳入的是本類的物件 2、與賦值運算符 "=" 不等價 [PS] 處理為 const & 更方便 |
析構函數#
銷毀物件
原型:~People ();
⚠️注意:
1、沒有參數,也沒有返回值
2、資源回收時使用
小結#
都沒有返回值,函數名與類名一致
- 在工程開發中,構造函數和析構函數的功能會設計得非常簡單
- ❓為什麼不在構造函數中進行大量的資源申請?
- 原因:構造函數的 Bug,編譯器較難察覺
- 解決方式:偽構造函數、偽析構函數;工廠設計模式
- [+] 移動構造函數(另一個關鍵構造函數,後續學習)
- 來自 C++ 11 標準 ——C++ 重新歸回神壇的標準
- 在此之前,STL 的性能低下,因為 C++ 的語言特性不好,它沒有區分左值、右值概念,使得 STL 使用過程中發生的大量拷貝操作,尤其是深拷貝操作,會大大影響性能
- 在此之後,有了右值的概念,引入了移動構造函數
返回值優化(RVO)#
編譯器默認開啟了 RVO
引入#
物件a通過fun()返回值進行構造
輸出結果:
- 理論上:應該輸出 1 次 transform 和 2 次 copy
- 詳見具體分析👇
- 實際上:沒有輸出 copy,且 a.x 的值為物件 temp 中的 x 值,即局部變數 temp 的地址直接使用了物件 a 的地址(先開辟的物件 a 的數據區,見下)
- temp 更像是一個引用
- 存在編譯器優化,即返回值優化 RVO🌟
具體分析#
物件初始化過程:1、開辟物件數據區➡️2、匹配構造函數➡️3、完成構造
- 了解物件初始化過程後,再分析上面代碼中的構造過程:
- 首先,開辟物件 a 的數據區
- 然後,進入 func 函數,開辟物件 temp 的數據區,通過「轉換構造」初始化局部變數 temp——A temp (69);
- 再者,通過「拷貝構造」將 temp 拷貝給臨時匿名物件,並銷毀物件 temp——return temp;
- 最後,通過「拷貝構造」將臨時匿名物件拷貝給 a,銷毀臨時匿名物件 ——Aa= func ();
- 👉由此可見,過程包含 1 次轉換構造、2 次拷貝構造
- 編譯器優化
- 第 1 次優化,取消第 1 次「拷貝構造」,直接將 temp 拷貝給 a(Windows 下的優化)
- 第 2 次優化,取消第 2 次「拷貝構造」,直接是 temp 指向 a(Mac、Linux 下的優化)
關閉 RVO 後#
通過g++ -fno-elide-constructors編譯源文件,即可關閉RVO
- 出現了兩次額外的「拷貝構造」,具體分析見上
注意點#
- 編譯器本質上是通過替換 this 指針實現 RVO 的
- 因為編譯器一般會對拷貝構造進行優化,所以在工程開發中,不要改變拷貝構造的語義
- 即:拷貝構造中只做拷貝操作,而不要做其它操作
- 如:拷貝構造中,對拷貝的屬性加 1,就不符合拷貝構造的語義,編譯器把拷貝構造優化掉後,結果與優化前不一致
+ 賦值運算的優化#
也存在RVO——優化了1次拷貝構造,即將局部變數拷貝給臨時匿名物件
- 添加了紅框部分代碼
- 優化前結果:
- 1 次「轉換構造」 + 1 次「拷貝構造」
- 優化後結果:
- 1 次「拷貝構造」
+ 拷貝構造函數的調用分析#
多種寫法下,拷貝構造函數是如何調用的?
場景:類 A 中包含一個自定義類 Data 的物件 d,紅框為添加的代碼
「主要關注第 29 行;關閉 RVO 編譯,否則會跳過拷貝構造函數」
- 自定義拷貝構造函數,並且對每個成員屬性都進行了顯式拷貝,即第 29 行不變
- 在構造物件 d 時,會調用 Data 類的拷貝構造函數
- 自定義拷貝構造函數,不對每個成員屬性進行顯式拷貝,即刪去 ",d (a.d)"
- 在構造物件 d 時,會調用 Data 類的默認構造函數(自定義時沒顯式拷貝,則匹配默認構造)
- 不自定義拷貝構造函數,編譯器自動為其添加默認的拷貝構造函數,即刪去第 29~31 行
- 在構造物件 d 時,會調用 Data 類的拷貝構造函數(編譯器默認的)
結論:
❗️要想達到預期的結果,在自定義拷貝構造函數時,應該對每個成員屬性進行顯式拷貝
- 如果自定義的拷貝構造函數什麼都不寫,那該函數就什麼都不會做
其它知識點#
引用#
引用就是其綁定物件的別名
- ❗️引用在定義的時候就需要初始化,即綁定物件,如:
- People a;
- 定義時就初始化:People &b = a;
- 否則:People &b; b = c; 會發生歧義 —— 綁定物件還是賦值?
類屬性與方法#
加有 static 關鍵字
區別於成員屬性(每一個物件特有的)、成員方法(this 指針指向當前物件)
- 類屬性:該類所有物件統一的屬性
- 全局唯一、共享
- 例如:全人類的數量 —— 人類中所有物件的數量
- 類方法:不單獨屬於某個物件的方法
- 不和物件綁定,無法訪問 this 指針
- 例如:測試某個高度是否為合法身高
const 方法#
不改變物件的成員屬性,不可調用非 const 成員函數
- 提供給 const 物件使用(其任何屬性都不能被改變)
⚠️:
mutable:可變的,其修飾的變數在 const 方法中可變
default 和 delete#
默認函數的控制,C++ 11
- default:顯式地使用編譯器默認提供的規則
- 僅適用於類的特殊成員函數(默認構造函數、析構函數、拷貝構造函數、拷貝賦值運算符),且該特殊成員函數沒有默認參數
- ❗️該特性沒有功能上的意義,這是 C++ 的設計哲學:關注可讀性、可維護性等
- 理解這種理念,可以提高自己在 C++ 上的審美標準
- delete:顯式地禁用某個函數
struct 和 class#
- struct
- 默認訪問權限:public(黑名單機制,需要顯式定義 private 成員)
- 也是用來定義類的,有成員屬性和成員方法,要與 C 中區分開來
- class
- 默認訪問權限:private(白名單機制:需要顯式定義 public 成員)
❓:C++ 為什麼要保留 struct 關鍵字?默認權限為什麼是 public?
- 都是為了兼容 C 語言,可以減小推廣難度
PS:前端語言 JavaScript 為了減小推廣難度,從名稱蹭 Java 的熱度,本質與 Java 無關
代碼演示#
類的示例#
- 類中屬性和方法的聲明和定義,建議分開
- this 指針只在成員方法中使用,它指向當前物件的地址
簡單實現 cout#
- cout 是一個物件,是一個高級變數
- 返回其自身引用可以實現連續 cout,使用引用的原因後續理解
- 字符串需要使用 const 類型變數接收,否則會警告,因為是字符串是字面量
- 命名空間的精髓:相同的物件名可以存在不同的命名空間中
構造函數和析構函數#
運行結果:
1)析構順序的探討#
- 構造順序:物件 a,物件 b
- 析構順序:物件 b,物件 a
- ❓為什麼析構函數的調用順序是反過來的?是因為編譯器產生的特例,還是一個正常的語言特性?👉語言特性
- 物件 b 的構造可能依賴物件 a 的信息➡️在析構的時候物件 b 也可能會用到物件 a 的信息➡️物件 b 要先於物件 a 析構
- ❗️誰先構造,它就後析構
- PS
- 這與物件放在堆空間還是棧空間無關,實驗表明,析構順序都是反過來的
- 可以認為是一種拓撲序
2)轉換構造函數#
- ❓為什麼叫做轉換構造函數(單一參數的構造函數)
- 將一個變數轉化成了該類型的物件
- 🌟a=123 涉及運算符重載:隱式類型轉換➡️賦值➡️析構,詳解見代碼
3)拷貝構造函數#
1、加引用:防止無限調用拷貝構造
- ❗️如果拷貝構造函數為:A (A a) {},則 A b = a; 時,
- 因為 [形參 a] 不是引用(值傳遞),所以需要先將 [物件 a] 拷貝到 [形參 a] 中生成臨時物件
- 此時,又會發生拷貝構造,而這個拷貝構造同樣會經歷上述過程
- 從而無限遞歸
- PS:引用不產生任何拷貝行為,比指針更方便
2、加 const:防止 const 類型物件使用非 const 類型的拷貝構造,報錯
- 也防止被拷貝的物件被修改
注:
- 在定義物件時,"=" 調用的是拷貝構造函數,而不是賦值運算,例如,A b = a
4)思考#
- 物件是什麼時候完成了構造?
- 「參考代碼,以默認構造函數為例」
- 功能上的構造 [邏輯上]:到第 46 行,構造函數表面上執行完成了
- 編譯器的構造[實際上]:到第 39 行,就已經可以調用物件成員了❗️🌟
- 通過思考部分的代碼可以理解:
- 場景:在類 Data 中添加有參構造函數,使編譯器幫其添加的默認構造函數被刪除
- 過程:在生成類 A 物件時,成員屬性 Data 類物件 p、q 需要已經完成構造,而此時 Data 類的默認構造已經被刪除
- 結果
- 如果不使用初始化列表初始化 p、q,則報錯
- 如果使用初始化列表,則可行,初始化列表屬於編譯器所謂的構造
- ❗️這說明編譯器的構造是在函數聲明後(第 39 行)就完成了
- ⚠️:
- 編譯器會默認添加默認構造函數和拷貝構造函數
- 構造行為都應該放在編譯器所謂的構造裡,如使用初始化列表
+)左值引用#
- 後續學習
+)友元函數#
- 在類內聲明(同時保證是類的管理者批准的)
- 在類外實現,本質是類外的函數,但可以訪問類內的私有成員屬性
深拷貝、淺拷貝#
拷貝物件:數組
- 編譯器默認添加的拷貝構造為淺拷貝
- 對於指針,只拷貝地址,所以對拷貝後的物件的修改,會修改原物件
- 自定義深拷貝版的拷貝構造函數
- 對於指針,拷貝其指向地址的值
- PS:構造函數只有在初始化時才被調用,不存在自己拷貝自己的行為
⚠️:
- 為了適配重載 "<<" 時的 const 參數,需要實現 const 版的 "[]" 重載
- Array 類裡的 end 和數組結尾數據沒關係,他只是用來監控數組越界的情況
new、malloc 差別分析#
運行結果:
- malloc 和 new 都可以申請空間,分別對應 free 和 delete(如果是數組,則為 delete [])銷毀空間
- new 會自動調用構造函數,對應的 delete 會自動調用析構函數;而 malloc 和 free 都不會
- malloc + new 可以實現原地構造,常用於深拷貝,其中,new 還可以對應不同類的構造函數
類屬性與方法、const、mutable#
- 類屬性:帶 static 在類內聲明,不帶 static 在類外初始化
- 類方法:可以通過物件或類名兩種方式調用它
- 因此,物件調用的方法不一定是成員方法,還可能是類方法
- const:const 物件只能調用 const 方法,const 方法內只能調用 const 方法
- mutable:其修飾的變數可在 const 方法中改變
default、delete#
功能需求:使某個類下的物件,不能被拷貝
- 禁用拷貝構造函數和賦值運算符
- 拷貝構造函數:設置為 = delete,或者,將其放在 private 權限中
- 賦值運算符重載函數:同樣設置為 = delete(永遠不能使用賦值運算),或者,將其放在 private 權限中(只有類內方法可以使用賦值運算)
- PS:賦值運算符要同時考慮 const 版和非 const 版
- ❓要真正實現物件不能被拷貝,應該將拷貝構造函數和賦值運算符重載都設置為 = delete,否則類內方法還是可以拷貝該物件
附加知識點#
- 構造函數後的花括號
- 加花括號,表示是一個函數實現
- 不加花括號,則只是一个函數聲明,還需要寫一個專門的實現
思考點#
- 要關注編譯器默認做的很多事情,這是 C++ 複雜的地方
Tips#
- C++
- 學習方法:按照編程範式分門別類地學習
- 學習重點:程序的處理流程 [遠比 C 複雜]