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

封裝 ( Encapsulation )

封裝就是藉由存取限定符來對不同的成員加以限制,讓只有必要的組件對外公開,其他資訊都被隱藏了起來。

#include <iostream>
using namespace std;

class Person {
  // 私有成員 在類別外部就無法被存取(相當於隱藏了起來)
  int socialID;
  string name;
  public:
  Person(string n, int id) {
    name = n;
    socialID = id;
  }
  // 如果必要,就可以利用public修飾的成員函數來存取私有成員
  string getName() {
    return name;
  }
  bool validateID() {
    if (socialID <= 1001 && socialID >= 0)
      return true;
    else
      return false;
  }
};
int main() {
  Person p1("Shubham", 503);
  if (!p1.validateID()) cout << "Invalid SocialID\n";
  cout << p1.getName();
  return 0;
}

封裝的優點

  • 模組化:封裝透過將程式碼組織成處理特定任務的單獨物件來促進模組化,使程式更加結構化且更易於管理。
  • 程式碼維護:封裝有助於更輕鬆地維護和更新程式碼。
  • 提高安全性:透過隱藏資料並限制未經授權的存取,封裝提高了程式的安全性。
  • 去耦合:有助於減少系統組件之間的依賴性,使它們獨立運行,並使系統更易於維護和擴展。

繼承 ( Inheritance )

初探繼承

繼承機制是物件導向程式設計使程式碼可以複⽤的最重要的⼿段,它允許我們在保持原有類別特性的基礎上進⾏擴展。

增加⽅法(成員函式)和屬性(成員變數),這樣產⽣新的類別,稱派⽣類別。

繼承呈現了物件導向程式設計的層次結構,體現了由簡單到複雜的認知過程。

繼承關係可以理解為 B is A

// 舉例來說 : 一個人可以是多個角色 教師、學生等
class Person {
private:
string _Name;
int _Age;
string _PhoneNumber;
// ....
};

class Teacher : public Person {
private:
int _ID;
string _Course;
// ...
};

class Student : public Person {
private:
int _StuID;
// ...
};

int main() {
Person p;
Teacher t;
Student s;
return 0;
}

繼承.png

繼承定義

將上面的程式碼取一部分出來

class Student : public Person{...};

/*
Student -> 派⽣類別
public -> 繼承方式
Person -> 繼承的類別(也稱基類)
*/

關於繼承方式,和存取限定一樣分為 public、private、protected,但在繼承中,private和protected就開始有區別了。
雖然繼承方式有三種,但最常用的也就public。

繼承方式比較.png

private成員也繼承了,只是派⽣類別中不能直接存取

基類與派⽣類

在 public 繼承的情況下,派生類物件可以賦值給基類的指標或引用,這稱為向上轉型,是安全的。

然而,若派生類物件以值傳遞(by value)的方式賦值給基類物件,則會發生物件切片(object slicing)。此時,派生類中屬於基類的那一部分會被「切」出來,只保留基類成員,而派生類中新增的成員則會被捨棄。
換句話說,基類的指標或引用實際上只指向派生類物件中「基類那一部分」的區域。

*但基類物件不能賦值給派⽣類別物件。

class Person {
protected:
string _name; // 姓名
string _sex;  // 性別
int _age;     // 年齡
};
class Student : public Person {
public:
int _No; // 學號
};
int main() {
Student sobj;
// 1.派⽣類別物件可以賦值給基類的指標/引⽤
Person *pp = &sobj;
Person &rp = sobj;
// 派⽣類別物件可以賦值給基類的物件是透過調⽤基類的拷貝構造完成的
Person pobj = sobj;

// 2.基類物件不能賦值給派⽣類別物件,這⾥會編譯報錯
sobj = pobj;
return 0;
}

切片.png

基類的指標或引⽤可以透過強制類型轉換賦值給派⽣類別的指標或者引⽤,但是必須確定基類的指標是指向派⽣類別物件時是安全的。

Base* b = new Derived();
Derived* d = (Derived*)b;  // OK,這次安全,因為 b 確實指向 Derived 物件
d->show();  // 輸出 "Derived"
------------------------------------
Base* b = new Base();
Derived* d = (Derived*)b;  // 語法可行,但實際上不安全!
d->show();  // 未定義行為(可能 crash)

派⽣類別的默認成員函數

