C++ 物件導向概念 ( 上 )

C++ 中新增了一個關鍵字 ---- class
類別是物件導向程式設計的基石,可以將類別當作一個藍圖,在類別中有成員變量(屬性)和成員函數(行為) *定義在類別內的成員函數預設為inline函數
C++中 struct 也可以定義類別,C++相容C中 struct 的⽤法,同時struct 升級成了類別
明顯的變化是struct中可以定義函數,⼀般情況下還是推薦⽤class定義類別

class Animal {
public:
    // 成員變量
    string species;
    int age;
    int name;
    
    // 成員函數
    void eat() {
    }
    void sleep() {
    }
    void makeSound () {
    }
};

有關類別的存取限定符,是在賦予類別成員可被存取的權限,決定類別成員是否能被外部存取
C++中有三種存取限定符:

  • public:被public修飾的成員可以在類別外被存取,C++中struct預設存取權限為public
  • private:類別預設的存取權限,被private修飾的成員只能在類別內存取,類別外不能被存取
    除了友元函數或友元類別可以在類別外存取這些私有成員。如果期望在類別外取得私有成員,可以在類別中寫public修飾的函數來獲取
  • protected:在不討論繼承的情況下,可以認為private和protected是一樣的,其差異會在「繼承」中細說

類別的大小

一個類別中有成員變量和成員函數,而決定類別大小的只有成員變量。因為函數在被編譯後就是指令,類別實例出來的物件不能儲存這些指令。這些指令被儲存在程式碼段。而類別的大小和結構體一樣有記憶體對齊的規則。

記憶體對齊規則

  • 第⼀個成員在與結構體偏移量為0的位址處
  • 其他成員變量要對齊到某個數字(對齊數)的整數倍的位址處
  • 注意:對齊數 = 編譯器預設的⼀個對齊數 與 該成員⼤⼩的較⼩值
  • VS中預設的對齊數為8
  • 結構體總⼤⼩為:最⼤對齊數(所有變量類型最⼤者與預設對齊參數取最⼩)的整數倍
  • 如果巢狀了結構體的情況,巢狀的結構體對齊到⾃⼰的最⼤對齊數的整數倍處,結構體的整體⼤⼩就是所有最⼤對齊數(含巢狀結構體的對齊數)的整數倍
    物件成員的存儲.png

類別中的默認成員函數

在 C++ 裡,如果我們沒有自己寫某些特殊的成員函數,編譯器會自動幫我們生成。這些函數就叫做默認成員函數
默認成員函數.png

構造函數:物件實例化時初始化物件

  • 函數名與類別名相同
  • 無回傳值
  • 物件實例化時系統會⾃動調⽤對應的構造函數
  • 構造函數可以重載
  • 如果類別中沒有顯式定義構造函數,則C++編譯器會⾃動⽣成⼀個無參的默認構造函數,⼀旦⽤⼾顯式定義編譯器將不再⽣成
  • 無參構造函數、全缺省構造函數、我們不寫構造時編譯器默認⽣成的構造函數,都叫做默認構造函數。但是這三個函數有且只有⼀個存在,不能同時存在

無參構造函數和全缺省構造函數雖然構成函數重載,但是調⽤時會存在歧義,而C++中默認生成的構造函數是無參構造函數。

總結來說,能在沒有參數的情況下呼叫的構造函數叫作默認構造函數 若我們未自行撰寫構造函數,編譯器會自動生成一個默認構造函數。

對於內建類型的成員變量,編譯器並不會自動進行初始化,因此其值是否被初始化是未定義的(可能依編譯器而異)。
而對於自定義類型的成員變量,編譯器會嘗試呼叫該類型的默認構造函數進行初始化 若該成員類型沒有提供默認構造函數,則會導致編譯錯誤。

為了正確初始化此類成員,我們必須在構造函數中使用初始化列表進行明確初始化。


析構函數

一般來說,析構函數不需要顯示定義,但如果物件成員有動態申請空間,就需要顯示定義析構函數來釋放這些動態申請的資源

  • 析構函數名是在類別名前加上字元 ~
  • 無參數無回傳值
  • ⼀個類別只能有⼀個析構函數。若未顯式定義,系統會⾃動⽣成默認的析構函數
  • 物件⽣命週期結束時,系統會⾃動調⽤析構函數

