學習目標

讀完本章後,你應該能夠:

  1. 理解類別(class)與結構體(struct)的差異,以及「物件」的本質
  2. 說明封裝(encapsulation)的哲學與實際好處
  3. 掌握存取修飾詞:publicprivateprotected
  4. 定義成員變數與成員函式,並理解 const 成員函式為何重要
  5. 深入理解 this 指標的用途與機制
  6. 學會使用 static 成員變數與成員函式(共享狀態)
  7. 理解類別不變式(invariant)的概念,並用它指導 getter/setter 設計
  8. 學會將類別宣告與定義分離(header / source)
  9. 認識 inline 成員函式與 mutable 關鍵字

本章以一個貫穿全章的 BankAccount(銀行帳戶)為例,逐步示範類別的各種特性。


一、從 struct 到 class:為什麼需要封裝?(WHY)

上一章的 struct 把資料聚合在一起,但任何人都能隨意修改成員:

struct BankAccount {
    std::string owner;
    double balance;
};

BankAccount acc{"小明", 1000};
acc.balance = -999999;   // 😱 餘額變負的,沒有任何阻擋!

這違反了帳戶「餘額不可為負」的規則。我們希望有個機制能保護資料的正確性——這就是封裝

封裝的核心想法: - 把資料設為 private,外部無法直接亂改。 - 只透過公開的成員函式(如 depositwithdraw)操作資料,並在函式內驗證。 - 這樣物件就能始終維持在「合法狀態」。

class BankAccount {
private:
    std::string owner;
    double balance;     // 外部碰不到,只能透過方法操作

public:
    BankAccount(const std::string& owner, double initial)
        : owner(owner), balance(initial > 0 ? initial : 0.0) {}

    bool withdraw(double amount) {
        if (amount <= 0 || amount > balance) return false;   // 守門員
        balance -= amount;
        return true;
    }
    double getBalance() const { return balance; }
};

現在 acc.balance = -999999; 會直接編譯錯誤——資料被保護了。

封裝帶來的三大好處:

好處 說明
資料驗證 setter / 方法內檢查輸入,確保物件永遠合法
隱藏實作 外部不需知道內部如何儲存(攝氏?華氏?)
維護彈性 修改內部實作不影響使用該類別的程式碼

二、類別(class)基礎

2.1 class 與 struct 的差異

struct PointS {
    double x;  // 預設 public
    double y;
};

class PointC {
    double x;  // 預設 private
    double y;
public:
    PointC(double x, double y) : x(x), y(y) {}
    double getX() const { return x; }
    double getY() const { return y; }
};
項目 struct class
預設存取權限 public private
預設繼承方式 public private
慣例 無不變式的資料載體 封裝資料 + 行為 + 不變式
語言功能 完全相同 完全相同

兩者唯一的語言差異是預設存取權限。慣例上:需要保護不變式時用 class,純資料聚合用 struct

2.2 類別、物件與實例

  • 類別(class) 是「藍圖」:描述一種型別有哪些資料與行為。
  • 物件(object)/ 實例(instance) 是依藍圖建立出來的「實體」。
BankAccount acc1("小明", 1000);   // acc1 是一個物件
BankAccount acc2("小華", 500);    // acc2 是另一個物件,有自己的 owner/balance

acc1acc2 各自擁有獨立的成員資料,但共用同一份成員函式程式碼。

2.3 類別的組成

class BankAccount {
private:
    // 成員變數(資料 / 狀態)
    std::string owner;
    double balance;

public:
    // 建構子(下一章詳述)
    BankAccount(const std::string& owner, double initialBalance)
        : owner(owner), balance(initialBalance) {}

    // 成員函式(方法 / 行為)
    void deposit(double amount) {
        if (amount > 0) balance += amount;
    }
    double getBalance() const { return balance; }
};

三、存取修飾詞

3.1 三種存取層級

修飾詞 類別內部 衍生類別 外部程式
public
protected
private

視覺化:

        ┌─────────────────────────────────────┐
        │            class BankAccount         │
        │  ┌───────────────────────────────┐   │
        │  │ private: owner, balance       │   │  ← 只有類別內部看得到
        │  └───────────────────────────────┘   │
        │  ┌───────────────────────────────┐   │
        │  │ protected: ...                │   │  ← 類別 + 衍生類別
        │  └───────────────────────────────┘   │
        │  ┌───────────────────────────────┐   │
        │  │ public: deposit(), getBalance │   │  ← 對外開放的介面
        │  └───────────────────────────────┘   │
        └─────────────────────────────────────┘
                       ▲
                       │ 外部程式只能透過 public 介面互動