默認成員函數,是指我們不寫,編譯器會為我們⾃動⽣成。那麼在派⽣類別中,這幾個成員函數是如何⽣成的?它們的運作流程又是如何?

  • 派⽣類別的建構函式必須調⽤基類的建構函式初始化基類的那⼀部分成員。
  • 如果基類沒有預設的建構函式,則必須在派⽣類別建構函式的初始化列表顯⽰調⽤。
  • 派⽣類別的拷貝構造函數必須調⽤基類的拷貝構造完成基類的拷貝初始化。
  • 派⽣類別的operator=必須要調⽤基類的operator=完成基類的拷貝。
  • 需要注意的是派⽣類別的operator=隱藏了基類的operator=,所以顯⽰調⽤基類的operator=,需要指定基類作⽤域。
  • 派⽣類別的析構函數會在被調⽤完成後⾃動調⽤基類的析構函數清理基類成員,因為這樣才能保證派⽣類別物件先清理派⽣類別成員再清理基類成員的順序。
  • 派⽣類別物件初始化先調⽤基類構造函數再調派⽣類別構造函數。

派生類、基類順序.png

需注意多態中⼀些場景析構函數需要構成重寫,重寫的條件之⼀是函數名相同。那麼編譯器會對析構函數名進⾏特殊處理,處理成destructor(),所以基類析構函數不加virtual的情況下,派⽣類別析構函數和基類析構函數構成隱藏關係。

class Person {
public:
  Person(const char *name = "peter") : _name(name) {
    cout << "Person()" << endl;
  }
  Person(const Person &p) : _name(p._name) {
    cout << "Person(const Person& p)" << endl;
  }
  Person &operator=(const Person &p) {
    cout << "Person operator=(const Person& p)" << endl;
    if (this != &p)
      _name = p._name;
    return *this;
  }
  ~Person() { cout << "~Person()" << endl; }

protected:
  string _name; // 姓名
};
class Student : public Person {
public:
  Student(const char *name, int num) : Person(name), _num(num) {
    cout << "Student()" << endl;
  }
  Student(const Student &s) : Person(s), _num(s._num) {
    cout << "Student(const Student& s)" << endl;
  }
  Student &operator=(const Student &s) {
    cout << "Student& operator= (const Student& s)" << endl;
    if (this != &s) {
      // 構成隱藏,所以需要顯⽰調⽤
      Person::operator=(s);
      /*
      傳給Person的拷貝構造就相當於 s 是 賦值給基類的引⽤ => 切片概念
      對 s 從基類繼承的那部分進行拷貝
      */
      _num = s._num;
    }
    return *this;
  }
  ~Student() { cout << "~Student()" << endl; }

protected:
  int _num; // 學號
};
int main() {
  Student s1("jack", 18);
  Student s2(s1);
  Student s3("rose", 17);
  s1 = s3;
  return 0;
}

隱藏

隱藏:派⽣類別和基類中有同名成員,派⽣類別成員將屏蔽基類對同名成員的直接存取,這種情況叫隱藏。

(在派⽣類別成員函數中,可以使⽤ 基類::基類成員 顯⽰存取)

  • 在繼承體系中基類和派⽣類別都有獨⽴的作⽤域。
  • 需要注意的是如果是成員函數的隱藏,只需要函數名相同就構成隱藏。

注意這裡是對不同作用域,所以不是重載,而是隱藏。
注意在實際中在繼承體系⾥⾯最好不要定義同名的成員

// Student的_num和Person的_num構成隱藏關係,可以看出這樣程式碼雖然能跑,但是⾮常容易混淆
class Person {
protected:
  string _name = "⼩李⼦"; // 姓名
  int _num = 111;          // ⾝份證號
};

class Student : public Person {
public:
  void Print() {
    cout << " 姓名:" << _name << endl;
    cout << " ⾝份證號:" << Person::_num << endl;
    cout << " 學號:" << _num << endl;
  }
protected:
  int _num = 999; // 學號
};

int main() {
  Student s1;
  s1.Print();
  return 0;
};
class A {
public:
  void fun() { cout << "func()" << endl; }
};
class B : public A {
public:
  void fun(int i) { cout << "func(int i)" << i << endl; }
};
int main() {
  B b;
  b.fun(10);
  b.fun(); // 編譯錯誤,因為A的fun被隱藏了,不會調用A的fun 除非顯示調用
  return 0;
}

無法被繼承的類別

