學習目標
讀完本章後,你應該能夠:
- 理解類別(class)與結構體(struct)的差異,以及「物件」的本質
- 說明封裝(encapsulation)的哲學與實際好處
- 掌握存取修飾詞:
public、private、protected - 定義成員變數與成員函式,並理解
const成員函式為何重要 - 深入理解
this指標的用途與機制 - 學會使用
static成員變數與成員函式(共享狀態) - 理解類別不變式(invariant)的概念,並用它指導 getter/setter 設計
- 學會將類別宣告與定義分離(header / source)
- 認識
inline成員函式與mutable關鍵字
本章以一個貫穿全章的
BankAccount(銀行帳戶)為例,逐步示範類別的各種特性。
一、從 struct 到 class:為什麼需要封裝?(WHY)
上一章的 struct 把資料聚合在一起,但任何人都能隨意修改成員:
struct BankAccount {
std::string owner;
double balance;
};
BankAccount acc{"小明", 1000};
acc.balance = -999999; // 😱 餘額變負的,沒有任何阻擋!
這違反了帳戶「餘額不可為負」的規則。我們希望有個機制能保護資料的正確性——這就是封裝。
封裝的核心想法:
- 把資料設為 private,外部無法直接亂改。
- 只透過公開的成員函式(如 deposit、withdraw)操作資料,並在函式內驗證。
- 這樣物件就能始終維持在「合法狀態」。
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
acc1 和 acc2 各自擁有獨立的成員資料,但共用同一份成員函式程式碼。
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)
不變式是「物件在任何時刻都必須成立的條件」。它是「為什麼要封裝」的理論核心。
BankAccount:balance >= 0Date:1 <= month <= 12Fraction:denominator != 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 成員函式重要?
- 正確性:明確表達「這個方法只讀」,編譯器會在你不小心修改成員時報錯。
- 可用於 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:: 限定 |
常見錯誤與陷阱
-
忘記初始化成員變數:未初始化的成員值不確定,應在建構子(初始化列表)中初始化。
-
const 物件呼叫非 const 方法:
const物件只能呼叫const成員函式;忘記在只讀方法加const會在傳const&時編譯錯誤。 -
static 成員變數忘記在類別外定義:只在類別內
static int count;是宣告,必須在某個.cpp中int ClassName::count = 0;定義,否則連結錯誤(undefined reference)。 -
在 static 函式中使用 this 或存取非 static 成員:static 函式沒有
this,做不到。 -
過度使用 public:把所有成員設
public等於放棄封裝,違反最小權限原則。 -
無腦為每個成員加 getter/setter:這只是換個方式公開資料,並未真正封裝;應提供有語意的行為方法。
-
在標頭檔定義非 inline 的自由函式或變數:會違反 ODR 造成重複定義;成員函式因隱式 inline 無此問題。
-
回傳內部成員的非 const 參考:等於把 private 成員洩漏出去,外部可繞過驗證修改它。
練習題
以下練習由淺入深。建議搭配
class_basics.cpp、access_control.cpp對照學習。
練習 1(基礎):Counter 類別
實作一個 Counter 類別,成員 value 設為 private,初始值為 0。提供 increment()、decrement()、int getValue() const。
提示:getValue 只讀資料,記得加 const。
練習 2(基礎):封裝與驗證
設計一個 Temperature 類別,內部以攝氏儲存(private)。提供 setCelsius()(需檢查 ≥ -273.15)、getCelsius() const、getFahrenheit() 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 工具類別