class objobjobj
PREREQ P8

物件導向(基礎):資料+操作打包成一個東西

先備知識 · 類別與物件
class|建構式|this|public / private|const|operator<<|組合
向下捲動開始互動
📌 本頁使用方式(先備知識 · 類別與物件)

① 這頁從類別語法開始,逐步建立物件、建構式、封裝、組合與檔案拆分的觀念。已經熟的可以直接跳過。 ② 每節都附一個「這個語法解決什麼問題」的小方框,讀完要能用自己的話解釋。 ③ 讀完做 自我檢測,再翻 關鍵詞彙卡
可依熟悉程度選讀,先確認能讀懂每個例子再往下。

CONTENTS · 內容目錄
PROLOGUE · 開場

把狀態與操作放在同一個型別裡

例如一筆測量有數值,也有讀取與更新這個數值的操作。class 讓我們定義這樣的型別;建立物件後,每個物件保存自己的狀態。這頁先從一個 int 成員開始,再逐步加入存取控制、建構式與輸出介面。

「抽象資料型別」從使用者能儲存什麼、能做什麼操作來描述一種資料,不先限定內部實作。C++ 類別是表達這類介面的一種工具;不是每種資料結構都必須以一個類別實作。下表保留兩種觀點的對照。

抽象資料型別(ADT)的說法C++ 的說法例子
這個結構「存什麼」成員變數(member variable)int value;
這個結構「能做什麼」成員函式(member function)getData() / setData()
結構的規格(介面)class 定義class Reading { … };
實際被建立出來的一個結構物件/實例Reading room(18);
💡 這一頁的目標

讀完應能自行寫出 Reading 與 ReadingLog,說明物件何時初始化、private 限制哪些存取,以及如何用公開介面讀值、更新與輸出。

📌 本頁定位

建議先熟悉 P3 的參數傳遞與 P4 的指標,再讀本頁類別語法;P9 會接著介紹繼承與模板。

PART 01 · 類別與物件

藍圖與成品 四個必背詞

類別是藍圖,物件是照著藍圖蓋出來的東西。藍圖只有一份,蓋出來的房子可以有很多間,每一間的門牌、住戶各自不同。

最小可用的 Reading 類別
#include <iostream> using namespace std; class Reading { // 藍圖 private: int value; // 成員變數:每個物件各有一份 public: Reading(int initialValue) { // 建構式:建立物件時自動執行 value = initialValue; } int getData() const { return value; } // const:不透過此函式修改一般成員 }; int main() { Reading n(93); // 一個物件(實例) cout << "n.getData() = " << n.getData() << endl; return 0; }
預期輸出
n.getData() = 93

Reading n(93); 建立一個物件並自動執行建構式。每個 Reading 物件各自保存 value;修改其中一個物件不會改到另一個。

🔗 每個物件有自己的狀態

Reading 把測量值與操作包在一起。類別定義只有一份,但每個 instance 都有自己的 value。

PART 02 · 建構式

建構式與 this

建構式:跟類別同名、沒有回傳型別

你寫 Reading n(93); 的時候,C++ 先配置一塊記憶體,然後自動呼叫 Reading(93) 把它填好。所以建構式裡做的事就是「這個物件一出生要帶著哪些資料」。

它有兩個外觀特徵:名字跟類別一模一樣,而且沒有回傳型別(連 void 都不寫)。

this:指向目前物件的指標

在非靜態成員函式中,this 指向目前執行該函式的物件。例如以 obj.getData() 呼叫時,this 對應 &obj;它不是指向呼叫端函式。成員沒有被同名區域變數遮蔽時,可以直接寫 value,或明寫 this->value。