如果希望一個類別無法被其他類別繼承

  1. 將基類的構造函數設成私有
    => 派⽣類別的構成必須調⽤基類的構造函數,但是基類的構成函數私有以後,派⽣類別看不見就不能調⽤了,那麼派⽣類別就無法實例化出物件。

    class Base {
     private:
     Base() {}
     int _a = 10;
    };
    
    class Derived : public Base {
     private:
     int _b = 20;
    };
    
    int main() {
     Derived d;
     /*
     Call to implicitly-deleted default constructor of 'Derived'clang(ovl_deleted_special_init)
    main.cpp(371, 17): Default constructor of 'Derived' is implicitly deleted because base class     'Base' has an inaccessible default constructor
     */
     return 0;
    }
  2. C++11 新增了一個關鍵字 final,final修改基類,派⽣類別就不能繼承了。

    class Base final {
      Base() {}
    
      private:
      int _a = 10;
    };
    
    class Derived : public Base { // 報錯:Base 'Base' is marked 'final'
      private:
      int _b = 20;
    };
    
    int main() {
      Derived d;
      return 0;
    }

繼承與友元

基類的友元不會繼承給派⽣類別 => 也就是說,基類的友元不能存取派⽣類別的私有、保護成員。

class Derived;

class Base {
friend void display(const Base &b, const Derived &d);

public:
private:
int _a = 10;
};

class Derived {
// friend void display(const Base &b, const Derived &d);

private:
int _b = 20;
};

void display(const Base &b, const Derived &d) {
cout << b._a << endl; // 可以存取Base 的私有
cout << d._b << endl; // 不能存取
}

繼承與靜態成員

基類定義了static靜態成員,則整個繼承體系⾥⾯只有⼀個這樣的成員,無論派⽣出多少個派⽣類別,都只有⼀個static成員實例。

如何驗證?

class Person {
 public:
 string _name;
 static int _count;
};
int Person::_count = 0;
class Student : public Person {
 protected:
 int _stuNum;
};
int main() {
 Person p;
 Student s;
 // 這⾥的運⾏結果可以看到⾮靜態成員_name的位址是不⼀樣的
 // 說明派⽣類別繼承下來了,⽗類派⽣類別物件各有⼀份
 cout << &p._name << endl;
 cout << &s._name << endl;
 // 這⾥的運⾏結果可以看到靜態成員_count的位址是⼀樣的
 // 說明派⽣類別和基類共⽤同⼀份靜態成員
 cout << &p._count << endl;
 cout << &s._count << endl;
 // 公有的情況下,⽗派⽣類別指定類別域都可以存取靜態成員
 cout << Person::_count << endl;
 cout << Student::_count << endl;
 return 0;
}

static成員只共享一份.png

單繼承與多繼承

單繼承:⼀個派⽣類別只有繼承⼀個基類時稱這個繼承關係為單繼承
單繼承.png

多繼承:⼀個派⽣類別繼承兩個或以上基類時稱這個繼承關係為多繼承
多繼承.png

多繼承物件在記憶體中的模型是,先繼承的基類在前⾯,後⾯繼承的基類在後⾯,派⽣類別成員在放到最後⾯

而多繼承中,可能會引發菱形繼承。菱形繼承是多繼承的⼀種特殊情況。菱形繼承的問題,從下⾯的物件成員模型構造可以看出菱形繼承有資料冗餘和⼆義性的問題,在Assistant的物件中Person成員會有兩份。
菱形繼承.png

class Person {
public : string _name; // 姓名
};
class Student : public Person {
protected : int _num; // 學號
};
class Teacher : public Person {
protected : int _id; // 職⼯編號
};
class Assistant : public Student, public Teacher {
protected : string _majorCourse; // 主修課程
};
int main() {
// 編譯報錯:error C2385: 對"_name"的存取不明確
Assistant a;
a._name = "peter";
// 需要顯⽰指定存取哪個基類的成員可以解決⼆義性問題,但是資料冗餘問題⽆法解決
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
return 0;
}

那如何解決菱形繼承帶來的問題?虛繼承

虛繼承

class Person {
public:
string _name; // 姓名
/*int _tel;
int _age;
string _gender;
string _address;*/
// ...
};
// 使⽤虛繼承Person類別
class Student : virtual public Person { // virtual 關鍵字添加的位置應是菱形繼承中 中間的位置
protected:
int _num; // 學號
};
// 使⽤虛繼承Person類別
class Teacher : virtual public Person {
protected:
int _id; // 職⼯編號
};
// 教授助理
class Assistant : public Student, public Teacher {
protected:
string _majorCourse; // 主修課程
};
int main() {
// 使⽤虛繼承,可以解決資料冗餘和⼆義性
Assistant a;
a._name = "peter";
return 0;
}