3.2 最小權限原則

指引(C++ Core Guidelines C.9):盡量縮小成員的可見範圍。預設把資料成員設為 private,只把「使用者真正需要的介面」設為 public

class Temperature {
private:
    double celsius;   // 內部只存攝氏

public:
    void setCelsius(double c) {
        if (c >= -273.15) celsius = c;   // 絕對零度檢查(維持不變式)
    }
    double getCelsius() const { return celsius; }
    double getFahrenheit() const { return celsius * 9.0 / 5.0 + 32.0; }
};

注意:外部完全不知道 Temperature 內部是用攝氏儲存的——它也可以改用華氏儲存,只要 getter/setter 介面不變,使用者的程式碼完全不用改。這就是隱藏實作的價值。


四、類別不變式(Invariant)

不變式是「物件在任何時刻都必須成立的條件」。它是「為什麼要封裝」的理論核心。

  • BankAccountbalance >= 0
  • Date1 <= month <= 12
  • Fractiondenominator != 0

封裝的真正目的,就是保護不變式。 流程是: 1. 建構子建立物件時,確保不變式一開始就成立。 2. 所有會修改狀態的 public 方法,結束前都必須維持不變式。 3. 把資料設 private,讓外部無法繞過方法破壞不變式。

class Fraction {
private:
    int numerator;
    int denominator;   // 不變式:denominator != 0

public:
    Fraction(int n, int d) : numerator(n), denominator(d) {
        if (d == 0) throw std::invalid_argument("分母不可為 0");
    }
    void setDenominator(int d) {
        if (d == 0) throw std::invalid_argument("分母不可為 0");
        denominator = d;
    }
};

若一個 struct 的成員之間沒有任何約束(可以各自獨立任意改變),那它就沒有不變式,用 struct 即可、不需要封裝(Core Guidelines C.2)。


五、成員函式與 const

5.1 const 成員函式

在成員函式後加 const,表示該函式承諾不會修改物件的狀態

class Circle {
private:
    double radius;

public:
    Circle(double r) : radius(r) {}

    double area() const { return 3.14159265 * radius * radius; }  // 只讀
    void setRadius(double r) { radius = r; }                       // 會改
};

為什麼 const 成員函式重要?

  1. 正確性:明確表達「這個方法只讀」,編譯器會在你不小心修改成員時報錯。
  2. 可用於 const 物件const 物件(或 const& 參數)只能呼叫 const 成員函式。
const Circle c(5.0);
c.area();        // ✓ area() 是 const
// c.setRadius(3.0);  // ✗ 編譯錯誤!const 物件不可呼叫非 const 方法

void printArea(const Circle& c) {
    std::cout << c.area();   // 因為參數是 const&,只能呼叫 const 方法
}

指引(C++ Core Guidelines Con.2):預設把成員函式宣告為 const,除非它需要修改物件的可觀察狀態。這稱為「const correctness」,是寫出健壯 C++ 的關鍵習慣。

5.2 mutable 關鍵字(補充)

有時某個成員「在邏輯上不算物件狀態」(如快取、計數器),即使在 const 方法中也想修改它。此時可把該成員標為 mutable

class DataSet {
private:
    std::vector<int> data;
    mutable bool cached = false;     // 即使在 const 方法中也可修改
    mutable double cachedMean = 0.0;

public:
    double mean() const {            // const 方法,但會更新快取
        if (!cached) {
            cachedMean = computeMean();
            cached = true;
        }
        return cachedMean;
    }
private:
    double computeMean() const { /* ... */ return 0.0; }
};

mutable 應謹慎使用,僅限「不影響物件對外可觀察狀態」的內部最佳化(如快取、mutex)。


六、this 指標

this 是一個隱含的指標,存在於每個(非 static)成員函式中,指向呼叫該函式的物件本身

6.1 機制

當你寫 acc.deposit(100),編譯器實際上把 acc 的位址當作隱藏參數傳入,在 deposit 內就是 this。所以 balance += amount; 其實是 this->balance += amount;

6.2 常見用途

用途 1:區分同名的參數與成員

class Student {
    std::string name;
public:
    Student(const std::string& name) {
        this->name = name;   // this->name 是成員,name 是參數
    }
};

用途 2:回傳自身參考以支援鏈式呼叫(method chaining)

class Student {
    std::string name;
    int score;
public:
    Student& setName(const std::string& n) { name = n; return *this; }
    Student& setScore(int s) { score = s; return *this; }
};

Student s;
s.setName("小明").setScore(95);   // 鏈式呼叫