與構造函數類似,若我們未自行撰寫析構函數,編譯器會自動生成一個默認析構函數。

這個默認析構函數對於內建類型成員變量不做任何處理;而對於自定義類型成員變量,則會自動呼叫其對應的析構函數來完成釋放工作。

即使我們顯式撰寫了析構函數,自定義類型的成員仍然會自動呼叫其析構函數換言之,自定義類型的成員在任何情況下都會自動進行析構。

若類別中沒有動態資源的申請(如 new、檔案開啟等),則可以不撰寫析構函數,直接使用編譯器生成的默認版本即可。

但若類別中有資源的動態分配,則必須手動撰寫析構函數,以避免資源洩漏。

此外,C++的規範規定:在同一作用域(例如函數的區域變數)中,物件的析構順序與定義順序相反,即「後定義的先析構(LIFO 原則)」

class Stack{
 public:
  Stack(int n = 4){ // 構造
   cout << "調用 Stack()" << endl;
    _a = (int*)malloc(sizeof(int)*n);
    if(_a == NULL){
      perror("malloc fail");
      exit(-1);
    }
    _capacity = n;
    _size = 0;
  }
  ~Stack(){ // 析構
    cout << "~Stack()" << endl;
    free(_a);
    _a = nullptr;
    _top = _capacity = 0;
  }
  private:
  int* _a;
  int _capacity;
  int _size;
};

拷貝構造函數

  • 拷貝構造函數是構造函數的⼀個重載。
  • 拷貝構造函數的第⼀個參數必須是類別類型物件的引⽤,使⽤傳值⽅式編譯器直接報錯,因為語法邏輯上會引發無窮遞迴調⽤。拷貝構造函數也可以多個參數,但是第⼀個參數必須是類別類型物件的引⽤,後⾯的參數必須有缺省值
    拷貝構造.png
  • C++規定⾃定義類型物件進⾏拷貝⾏為必須調⽤拷貝構造,所以⾃定義類型傳值傳參和傳值回傳都會調⽤拷貝構造完成
  • 若未顯式定義拷貝構造,編譯器會⽣成⾃動⽣成拷貝構造函數。⾃動⽣成的拷貝構造對內建類型成員變量會完成值拷貝/淺拷貝(⼀個位元組⼀個位元組的拷貝),對⾃定義類型成員變量會調⽤他的拷貝構造
  • 像成員變量全是內建類型且沒有申請資源,編譯器⾃動⽣成的拷貝構造就可以完成需要的拷貝,所以不需要顯⽰實現拷貝構造。但是若有申請資源,編譯器⾃動⽣成的拷貝構造完成的值拷貝/淺拷貝不符合我們的需求,所以需要我們⾃⼰實現深拷貝(對指向的資源也進⾏拷貝)。
  • 這⾥還有⼀個⼩技巧:如果⼀個類別顯⽰實現了析構並釋放資源,那麼就需要顯⽰寫拷貝構造,否則就不需要
  • 傳值回傳會產⽣⼀個臨時物件調⽤拷貝構造,傳值引⽤回傳,回傳的是回傳物件的別名(引⽤),沒有產⽣拷貝。但是如果回傳物件是⼀個當前函數局部域的局部物件,函數結束就銷毀了,那麼使⽤引⽤回傳是有問題的,這時的引⽤相當於⼀個野引⽤,類似⼀個野指標⼀樣。傳引⽤回傳可以減少拷貝,但是⼀定要確保回傳物件,在當前函數結束後還在,才能⽤引⽤回傳

    class Date
    {
    public:
    Date(int year = 2025, int month = 1, int day = 1) : _year(year), _month(month), _day(day)
    {
    }
    // 拷貝構造函數
    // 如果寫的是 Date(Date d) -->  編譯報錯 : error C2652: "Date": 非法的拷貝構造函數: 第一個參數不應是"Date"
    Date(Date &d)
    {
      _year = d._year;xq
      _month = d._month;
      _day = d._day;
    }
    // 也可以使用指標來實現拷貝(但此時就只是普通的構造函數而不是拷貝構造函數)
    Date(Date *d)
    {
      _year = d->_year;
      _month = d->_month;
      _day = d->_day;
    }
    void Print()
    {
      cout << _year << "年" << _month << "月" << _day << "日" << endl;
    }
    private:
    int _year;
    int _month;
    int _day;
    };