虛繼承底層原理

一般多繼承的記憶體配置

class A { int a; };
class B { int b; };
class C : public A, public B { int c; };

/*C物件記憶體:
記憶體配置 => 以 32-bit 為例,每個 int 佔 4 bytes
+----+----+----+
| a  | b  | c  |
+----+----+----+
A 的成員 a 在最前面; 接著是 B 的成員 b ; 最後是 C 自己的成員 c
成員按照繼承順序線性排列
    (1)若 A 與 B 彼此沒有關係,這樣的記憶體模型沒有問題
    (2)若 A 是 B 和 C 的共同基類,會導致菱形繼承問題
所以可以看到,菱形結構並不是說多繼承關係圖呈現出來是菱形纔是菱形結構
*/

菱形結構

class A { int a; };
class B : public A {int b;};
class C : public A {int c;};
class D : public B, public C {};  // 問題在這

/*
D物件記憶體:
+----+    // B::A::a
| a  |
+----+
| b  |
+----+    // C::A::a
| a  |
+----+
| c  |
+----+
| d  |
+----+
成員 a 有兩份,導致二義性(ambiguous access)=> d.a 究竟是 B::A::a 還是 C::A::a?
*/

虛繼承

class A { int a; };
class B : virtual public A {int b;};
class C : virtual public A {int c;};
class D : public B, public C {};  // 問題在這

/*
D物件記憶體:
+---------------------+
| B 部分               |
| (含vptr)             |
+---------------------+
| C 部分               |
| (含vptr)             |
+---------------------+
| A 的實體資料 (唯一)    |
+---------------------+
| D 自己的資料          |
+---------------------+

    (1) A 的成員只會有一份
    (2) 存取時透過虛擬指標(virtual base table pointer, vbptr)來正確定位 A 的記憶體位址
    (3) C++ 編譯器通常在 B 和 C 的物件結構中額外加入一個 vbptr,用來指向虛擬繼承的 base class A 在物件中的位置
*/

*vptr指向vtable而vtable中保存著A相對於物件初始位置的offset

繼承與組合

繼承:派⽣類別繼承基類,獲得基類的屬性及方法 => 派⽣類別和基類是一種 is - a 的關係(派⽣類別是基類)

class Animal {
public:
void eat() { cout << "eating...\n"; }
};

class Dog : public Animal {
public:
void bark() { cout << "barking...\n"; }
};
// Dog 是一種動物

組合:在一個類別中擁有其他類別的實例 => has - a 的關係(A擁有B)

class Engine {
public:
void start() { cout << "Engine starts.\n"; }
};

class Car {
private:
Engine engine;
public:
void drive() {
 engine.start();
 cout << "Car drives.\n";
}
};
//  Car 透過內部的 Engine 物件使用功能,不是繼承,而是「擁有一個引擎」

繼承與組合的比較

繼承vs.組合.png

// 繼承繼承所有東西 vs. 組合封裝內部
class Window {
public:
void draw() { cout << "Draw window" << endl; }
void resize() { cout << "Resize window" << endl; }
};

class Dialog : public Window { // Dialog 得到所有 Window 行為,即使有些可能不該公開
public:
void showDialog() {
 draw();  // 合理
 resize(); // 也可以被呼叫(不一定需要)
}
};

class Dialog {
private:
Window window;
public:
void showDialog() {
 window.draw();  // 只用需要的方法 =>  不會曝露window
}
};
// 繼承容易破壞封裝
class Base {
protected:
int state = 0;
};

class Derived : public Base {
public:
void reset() {
 state = -999;  // 直接改父類別成員
}
};

class StateHolder {
private:
int state = 0;
public:
void reset() { state = 0; }
};

class Derived {
private:
StateHolder holder;
public:
void reset() {
 holder.reset();  // 間接操作
}
};

多態,也就是「 多種形態 」,以現實生活為例,一個人可以有不同的個性、扮演不同的角色。
而在C++中,多態可以應用在函數、運算符中,相同的函數在不同情況下可以有不同的表現,運算符同理。