回傳 *this(解參考後是物件本身)讓每次呼叫都能接著呼叫下一個方法。


七、static 成員

7.1 static 成員變數:共享狀態

一般成員變數是「每個物件各一份」。static 成員變數屬於類別本身,所有物件共享同一份

class Student {
private:
    static int count;   // 宣告(在類別內)— 所有 Student 共用

public:
    Student() { ++count; }
    ~Student() { --count; }
    static int getCount() { return count; }
};

int Student::count = 0;   // 定義(在類別外,整個程式只能定義一次)

記憶體示意:

   物件 s1            物件 s2            物件 s3
  ┌────────┐        ┌────────┐        ┌────────┐
  │ name   │        │ name   │        │ name   │   ← 各自一份
  └────────┘        └────────┘        └────────┘
        \               |               /
         \              |              /
          \             ▼             /
        ┌──────────────────────────────┐
        │  static int count   (共享)  │   ← 全部物件共用一份
        └──────────────────────────────┘

何時使用 static 成員變數? - 計數所有實例數量(如上例)。 - 全類別共用的常數或設定。 - 共享的資源池、ID 產生器等。

7.2 static 成員函式

Student::getCount();   // 不需要任何物件即可呼叫

特性: - 不需透過物件即可呼叫:ClassName::func()。 - 沒有 this 指標,因此無法存取非 static 成員。 - 只能存取 static 成員變數與其他 static 成員函式。

7.3 static 常數成員

class Circle {
    static constexpr double PI = 3.14159265358979;   // C++11,可在類別內直接初始化
public:
    double area(double r) const { return PI * r * r; }
};

static constexpr 成員可直接在類別內初始化,是定義類別層級常數的慣用做法。


八、inline 成員函式

直接寫在類別定義「內」的成員函式,會被隱式視為 inline

class Circle {
    double radius;
public:
    double area() const { return 3.14159 * radius * radius; }  // 隱式 inline
};

inline 的意義不只是「建議編譯器把函式展開」,更重要的是它允許同一個函式定義出現在多個翻譯單元(.cpp)而不違反 ODR(單一定義規則)——這正是為什麼可以把成員函式定義放在標頭檔。


九、類別宣告與定義分離(Header / Source)

對於較大的類別,慣例上把宣告放在 .h(介面),實作放在 .cpp(定義)。這樣可加快編譯、隱藏實作細節。

// BankAccount.h
#pragma once
#include <string>

class BankAccount {
private:
    std::string owner;
    double balance;

public:
    BankAccount(const std::string& owner, double initial);
    bool deposit(double amount);
    bool withdraw(double amount);
    double getBalance() const;
};
// BankAccount.cpp
#include "BankAccount.h"

BankAccount::BankAccount(const std::string& owner, double initial)
    : owner(owner), balance(initial > 0 ? initial : 0.0) {}

bool BankAccount::deposit(double amount) {
    if (amount <= 0) return false;
    balance += amount;
    return true;
}

bool BankAccount::withdraw(double amount) {
    if (amount <= 0 || amount > balance) return false;
    balance -= amount;
    return true;
}

double BankAccount::getBalance() const {
    return balance;
}
// main.cpp
#include "BankAccount.h"
#include <iostream>

int main() {
    BankAccount acc("小明", 1000);
    acc.deposit(500);
    std::cout << acc.getBalance() << std::endl;   // 1500
}

重點: - 在 .cpp 中定義成員函式時,要加上 ClassName:: 限定符。 - 標頭檔要用 #pragma once(或 include guard)避免重複引入。 - 將實作藏在 .cpp 後,修改實作不需重新編譯所有引用者(只要介面不變)。


十、getter / setter 設計

不是所有成員都需要 getter/setter。好的設計應以「物件提供什麼有意義的操作」為出發點,而非機械式地為每個成員加一對存取器。

做法 評價
為每個成員無腦加 public getter+setter ✗ 等於放棄封裝,只是把 public 成員包了一層
只提供讀取(getter)、不提供 setter ✓ 適合建構後不該改的資料
setter 內含驗證以維持不變式 ✓ 封裝的正確用法
提供「有意義的行為方法」取代裸 setter ✓✓ 最佳:deposit()setBalance() 更能保護不變式
// ✗ 不好:setBalance 等於讓外部隨意改餘額
void setBalance(double b) { balance = b; }

// ✓ 好:用有語意的操作,內含規則
bool deposit(double amount) { if (amount<=0) return false; balance += amount; return true; }

重點整理

