National Yunlin University of Science and Technology
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

雲林科技大學 AQI 56 27°C PM2.5 10
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 10

物件導向基礎

類別與物件、建構子與解構子、繼承與多型
Interaction實作 30 minute(s)

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 時;忘了寫就不會解構,而且記憶體洩漏

想一想:封裝與生命週期

1.

下面的程式能不能編譯?如果能,會印出什麼?

class Q {
    int x = 1;
};

int main() {
    Q q;
    cout << q.x;
}
2.

下面的程式會印出什麼?

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');
}
3.

下面的程式能不能編譯?如果能,會印出什麼?

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);
}
第 1 次作答

繼承

很多類別有共同的部分。狗和貓都是動物,都有名字、都會吃東西,但狗會撿球、貓會抓老鼠。 繼承(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) 指定要用基底的哪個建構子。順序是:

  1. 基底類別的建構子
  2. 衍生類別的成員初始化
  3. 衍生類別建構子的本體

解構的順序剛好相反:先衍生類別的解構子,最後才是基底類別的。

繼承與建構、解構順序

看 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。

想一想:繼承與多型

1.

下面的程式會印出什麼?

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();
}
2.

Shape 有純虛擬函式 virtual double area() const = 0;,Rect 繼承 Shape 並覆寫了 area()。下列哪一行可以編譯?

3.

基底類別有 virtual void speak() const;,衍生類別不小心寫成 void speek() const override;(拼錯字)。會發生什麼事?

第 1 次作答

動手寫:購物車裡的飲料

Item 是抽象類別,Book 已經寫好(書打九折)。請寫出 Drink 類別:繼承 Item,建構子接收名稱、單價、杯數,price() 回傳「單價 × 杯數」。main 已經寫好,完成後執行結果要和預期相同。

你的輸出
預期輸出

重點回顧

  • 類別把資料(成員變數)和操作(成員函式)包在一起;物件是類別的實體,每個物件有自己的一份成員變數
  • private 成員只有類別自己能用,外面透過 public 成員函式操作;class 預設 private、struct 預設 public
  • 成員函式靠隱藏的 this 指標知道在處理哪個物件;不修改物件的成員函式加上 const
  • 建構子在物件建立時執行,用初始化列表設定成員;解構子在物件生命結束時執行,區域物件依建立的相反順序解構
  • 衍生類別擁有基底類別的成員;建構時先基底後衍生,解構時相反;protected 讓衍生類別可以使用
  • virtual 函式依物件實際型別決定執行哪個版本,透過基底類別的指標或參考呼叫時才看得出差別;覆寫時寫 override
  • 有純虛擬函式(= 0)的是抽象類別,不能建立物件;有 virtual 函式的基底類別,解構子也要是 virtual
這一課完成了!
Next lesson:標準模板庫
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。