而C++的多態又可以分為兩種

  1. 編譯時多態:也稱「 靜態綁定 」,比如函數重載、運算符重載都是編譯時多態

    補充知識:函數重載 -> 兩個或多個同名函數,因傳入的參數不同而有不同的表現。

    函數重載 ( Overloading ) 的限制:

    • 要構成函數重載的函數需要在同一個作用域。
    • 重載函數是依據不同形參的個數、不同形參的類型、不同形參的順序為重載條件。
    • 函數回傳值不能當作函數重載的條件。
      *因為函數調用時沒有指定回傳類型,編譯器無法區分它們,從而導致歧義問題。
#include <iostream>
using namespace std;

// 不同的參數類別型
int add(int a, int b);
double add(double a, double b);

int main() {

    cout << add(10, 2) << endl; // 調用 int add(int a, int b)
    cout << add(5.3, 6.2); // 調用 double add(double a, double b);

    return 0;
}
int add(int a, int b) {
    return a + b;
}
double add (double a, double b) {
    return a + b;
}
  1. 運行時多態:也稱「 動態綁定 」由程式運行時確定要調用哪個函數。運行時多態是透過使用虛函數的函數重寫來實現的

    當派⽣類別定義了一個或多個基類的成員函數時,就會發生函數重寫 ( Overriding )

    函數重寫的條件:

    • 基類的函數需要宣告為虛函數 ( 在函數前加上 virtual 關鍵字 )
    • 函數名稱相同、函數參數個數、類型相同
    • 必須是基類的指標或引⽤調用虛函數
    #include <bits/stdc++.h>
    using namespace std;
    
    class Base {
    public:
    
     // 虛函數,函數回傳值類型前加上關鍵字 virtual
     virtual void display() { // 如果不希望虛函數被重寫,就可以在函數後加上final
         cout << "Base class function";
     }
    };
    
    class Derived : public Base {
    public:
     // 重寫虛函數 (基類的虛函數前面可以不寫virtual 因為繼承也會繼承父類的虛函數,因此這個函數本就有虛函數的特性)
       // override 關鍵字是用來確保重寫函數滿足重寫條件 ( 函數名稱相同、函數參數個數、類型相同 )
     void display() override {
         cout << "Derived class function";
     }
    };
    
    int main() {
     // 基類的指標
     Base* basePtr;
     Derived derivedObj;
         // 基類指標指向派⽣類別
     basePtr = &derivedObj;
     // 就會調用派⽣類別的虛函數 構成多態
     basePtr->display(); // Derived class function
     return 0; 
    }

    虛函數的工作原理

    如果一個類別包含一個虛函數,那麼編譯器本身就會做兩件事

    1. 如果建立了該類別的一個物件,則會插入一個虛擬指標 (vptr)指向該類別的虛函數表 (vtable)。對於每個新建立的物件,都會在物件記憶體的前四個位元組插入一個 vptr
    2. 無論物件是否建立,類別都包含一個靜態函數指標陣列,稱為虛函數表。該表儲存了該類別中每個虛函數的位址

VirtualFunctionInC.png
虛函數的規則:

  1. 虛函數不能是靜態的
  2. 虛函數可以是另一個類別的友元函數
  3. 使用基類類型的指標或引⽤來存取虛函數,以實現運行時多態性
  4. 類別可以有虛析構函數,但不能有虛構造函數

虛析構函數

#include <iostream>
using namespace std;
// 此程式會導致未定義行為 => 使用具有非虛析構函數的基類類型指標刪除派⽣類別物件會導致未定義行為
// 將基類的析構函數設為虛函數,可以保證派⽣類別的物件能夠被正確析構,也就是說,基類和派⽣類別的析構函數都會被調用
class base {
  public:
  base()     
  { cout << "Constructing base\n"; }
  // virtual ~base()
  // { cout<< "Destructing base\n"; }    
  ~base()
  { cout<< "Destructing base\n"; }     
};

class derived: public base {
  public:
  derived()     
  { cout << "Constructing derived\n"; }
  ~derived()
  { cout << "Destructing derived\n"; }
};

int main()
{
  derived *d = new derived();  
  base *b = d;
  delete b;
  getchar();
  return 0;
}

*原則上,只要類別中有一個虛函數或需要使用基類類型指標刪除派⽣類別物件,就應該立即添加一個虛析構函數(即使它什麼都不做)。這樣,就能確保以後不會出現任何意外,且虛析構函數應是寫在基類


重載 vs. 重寫 vs. 隱藏

比較.png