class Reading { private: int value; public: Reading(int value) { this->value = value; // 左邊是成員,右邊是參數,同名時要用 this 分辨 } int getData() const { return value; // 等同 this->value,沒有同名問題就不用寫 } };

冒號後面的成員初始化列表

Reading(int initial) : value(initial) {}initial 用作成員 value 的初值。冒號後、函式本體前的部分叫成員初始化列表(member initializer list)。它先初始化成員,再執行大括號中的敘述;先前的 this->value = value; 則是在本體內賦值。這個 int 範例兩種方式可得到相同數值,之後有 const 或參考成員時,初始化列表尤其重要。

💡 跟 Python 的 self 差在哪

Python 的 self明寫在參數列裡的第一個參數;C++ 的 this編譯器隱含傳進來的指標,不寫在參數列裡,而且因為是指標所以用 -> 存取。兩者角色一樣,但 C++ 這邊你平常根本不會看到它。

PART 03 · 存取控制

publicprivate:C++ 靠編譯器擋,不靠約定

private 成員從外面碰不到(真的編不過)
#include <iostream> using namespace std; class Reading { private: int value; public: Reading(int d) { value = d; } int getData() const { return value; } }; int main() { Reading n(93); cout << n.getData() << endl; // 可以:透過公開介面 cout << n.value << endl; // 不行:value 是 private return 0; }
預期輸出
[編譯失敗]
cell.cpp: In function ‘int main()’:
cell.cpp:15:15: error: ‘int Reading::value’ is private within this context
   15 |     cout << n.value << endl;        // 不行:value 是 private

private 由 C++ 編譯器檢查,類別外不能直接讀取這裡的 value。若熟悉 Python,可把底線命名的慣例與這種強制存取控制作比較;不需先學 Python,也能直接從成功/失敗程式看出差別。

區段誰能存取通常放什麼
public:任何人建構式、對外的操作(介面)
private:這個類別的成員與 friend(後面介紹)資料成員、內部輔助函式
protected:自己 + 子類別要給子類別用的東西(見 P9
⚠️ class 預設是 private

沒寫存取區段的話,class 裡的成員預設是 privatestruct 則預設 public,這是兩者主要的差別)。所以建構式忘了放在 public: 底下,就會出現「明明有建構式卻說不能建立物件」的錯誤。另一個差別是預設繼承層級:class 預設 private 繼承、struct 預設 public,所以 P9 一律寫 : public

PART 04 · 封裝

隱藏狀態或公開資料:依類別的需求選擇

這一節值得慢慢看,因為它是真實世界的取捨,不是參考資料的標準答案。

兩個類別可以依各自的不變條件採取不同的存取設計:

Reading 全包起來
class Reading { private: int value = 0; public: int getData() const { return value; } void setData(int newValue) { value = newValue; } };
DisplayPoint 全公開
class DisplayPoint { public: double x; double y; DisplayPoint(double initX, double initY) { x = initX; y = initY; } };
💡 為什麼可以不一樣

Reading 透過 getter/setter 集中處理讀寫,之後若要加入驗證或記錄修改,也有一致入口。DisplayPoint 只是一起傳遞座標,若沒有額外不變條件,公開成員也可能合理。封裝是控制使用者依賴的介面,不是單純替每個欄位機械地加上 getter/setter。

隨堂 1 · 該不該包起來

ReadingLog 以 vector 保存讀數,並希望使用者只透過 add、size、average 操作。內部 vector 應放在哪個存取區段?

(A) private,只留 add/size/average 對外
(B) public,這樣比較方便
(C) 都可以,沒有差別
(D) protected,讓所有呼叫端直接修改
🔗 封裝不是固定公式

Reading 透過 getter/setter 控制存取;有些單純資料型別則會公開成員。判準是外部直接修改狀態是否可能破壞類別承諾的不變條件。

PART 05 · const 成員函式

int getData() const:成員函式的唯讀承諾

const 成員函式透過 this 讀取一般成員,讓唯讀物件也能使用查詢介面。Reading 的 getter 加上 const,用來讀取 value;setter 會修改 value,因此不加 const。

class Reading { int value = 0; public: int getData() const { return value; } void setData(int d) { value = d; } }; // 假設已引入 <iostream>: void printIt(const Reading& sample) { std::cout << sample.getData(); // sample.setData(5); // const 物件不能呼叫此 setter }
✅ 為什麼值得養成習慣

因為 const 物件只能呼叫 const 成員函式。傳大物件時用 const T& 可避免複製並限制修改(見 P3);相應的查詢函式必須標成 const,否則唯讀物件無法呼叫。

備註:何時替成員函式加上 const

判斷方式直接看函式承諾:查詢狀態且不修改成員的函式應標成 const;會更新狀態的函式則不能加。這讓同一組查詢介面也能安全用在唯讀物件與 const T& 參數上。

getter 有 const,const 參考才用得下去
#include <iostream>
class Reading {
    int value;
public:
    Reading(int initial) : value(initial) {}
    int getData() const { return value; }
};
void show(const Reading& sample) {
    std::cout << sample.getData() << "\n";
}
int main() { Reading sample(18); show(sample); }
 
預期輸出
18
把 getter 的 const 拿掉,同一支程式就編不過
class Reading {
    int value;
public:
    Reading(int initial) : value(initial) {}
    int getData() { return value; } // 只拿掉 const
};
void show(const Reading& sample) {
    std::cout << sample.getData(); // 預期編譯錯誤
}
 

discards qualifiers 表示程式嘗試用唯讀物件呼叫可能修改狀態的函式。

PART 06 · 多載

同名不同參數:Timer 的三種建立方式

C++ 允許同一個名字有多個版本,只要參數列可區分即可。編譯器會依呼叫時提供的引數個數與型別挑選最合適的版本。

class Timer { public: Timer(int initial, int step) { initialValue = initial; stepValue = step; } // 兩個引數 Timer(int initial) { initialValue = initial; stepValue = 1; } // 省略步幅時使用 1 Timer() { initialValue = 0; stepValue = 1; } // 都沒給:從 0 起、步幅 1 private: int initialValue, stepValue; };
三個建構式各自被挑中(完整 Timer 定義接在 main 前)
#include <iostream> using namespace std; class Timer { int initialValue, stepValue; public: Timer(int initial, int step) : initialValue(initial), stepValue(step) {} Timer(int initial) : initialValue(initial), stepValue(1) {} Timer() : initialValue(0), stepValue(1) {} int initial() const { return initialValue; } int step() const { return stepValue; } }; int main() { Timer a(3, 4), b(5), c; cout << "a: " << a.initial() << ", " << a.step() << endl; cout << "b: " << b.initial() << ", " << b.step() << endl; cout << "c: " << c.initial() << ", " << c.step() << endl; }
預期輸出
a: 3, 4
b: 5, 1
c: 0, 1

Timer a(3, 4) 選兩參數版本,b(5) 選單參數版本,c 選無參數版本。這裡透過已學過的公開 getter 印出初值與步幅,稍後再教如何替整個物件定義 operator<<。

複製是有成本的,而且成本看得見
#include <iostream>
using namespace std;
class Big {
public:
    Big(int id) : tag(id) { cout << "  [建構] Big " << tag << '\n'; }
    Big(const Big &other) : tag(other.tag) {
        cout << "  [複製] 又抄了一份 Big " << tag << " ← 這行就是成本\n";
    }
    int getTag() const { return tag; }
private:
    int tag;
};
void byValue(Big b) { cout << "  函式裡看到 " << b.getTag() << '\n'; }
void byConstRef(const Big &b) { cout << "  函式裡看到 " << b.getTag() << '\n'; }
int main() {
    Big item(7);
    cout << "呼叫 byValue(item):\n"; byValue(item);
    cout << "呼叫 byConstRef(item):\n"; byConstRef(item);
}
預期輸出
  [建構] Big 7
呼叫 byValue(item):
  [複製] 又抄了一份 Big 7 ← 這行就是成本
  函式裡看到 7
呼叫 byConstRef(item):
  函式裡看到 7

byValue(item) 觸發一次複製建構;byConstRef(item) 直接讀原物件而不複製。

編譯器怎麼挑:一個一個比參數列

「編譯器會依你傳的引數挑一個」這句話講得太快了。它實際上做三件事:先把同名的建構式全抓出來當候選,再逐一比對參數列,個數或型別對不上就排除,最後從符合條件的裡面挑一個。下面的逐步器把這個過程放慢給你看。

本例的 Timer 可使用編譯器隱式宣告的複製建構式,將兩個 int 成員複製到新物件。這不表示所有類別都永遠能複製:某些成員或其他特殊成員函式的宣告,會讓隱式複製建構式被定義為 deleted。

呼叫端:(按下面任一顆按鈕開始)
 
#1 Timer() // 候選 A:不收引數 #2 Timer(int initial) // 候選 B:1 個 int #3 Timer(int initial, int step) // 候選 C:2 個 int #4 Timer(const Timer &other) // 候選 D:複製建構式(自動生成)
選一種呼叫寫法開始。四個候選的名字一模一樣,選擇依據是括號裡那一段。
速度
看簽名,不看名字 KEY

能區分多載的只有參數列:型別、個數、順序。名字全部相同,回傳型別也不算在內 —— 兩個函式只差回傳型別的話,那不是多載,是重複定義,直接編不過。

Timer g = f; 與 Timer g(f); 用已存在的同型別物件初始化新物件,這裡會複製成員。以左值物件傳入值參數也可能複製;回傳值則還可能移動或省略複製,不能說每次回傳都必定呼叫複製建構式。

預設參數 vs 多載 取捨

前三個候選也可以用一個帶預設值的建構式取代:Timer(int initial = 0, int step = 1);。少寫兩份本體、少兩份要一起維護的程式碼,這是它的好處。

代價是兩者不能並存:留著預設參數版又留著 Timer(),寫 Timer c; 時兩個都可行、也分不出高下,編譯器會報 call of overloaded … is ambiguous。判準很簡單:各版本本體一樣、只差沒填的值就用預設參數;本體真的要做不同的事(例如收 int 跟收 Timer)就寫成多載。

⚠️ Timer c; 不要寫成 Timer c();

加了括號,C++ 會把它當成「宣告一個回傳 Timer 的函式 c」,而不是建立物件。這種解析問題稱為 most vexing parse,錯誤訊息通常離現場很遠。要建立物件就不要加空括號。

PART 07 · 印得出來

operator<<friend:替物件定義輸出格式

沒有定義 operator<< 的下場
#include <iostream> using namespace std; class Point { private: int x, y; public: Point(int a, int b) { x = a; y = b; } }; int main() { Point p(3, 4); cout << p << endl; // 沒有定義 operator<<,編譯器不知道怎麼印 return 0; }
預期輸出
[編譯失敗]
cell.cpp: In function ‘int main()’:
cell.cpp:13:10: error: no match for ‘operator<<’ (operand types are ‘std::ostream’ {aka ‘std::basic_ostream<char>’} and ‘Point’)
   13 |     cout << p << endl;      // 沒有定義 operator<<,編譯器不知道怎麼印

cout << p 編不過,因為自訂類別沒有自動產生的串流輸出格式。訊息 no match for 'operator<<' 的意思是:編譯器找不到能接收這個型別的 << 版本。

要支援 cout << p,左運算元是 ostream,所以不能只寫成 Point 的一般成員運算子;可定義接收 ostream 與 Point 的非成員函式。下面函式需要直接讀取 private 的 x、y,因此在類別內以 friend 授權。如果改用公開 getter 讀值,就不一定需要 friend。

#include <iostream> using namespace std; class Point { int x, y; public: Point(int a, int b) : x(a), y(b) {} friend ostream& operator<<(ostream& out, const Point& p); }; ostream& operator<<(ostream& out, const Point& p) { out << "(" << p.x << ", " << p.y << ")"; return out; } int main() { Point p(3, 4); cout << p << endl; }
預期輸出
(3, 4)
💡 兩個容易漏的細節

回傳 ostream& 讓 cout << a << b 能繼續串接;參數 const Point& 避免複製並允許唯讀物件。friend 只授權指定函式存取,並不把該函式變成 Point 的成員。

🔗 可讀輸出也是介面

定義 operator<< 後,計時設定、測量值或座標都能用一致方式輸出,方便檢查程式執行期間的物件狀態。

PART 08 · 組合

用現成的東西蓋新的東西 has-a

ReadingLog 保存多筆測量,內部持有 vector<int>,對外提供加入、計數與計算平均。這種「A 擁有 B」的關係就是組合:自己的類別決定介面,標準容器管理底層儲存。

完整的 ReadingLog:用現成容器管理測量記錄
#include <iostream> #include <vector> #include <stdexcept> #include <cstddef> using namespace std; class ReadingLog { vector<int> values; public: void add(int value) { values.push_back(value); } size_t size() const { return values.size(); } double average() const { if (values.empty()) throw runtime_error("no readings"); double total = 0; for (int value : values) total += value; return total / values.size(); } }; int main() { ReadingLog log; log.add(18); log.add(22); log.add(20); cout << "count = " << log.size() << endl; cout << "average = " << log.average() << endl; }
預期輸出
count = 3
average = 20

重點是 vector<int> values;:ReadingLog 擁有一個 vector,並不是 vector 本身。values 維持 private,外部以 add、size、average 操作。沒有自訂建構式時,本例的預設建構會建立空 vector;對空記錄呼叫 average 會依契約拋出例外。未來更換內部儲存方式時,可以維持公開操作不變。

隨堂 2 · 選對那一端

ReadingLog::add 目前用 push_back 加到 vector 尾端。若改用 insert(begin(), value) 從前端加入,新版本的一次插入成本有何差別?

(A) 完全一樣,只是寫法不同
(B) 從 vector 前端插入通常是 $O(n)$
(C) 會出錯,vector 不支援從前端插入
(D) 編譯器會自動改用 另一種內部儲存
🔗 內部容器不等於公開介面

ReadingLog 持有 vector,但不需要向外公開所有 vector 操作。尾端加入具有攤還常數成本;前端插入通常要搬移原有元素,成本線性。average 則走訪所有讀數,呼叫一次的成本與記錄數成正比。

EX · 隨堂檢核

物件指標與公開介面

EXERCISE · 箭頭運算子

已有 Reading room(18); Reading *p = &room;。若要透過 p 讀取 room 的測量值,哪個寫法正確?

(A) p.getData()
(B) p->getData()
(C) (&p)->getData()
(D) p->getData
REFERENCE · 速查表

P8 速查表

語法骨架

class 類別名稱 { private: 型別 成員變數; // 預設就是 private public: 類別名稱(參數) { … } // 建構式:同名、沒有回傳型別 類別名稱() { … } // 多載:參數列不同就是不同版本 回傳型別 成員函式(參數) const { // const = 不修改一般成員 return 成員變數; } friend ostream& operator<<(ostream&, const 類別名稱&); // 開門給外部函式 }; 類別名稱 物件(引數); // 建在堆疊上,離開範圍自動消失 類別名稱 *p = new 類別名稱(引數); // 建在堆積上,要自己 delete

C++ ↔ Python 對照

概念C++Python
建構式類別名稱(…)__init__
指向自己this(指標,隱含)self(參數,要明寫)
藏起來private(編譯器強制)底線開頭(只是約定)
印出來operator<<__str__
唯讀承諾函式後面加 const沒有對應機制
同名多版本參數列不同即可只能靠預設參數

繼承、多型、virtual、運算子多載與 template,見 P9 · 物件導向(進階)

延伸閱讀

資源看什麼
cppreference · Classes類別、成員與存取控制的語言參考。
cppreference · Classes存取控制、建構式、成員函式的完整規則。
C++ Tutor把本頁的 Reading 貼進去,看物件的成員在記憶體裡怎麼排。
QUIZ · 自我檢測

自我檢測:類別與物件 隨堂自測 · 6 題

每個選項都有解說:選錯也點開看看為什麼錯。全對之後再往下翻詞彙卡。

Q1.以下程式印出什麼?
#include <iostream>
using namespace std;
class Reading {
    private:
        int value;
    public:
        Reading(int v) { value = v; }
        int getData() const { return value; }
};

int main() {
    Reading sample(93);
    cout << sample.getData();
}
Q2.以下程式為什麼編不過?
#include <iostream>
using namespace std;
class Reading {
    private:
        int value;
    public:
        Reading(int v) { value = v; }
};

int main() {
    Reading sample(93);
    cout << sample.value;
}
Q3.類別的成員如果沒有寫任何存取區段,預設是什麼?
Q4.依本頁 Timer 三個建構式,以下程式透過 getter 印出什麼?
Timer a(3, 4), b(5), c;
cout << a.initial() << "," << a.step() << " "
     << b.initial() << "," << b.step() << " "
     << c.initial() << "," << c.step();
Q5.要支援 cout << p,為何一般將 operator<< 定義為接收 ostream 與 Point 的非成員函式?
Q6.若把 ReadingLog 的 values 從 private 改為 public,主要影響是什麼?
CARDS · 關鍵詞彙卡

關鍵詞彙卡:點卡片翻面

先看正面術語,心中默想定義再翻面對答案;洗牌後再過一輪,直到每張都能不假思索說出來。