概念 說明
class vs struct 僅預設存取權限不同;有無不變式決定慣例選擇
物件 / 實例 依類別藍圖建立的實體,各有獨立成員資料
封裝 隱藏資料、透過方法操作,保護不變式
public / private / protected 由寬到嚴的存取層級
不變式 物件任何時刻都必須成立的條件
this 指向目前物件的指標;可用於鏈式呼叫 return *this;
const 成員函式 承諾不修改狀態;const 物件只能呼叫它
mutable 允許在 const 方法中修改的成員(如快取)
static 成員變數 所有物件共享,屬於類別而非物件
static 成員函式 this,可用 ClassName::func() 呼叫
inline 成員函式 類別內定義的函式隱式 inline,可放標頭檔
header / source 分離 .h 放宣告、.cpp 放定義,需 ClassName:: 限定

常見錯誤與陷阱

  1. 忘記初始化成員變數:未初始化的成員值不確定,應在建構子(初始化列表)中初始化。

  2. const 物件呼叫非 const 方法const 物件只能呼叫 const 成員函式;忘記在只讀方法加 const 會在傳 const& 時編譯錯誤。

  3. static 成員變數忘記在類別外定義:只在類別內 static int count; 是宣告,必須在某個 .cppint ClassName::count = 0; 定義,否則連結錯誤(undefined reference)。

  4. 在 static 函式中使用 this 或存取非 static 成員:static 函式沒有 this,做不到。

  5. 過度使用 public:把所有成員設 public 等於放棄封裝,違反最小權限原則。

  6. 無腦為每個成員加 getter/setter:這只是換個方式公開資料,並未真正封裝;應提供有語意的行為方法。

  7. 在標頭檔定義非 inline 的自由函式或變數:會違反 ODR 造成重複定義;成員函式因隱式 inline 無此問題。

  8. 回傳內部成員的非 const 參考:等於把 private 成員洩漏出去,外部可繞過驗證修改它。


練習題

以下練習由淺入深。建議搭配 class_basics.cppaccess_control.cpp 對照學習。

練習 1(基礎):Counter 類別

實作一個 Counter 類別,成員 value 設為 private,初始值為 0。提供 increment()decrement()int getValue() const提示getValue 只讀資料,記得加 const

練習 2(基礎):封裝與驗證

設計一個 Temperature 類別,內部以攝氏儲存(private)。提供 setCelsius()(需檢查 ≥ -273.15)、getCelsius() constgetFahrenheit() const。測試傳入非法值時是否被擋下。 提示:華氏 = 攝氏 × 9/5 + 32,注意用浮點數運算。

練習 3(中級):this 與鏈式呼叫

設計一個 Pizza 類別(builder 風格),用鏈式呼叫設定屬性:setSize().addTopping().setCrust(),每個 setter 回傳 Pizza&,最後用 describe() const 印出完整內容。 提示:每個 setter 的最後 return *this;

練習 4(中級):static 成員計數

設計一個 Connection 類別,用 static 成員 activeCount 追蹤目前活躍連線數。建構子 +1、解構子 -1,並限制最大連線數為 10(超過時建構失敗或拒絕)。提供 static int getActiveCount()提示:別忘了在類別外定義 int Connection::activeCount = 0;

練習 5(中級):BankAccount 強化

延伸本章的 BankAccount:加入 transfer(BankAccount& to, double amount) 在兩個帳戶間轉帳,並維持「餘額不可為負」的不變式。再加一個 static 成員追蹤所有帳戶的總交易筆數。 提示transfer 可內部呼叫自己的 withdraw 與對方的 deposit,依結果決定是否成功。

練習 6(挑戰):Header / Source 分離

把練習 5 的 BankAccount 拆成 BankAccount.h(宣告)、BankAccount.cpp(定義)、main.cpp(測試)三個檔案,並用 g++ -std=c++17 -Wall BankAccount.cpp main.cpp -o bank 編譯成功。 提示:標頭檔加 #pragma once.cpp 中每個方法前加 BankAccount::

練習 7(挑戰):圖書館系統

設計一個 Library 系統:Book 類別(書名、ISBN、是否借出,全部 private),提供 borrow()returnBook()(維持「不能重複借出」的不變式),並用 static 成員追蹤全館總借閱次數。再寫一個簡單的選單迴圈操作數本書。 提示borrow() 要先檢查 isBorrowed;總借閱次數是跨所有 Book 共享的,適合用 static


對應程式碼檔案

  • class_basics.cpp — 類別基礎、BankAccount、this 指標、鏈式呼叫、const 成員函式
  • access_control.cpp — 存取控制、封裝與驗證、static 成員、純 static 工具類別