Learning Objectives
- 用 class 或 struct 定義類別,分辨成員變數、成員函式,以及 public 與 private
- 寫建構子與初始化列表,說出物件什麼時候建構、什麼時候解構
- 說明成員函式如何透過 this 知道自己在處理哪一個物件,以及 const 成員函式的用途
- 用繼承重用程式碼,說明 protected 與建構、解構的順序
- 用 virtual、override 與純虛擬函式寫出多型,並說明基底類別為什麼需要 virtual 解構子
為什麼需要類別
寫一個銀行帳戶程式時,你可能會用兩個變數記錄戶名和餘額,再寫幾個函式處理存款和提款。
帳戶一多,變數和函式散落各處,很容易有人直接寫 balance = -500;,跳過「餘額不能是負的」這條規則。
類別(class)把「資料」和「操作這些資料的函式」包在一起,成為一種新的型別:
class Account {
private:
int balance = 0; // 成員變數:每個物件各有一份
public:
void deposit(int amount) { // 成員函式:操作這個物件的資料
if (amount > 0) balance += amount;
}
int getBalance() const { return balance; }
};
Account a; // a 是 Account 型別的一個物件(object)
a.deposit(500); // 用「物件.成員」呼叫成員函式
類別是設計圖,物件是照著設計圖做出來的實體。同一個類別可以建立很多個物件,每個物件有自己的一份成員變數。
private 的成員只有類別自己的成員函式能碰,外面的程式只能透過 public 的成員函式操作,規則就只需要寫在一個地方。
這種做法叫封裝(encapsulation)。
本課的逐步執行動畫會把物件的每個成員畫出來;呼叫成員函式時,新框架裡會多一個 this,箭頭指向這次被操作的物件。
兩個物件、各自的成員
a 和 b 是同一個類別的兩個物件,右邊會看到它們各有一份 owner 和 balance。每次呼叫成員函式時注意 this 的值:呼叫 a.deposit 時它是 a 的位址,呼叫 b.deposit 時換成 b 的位址。
public、private、struct、const 與 this
| 存取權 | 誰能使用 |
|---|---|
public |
任何地方 |
private |
只有這個類別自己的成員函式(以及之後會學的 friend) |
protected |
這個類別和它的衍生類別(下面的繼承會用到) |
在類別外面寫 a.balance = -500;,編譯器會直接拒絕,程式根本不會被編譯出來。常見的作法是成員變數設成 private,
另外提供 public 的成員函式讀取或修改,例如 getBalance()。
class 和 struct 幾乎一樣,唯一的差別是預設的存取權:class 沒寫的成員是 private,struct 是 public。
習慣上,只是把幾個資料放在一起、沒有規則要保護的,用 struct;有規則要維護的,用 class。
struct Point { int x; int y; }; // 成員都是 public
Point p{3, 4};
cout << p.x; // 可以直接存取
const 成員函式:函式參數列後面加 const,表示這個函式不會修改物件。
透過 const 物件或 const 參考只能呼叫 const 成員函式,所以只讀資料的函式(像 getBalance)都應該加上 const。
this:每個成員函式都有一個隱藏的參數 this,是指向「這次被操作的物件」的指標。
a.deposit(500) 時 this 是 &a,b.deposit(200) 時是 &b;同一份成員函式就是靠它分辨要改哪個物件。
成員名稱和參數同名時,可以寫 this->balance 明確指出是成員。
建構子、解構子與物件的生命週期
每個 Lamp 被建立時,執行會跳進建構子;物件生命結束時,執行會跳進解構子 ~Lamp,程式裡沒有任何一行明確呼叫它。注意三件事:內層區塊的 tmp 在 } 就解構;new 出來的物件位址是堆積(0x55a3…),直到 delete 才解構;main 結束時,剩下的區域物件以建立的相反順序解構。
建構子的寫法
建構子(constructor)名稱和類別相同、沒有回傳型別,物件建立時自動執行,負責讓物件一開始就處於合理的狀態。 可以寫好幾個參數不同的建構子(多載);完全沒寫時,編譯器會提供一個預設建構子。
class Lamp {
string name;
int watt;
public:
Lamp(string n, int w) : name(n), watt(w) { } // 初始化列表
Lamp() : name("lamp"), watt(10) { } // 預設建構子
};
: name(n), watt(w) 是初始化列表:在建構子本體執行之前就初始化成員。也可以在本體裡寫 name = n;,
但那是「先建立、再賦值」。const 成員、參考成員,以及下面要談的基底類別部分,都只能在初始化列表裡初始化。
成員實際初始化的順序是依類別裡宣告的順序,不是初始化列表的書寫順序,兩者最好寫成一致。
解構子(destructor)寫成 ~類別名(),物件生命結束時自動執行,常用來釋放物件持有的資源(例如 new 來的記憶體、開啟的檔案)。
| 物件建立的方式 | 什麼時候解構 |
|---|---|
區域變數 Lamp a(...); |
離開它所在的區塊(})時,同一個區塊裡依建立的相反順序 |
new Lamp(...) |
你寫 delete 時;忘了寫就不會解構,而且記憶體洩漏 |
想一想:封裝與生命週期
下面的程式能不能編譯?如果能,會印出什麼?
class Q {
int x = 1;
};
int main() {
Q q;
cout << q.x;
}
下面的程式會印出什麼?
struct T {
char c;
T(char x) : c(x) { cout << c; }
~T() { cout << c; }
};
int main() {
T a('A');
{
T b('B');
}
T c('C');
}
下面的程式能不能編譯?如果能,會印出什麼?
class Counter {
int v = 0;
public:
void inc() { v++; }
int get() { return v; }
};
void report(const Counter& c) {
cout << c.get();
}
int main() {
Counter k;
k.inc();
report(k);
}
繼承
很多類別有共同的部分。狗和貓都是動物,都有名字、都會吃東西,但狗會撿球、貓會抓老鼠。 繼承(inheritance)讓你把共同的部分寫在基底類別(base class),衍生類別(derived class)自動擁有這些成員,再加上自己的部分:
class Animal { // 基底類別
protected:
string name;
public:
void eat() const;
};
class Dog : public Animal { // Dog 繼承 Animal
public:
void fetch() const; // Dog 自己新增的成員函式
};
: public Animal 是公開繼承,表示「Dog 是一種 Animal」(is-a 關係);Animal 的 public 成員在 Dog 裡仍然是 public。
基底類別的 private 成員,衍生類別也碰不到;如果希望衍生類別可以用、外面不能用,就宣告成 protected。
一個 Dog 物件裡包含一個完整的 Animal 部分,所以建構時要先把基底部分建好:
衍生類別的建構子在初始化列表裡寫 Animal(n) 指定要用基底的哪個建構子。順序是:
- 基底類別的建構子
- 衍生類別的成員初始化
- 衍生類別建構子的本體
解構的順序剛好相反:先衍生類別的解構子,最後才是基底類別的。
繼承與建構、解構順序
看 Dog d("Lucky", 3); 這一行:執行先進入 Dog 的建構子,但在本體執行之前,會先跳到 Animal 的建構子把基底部分建好。d 只有一個位址,Animal 和 Dog 的成員函式拿到的 this 都是它。程式結束時,解構的順序和建構相反。
多型:virtual 與 override
衍生類別的物件可以當成基底類別使用:Base* p = &d; 或 const Base& r = d; 都合法,因為 Derived 是一種 Base。
問題是:透過 p 呼叫一個 Base 和 Derived 都有寫的成員函式時,要執行哪一個版本?
- 沒有
virtual:看指標或參考的型別,在編譯時就決定,執行Base的版本 - 有
virtual:看物件實際的型別,在執行時才決定,執行Derived的版本
第二種就是多型(polymorphism):同一行 p->speak(),指向不同物件時做不同的事。
衍生類別覆寫(override)基底的 virtual 函式時,在後面加上 override。這個關鍵字是 C++11 加入的,
它要求編譯器確認「基底類別真的有一個可以覆寫的 virtual 函式」(同名、同參數、同 const);名稱拼錯或參數寫錯時,編譯器會報錯,
而不是默默變成另一個新函式。
有沒有 virtual 的差別
hello 沒有 virtual,speak 有。p 的型別是 Base*,但它指向的物件是 Derived。看每次呼叫時新框架的名稱:hello 一律進到 Base::hello,speak 則進到 Derived::speak。透過 const Base& 參考呼叫也一樣。
純虛擬函式、抽象類別與 virtual 解構子
有些基底類別本身沒辦法給出合理的實作,例如「形狀」的面積怎麼算?要看是哪一種形狀。這時可以寫成純虛擬函式:
class Shape {
public:
virtual double area() const = 0; // = 0:純虛擬函式,沒有實作
virtual ~Shape() { } // virtual 解構子
};
有純虛擬函式的類別叫抽象類別(abstract class),不能建立物件:Shape s; 和 new Shape() 都會編譯錯誤。
它的用途是規定「所有衍生類別都必須提供 area()」,再透過 Shape* 或 Shape& 統一使用它們。
衍生類別如果沒有覆寫所有純虛擬函式,自己也還是抽象類別。
基底類別的解構子要加 virtual:用 delete 刪除一個「透過基底類別指標指向的衍生類別物件」時,
如果解構子不是 virtual,C++ 標準規定這是未定義行為;常見的結果是只執行基底的解構子,衍生類別的解構子被跳過,
它負責釋放的資源就漏掉了。規則很簡單:類別裡只要有 virtual 函式,解構子就加上 virtual。
多型:一個陣列裝不同的形狀
shapes 是 Shape* 的陣列,兩個元素分別指向堆積上的 Rect 和 Circle。迴圈裡都是同一行 shapes[i]->area(),但 i 是 0 時進到 Rect::area、i 是 1 時進到 Circle::area。delete 的時候,因為 ~Shape 是 virtual,會先執行衍生類別的解構子,再執行 ~Shape。
想一想:繼承與多型
下面的程式會印出什麼?
class A {
public:
void f() { cout << "A"; }
virtual void g() { cout << "a"; }
};
class B : public A {
public:
void f() { cout << "B"; }
void g() override { cout << "b"; }
};
int main() {
B b;
A* p = &b;
p->f();
p->g();
b.f();
}
Shape 有純虛擬函式 virtual double area() const = 0;,Rect 繼承 Shape 並覆寫了 area()。下列哪一行可以編譯?
基底類別有 virtual void speak() const;,衍生類別不小心寫成 void speek() const override;(拼錯字)。會發生什麼事?
動手寫:購物車裡的飲料
Item 是抽象類別,Book 已經寫好(書打九折)。請寫出 Drink 類別:繼承 Item,建構子接收名稱、單價、杯數,price() 回傳「單價 × 杯數」。main 已經寫好,完成後執行結果要和預期相同。
重點回顧
- 類別把資料(成員變數)和操作(成員函式)包在一起;物件是類別的實體,每個物件有自己的一份成員變數
private成員只有類別自己能用,外面透過public成員函式操作;class預設private、struct預設public- 成員函式靠隱藏的
this指標知道在處理哪個物件;不修改物件的成員函式加上const - 建構子在物件建立時執行,用初始化列表設定成員;解構子在物件生命結束時執行,區域物件依建立的相反順序解構
- 衍生類別擁有基底類別的成員;建構時先基底後衍生,解構時相反;
protected讓衍生類別可以使用 virtual函式依物件實際型別決定執行哪個版本,透過基底類別的指標或參考呼叫時才看得出差別;覆寫時寫override- 有純虛擬函式(
= 0)的是抽象類別,不能建立物件;有 virtual 函式的基底類別,解構子也要是 virtual