運算符重載

當運算符被⽤於類別類型的物件時,C++允許我們透過運算符重載的形式指定新的含義。C++規定類別類型物件使⽤運算符時,必須轉換成調⽤對應運算符重載,若沒有對應的運算符重載,則會編譯報錯。

運算符重載是具有特殊名字的函數,他的名字是由operator和後⾯要定義的運算符共同構成。和其他函數⼀樣,它也具有其回傳類型和參數列表以及函數體。

重載運算符函數的參數個數和該運算符作⽤的運算物件數量⼀樣多。⼀元運算符有⼀個參數,⼆元運算符有兩個參數,⼆元運算符的左側運算物件傳給第⼀個參數,右側運算物件傳給第⼆個參數。

如果⼀個重載運算符函數是成員函數,則它的第⼀個運算物件默認傳給隱式的this指標,因此運算符重載作為成員函數時,參數⽐運算物件少⼀個。

運算符重載以後,其優先級和結合性與對應的內建類型運算符保持⼀致。不能透過連接語法中沒有的符號來建立新的操作符:⽐如operator@。

.* :: sizeof ?: . 注意以上5個運算符不能重載

重載操作符⾄少有⼀個類別類型參數,不能透過運算符重載改變內建類型物件的含義

class A {
public:
    int value;
    A(int v) : value(v) {}

    // 重載 +
    A operator+(const A& other) const {
        return A(value + other.value);
    }
};

int main() {
    A a1(1), a2(2);
    A a3 = a1 + a2;  // OK:兩個參數都是類型 A
}

// 錯誤:你不能這樣改變 int 的加法行為
int operator+(int a, int b) {
    return a - b; // 讓 + 變成 -?
}

⼀個類別需要重載哪些運算符,取決於哪些運算符重載後有意義

重載++運算符時,有前置++和後置++,運算符重載函數名都是operator++,無法很好的區分。因此,C++規定,後置++重載時,增加⼀個int形參,跟前置++構成函數重載,⽅便區分

重載<<和>>時,需要重載為全域函數,因為重載為成員函數,this指標默認搶佔了第⼀個形參位置,第⼀個形參位置是左側運算物件,調⽤時就變成了物件 << cout,不符合使⽤習慣和可讀性。重載為全域函數把ostream/istream放到第⼀個形參位置就可以了,第⼆個形參位置當類別類型物件

// 如果把 << 或 >> 重載成成員函數
obj << cout;  // 很奇怪
// 全域函數
cout << obj;  // 符合直觀上使用
// 通常把它們寫成 全域函數,第一個參數放 ostream& 或 istream&,第二個參數放類別物件

重載為全局時⾯臨物件訪問私有成員變量的問題有幾種⽅法可以解決

  • 成員放公有
  • Date提供getxxx函數
  • 友元函數

重載為全局時⾯臨物件訪問私有成員變量的問題有幾種⽅法可以解決

  • 成員放公有
  • Date提供getxxx函數
  • 友元函數
  • 重載為成員函數

友元函數在類別外實現不用加上作用域限定符因为友元函數不是類別的成員函數,它只是被授權可以存取該類別的私有成員的一個普通函數,所以不屬於類別,也就不需要加ClassName::

賦值運算符重載⽤於完成兩個已經存在的物件直接的拷貝賦值,這⾥要注意跟拷貝構造區分,拷貝構造⽤於⼀個物件拷貝初始化給另⼀個要創建的物件


const 修飾成員函數

將const修飾的成員函數稱為const成員函數,const修飾成員函數放到成員函數參數列表的後⾯ const實際修飾該成員函數隱含的this指針,表明在該成員函數中不能對類別的任何成員進⾏修改。

const修飾類別的成員函數,成員函數隱含的this指標由 類別* const this 變成 const 類別* const this