先讀語法與例子,再操作互動圖解並完成四選一自測。忘記寫法時回看速查表,最後用中英詞彙卡複習。
P8 教你把「資料 + 操作」包成一個 class。這一頁處理接下來必然發生的三件事:
| 問題 | C++ 的答案 | 本頁哪一節 |
|---|---|---|
| 兩個類別有一半的程式碼一樣,要複製貼上嗎? | 繼承、virtual、多型 | P01–P04 |
怎麼讓 sort、set、priority_queue 吃我自己寫的型別? | 運算子多載 | P05–P07 |
怎麼讓一份程式碼同時服務 int 與 string? | template | P10 |
本頁先介紹繼承如何建立共用介面,再說明虛擬函式如何依物件的實際型別選擇版本。這是執行期多型;C++ 也能用稍後介紹的模板表達編譯期多型。
下例的 Dog(string n) : Animal(n) 使用 P8 的建構式初始化列表;這次冒號後指定的是基底 Animal 的建構式。它與 class Dog : public Animal 宣告繼承關係的冒號不同。下一節再整理執行順序。
「有序串列是一種串列」—— 這種 is-a 關係就用繼承。子類別自動擁有父類別所有的成員,只需要寫「不一樣的那部分」。
小黑 在吃東西 小黑 汪汪叫
Dog 沒有自己寫 eat(),直接用繼承來的;但它寫了自己的 speak(),蓋掉父類別的版本,這叫覆寫。另外注意 protected:它讓子類別碰得到 name,但外人還是碰不到。
| 寫法 | 意思 |
|---|---|
| class Dog : public Animal | Dog 繼承 Animal,父類別的 public 仍是 public |
| protected: | 自己與子類別看得到,外人看不到 |
| Dog(string n) : Animal(n) | 成員初始化列表:先把父類別的部分蓋好 |
| void speak() override | 明說「我是在覆寫」,名字打錯編譯器會抓出來 |
建立子類別物件時,C++ 一定先把父類別的部分建好,再執行子類別的建構式主體。父類別如果沒有無參數建構式,你就必須用成員初始化列表明說要呼叫哪一個。
初始化列表沒有指定基底時,會嘗試呼叫基底的預設建構式;若沒有可用版本,程式無法編譯。若預設建構式存在,欄位是否得到適當初值取決於它的實作及成員初值。本例明確傳入 Package(label),說明基底需要哪些建立資料。
virtual:依物件的動態型別選擇成員函式Python 的多型是免費的:方法名字對得上就能互換。C++ 不是。
我是 Base 我是 Derived
同一個物件 d,用 Base* 指標呼叫跑父類別、直接用物件呼叫跑子類別。因為沒有 virtual 時,編譯器在編譯期就照指標的型別決定要呼叫誰。
我是 Derived
加了 virtual,C++ 會在執行期查一張表(虛擬函式表)找出物件真正的型別。這就是動態繫結,也是多型能運作的機制。子類別那邊建議加 override:名字或簽章打錯時編譯器會直接抓出來。
① 方法要標 virtual;② 要透過基底類別的指標或參考呼叫。
少了第二點,多型也不會發生 —— 而且如果你是用值傳遞子類別物件給一個吃父類別的函式,子類別多出來的部分會被直接切掉(叫做 object slicing)。所以多型場合一律傳指標或參考。
= 0:宣告純虛擬函式virtual void deliver() = 0; 宣告純虛擬操作。~Notification() 是解構式,負責物件生命週期結束時的收尾;= default 要求編譯器提供預設實作。基底的虛擬解構式允許透過基底指標刪除衍生物件時正確執行解構;本例沒有額外資源需要手動清理。
例如通知介面規定「可以傳送」,不同通知類型卻有不同傳送方式。基底可用純虛擬函式表達這個操作;尚未提供實作的衍生類別仍可保持抽象,只有要建立具體物件的型別才必須完成相應介面。
抽象類別不能直接建立物件,但可以作為基底介面。上例 ConsoleNotice 完成 deliver,才成為可以實例化的具體類別;這是獨立通知範例,不需其他標頭。
模板讓型別成為編譯期參數。template <typename T> 宣告一個型別參數 T,Box<int> 就是以 int 替換 T 的具體型別。先讀下面完整的 Box,再用同一套尖括號讀後續容器與比較器的型別。
42 / hello sizeof(Box<int>) = [依平台而異] sizeof(Box<string>) = [依平台而異]
Box<int> 與 Box<string> 是不同的模板實例化型別。它們的物件大小依成員與實作而定,不保證一定不同;sizeof 只用來觀察目前平台的物件大小,不能拿大小差異當作判斷型別是否相同的標準。
.hpp編譯器在實例化 Container<int> 的位置通常必須看得到完整模板定義。因此模板類別常把宣告與定義一起放在標頭;也可使用明確實例化等進階方式分離,但那會限制預先提供的型別組合。這接續 P8 的多檔組織。
沒有 main( 的 cell 會被 kernel 自動包進一個 main(),於是你的 template 類別變成區域類別 —— 而區域類別不能是 template,直接編譯失敗。詳見 00B 的常見問題。
operator==:定義「什麼叫一樣」若類別沒有提供可用的相等運算,兩個物件便不能直接以 == 比較。要讓「內容一樣就算相等」,必須定義符合型別語意的比較;以下採用 C++17 可用的明確多載。
對 GridPosition 而言,兩個物件只有在 row 與 column 都相同時才位於同一格,因此逐欄比較正好符合型別語意。「什麼叫一樣」是類別介面的一部分,應由設計者明確定義。
operator<:讓排序與預設有序容器比較物件[編譯失敗] predefined_ops.h:45:23: error: no match for ‘operator<’ (operand types are ‘Task’ and ‘Task’) predefined_ops.h:98:22: error: no match for ‘operator<’ (operand types are ‘Task’ and ‘Task’)
sort、min_element、priority_queue 與 set 都需要合適的比較規則。自訂型別若沒有可用的比較,使用預設排序就可能在編譯時失敗;下面保留失敗與成功版本作對照。
sort -> 修 bug(1) 回信(3) 備份(5) min -> 修 bug pq 頂 -> 備份(預設是最大優先)
只寫了一個 operator<,sort、min_element、priority_queue 就全部能用了。注意 priority_queue 預設是最大優先,所以頂端是 priority 最大的那個。
逐步比較四筆工作的優先序,觀察 operator< 的回傳值如何決定排列順序。動畫示範比較關係;std::sort 的實際比較步驟由標準函式庫實作決定。
fix < backup 相當於 fix.operator<(backup),本例比較 priority。比較應符合嚴格弱序:例如不能讓 a < a 為真,也不能互相認定 a < b 且 b < a。這些規則錯了,即使語法合法,標準排序的前提也不成立。
priority_queue 那三個尖括號參數是什麼優先佇列的型別可以明寫以下三個參數,先逐個讀「元素、底層容器、比較器」。它是一般標準庫介面,不依賴特定演算法。
priority_queue<pair<int,string>, vector<pair<int,string>>, greater<pair<int,string>>> pq;
| 位置 | 這裡的值 | 意思 |
|---|---|---|
| 第 1 參數 | pair<int,string> | 元素型別 —— 存的是什麼。pair 比較時先比 first(就是這一節的 operator<) |
| 第 2 參數 | vector<pair<int,string>> | 保存元素的底層容器;此處用 vector,滿足 priority_queue 對容器的需求 |
| 第 3 參數 | greater<pair<int,string>> | 比較器 —— 決定誰排在頂端 |
預設比較方式下 priority_queue 讓較大的值在頂端。若情境是「較早的預約時間先處理」,可用適當的 greater 比較器讓較小時間先出現。比較器影響取出規則,不需先了解底層如何維護順序。
預設(最大值優先) top -> 8 D greater(最小值優先) top -> 2 A
pair 的預設比較先比 first,相同時再比 second。less 使用 <,greater 使用 >;本例的 pair 已提供對應比較。自訂型別若只有 operator<,不能因此假設所有方向的運算子都自動存在。
operator<,無序容器要雜湊下面把已學過的模板延伸為「替特定型別提供版本」:template<> struct hash<Point> 是 std::hash 對 Point 的特化。它放在 namespace std,是為自訂型別提供標準雜湊器的指定用法,不代表可以任意往 std 加入宣告。operator() 是呼叫運算子,因此雜湊器物件可寫成 hasher(point)。std::size_t 是表示大小的無號整數型別。
本例先用 std::hash<int>{} 建立標準整數雜湊器,再對座標呼叫它。^ 是逐位元 XOR,<< 1 把無號數的位元向左移一格;這裡只是簡單組合兩個欄位,並不保證不同座標得到不同雜湊值。相等的座標必須得到相同值,而碰撞時容器仍會比較鍵。
這是 C++ 與 Python 一個結構性的差別。Python 的 dict 與 set 一律要求鍵可雜湊;C++ 分成兩套:
| 容器 | 底層 | 你的型別要提供 | 查詢成本 |
|---|---|---|---|
| set / map | 依比較器排序 | operator< 或自訂比較器 | $O(\log n)$,走訪有序 |
| unordered_set / unordered_map | 雜湊表 | operator== + std::hash 特化 | 平均 O(1)、最壞 O(n);走訪順序未指定 |
unique = 1 found = 1
a == b 為真時,hash(a) 必須等於 hash(b)。所以雜湊值要從「決定相等的那些欄位」算出來。這條規則違反了,同一個鍵會查不到、或在容器裡出現兩份。
size()、[]、forC++ 沒有像 Python 那樣一組固定的「協定方法」,但 STL 有一套慣例。照著提供,你的類別就能用得跟內建容器一樣。
| 你想支援的寫法 | 要提供什麼 | 獨立例子 |
|---|---|---|
| x.size() | 一個 size() 成員函式 | 名單人數 |
| x[i] | operator[] | 每小時溫度 |
| x[i] = v | 回傳參考的 operator[] | 更新某時段讀數 |
| a == b | operator== | 相同格子位置 |
| a < b | operator< | 工作優先順序 |
| for (auto x : c) | begin() 與 end() | 逐一讀取記錄 |
operator[] 要寫兩個版本一個回傳參考(T& operator[](int i))給人改,一個是 const 版本(const T& operator[](int i) const)給唯讀情境用。少了 const 版本,const 物件就下標不了。
Inbox 該繼承 vector 嗎?做一個必須保留收件順序、只允許依序接收與取件的收件箱,最省事的寫法看起來是直接繼承 vector。試試看會怎樣:
收到的順序: 20 10 外部任意改動後: 10 99 20
問題不在它不能動,而在它能做的事太多了:使用者可以對 Inbox 呼叫 sort() 或 insert(begin()+1, x),任意改變收件順序,破壞類別只允許依序收件與取件的承諾。
| 繼承(is-a) | 組合(has-a) | |
|---|---|---|
| 寫法 | class Inbox : public vector<int> | vector<T> items; |
| 對外露出什麼 | 父類別所有方法,擋不掉 | 只有你自己寫的 |
| 能換底層實作嗎 | 公開介面已依賴基底型別,替換較受限制 | 可在維持公開操作的前提下調整內部實作 |
| 什麼時候該用 | 子類別真的是一種父類別,而且父類別每個方法對它都合理 | 絕大多數的資料結構 |
先問基底的公開操作是否都符合衍生型別的承諾。收件箱若必須保留收到順序,公開繼承 vector 就會把任意插入、索引與重排等能力一併暴露;用成員組合可以只公開需要的操作。
類別若直接擁有動態資源,就要一起考慮釋放、複製建構與複製賦值,不能只補解構式。預設逐成員複製指標可能讓兩個物件共用同一配置,造成相互影響與重複釋放,這是 Rule of Three 要提醒的責任。短程式也可能在結束前就出錯;實務可優先使用標準容器或合適的所有權管理型別。
以下是一般 C++ 程式常見的介面與資源檢查點。每項都有相應的語法或生命週期理由,不能以「程式很短」作為忽略它的理由。
std:: 前綴,避免 using namespace std; 影響每個引入它的來源檔。命名空間與標頭的關係見 P1。new 配置的資源,要一起安排釋放、複製建構與複製賦值,或明確禁止複製;不能只複製原始指標。這是上面的 Rule of Three。stack 的 top() 與 pop() 要求非空,使用前先檢查 empty()。自己的介面若選擇以例外回報空資料,就要清楚說明契約;例外處理見 P7。const,讓唯讀物件也能使用;會更新一般成員的 setter 不加。對照例子見 P8。string 參數,可用 const string& 避免複製;需要獨立副本時再選傳值。參數傳遞見 P3。size() 使用無號的 size_type,可用 auto 保留回傳型別;不要在未確認範圍時轉成 int,也要避免空容器的 size()-1。大小與容量見 P5。nullptr 表達空指標;釋放後即使把一個指標設空,其他別名也不會自動更新。指標生命週期見 P4。throw 與自訂例外當函式無法完成它承諾的事時就該拋例外,而不是以正常回傳值掩蓋失敗。
接住了: 草稿箱沒有待取出的草稿
這個例子的自訂例外繼承 runtime_error,建構式把訊息交給基底類別,再以 e.what() 取回。獨立型別讓呼叫端能精準接住草稿箱的特定失敗;這也延續了 P7 先用標準例外建立的 throw/catch 基礎。
因為沒有哪個值是安全的。空堆疊 takeLast 回傳 0?那真的存了 0 的時候怎麼辦?回傳 -1?那存負數呢?而且呼叫端很容易忘記檢查,錯誤就一路往下傳,最後在很遠的地方才顯現。例外的價值就在於它不會被無視。
你寫了一個 Job 類別,把一堆 Job 丟進 priority_queue,結果編譯錯誤說 no match for 'operator<'。最直接的解法是?
Base *p = &derivedObj; p->who(); 跑出來是父類別的版本。為什麼?
你要實作一個保留收件順序、只允許依序接收與取件的 Inbox。哪個設計比較好?
| 你寫的 | 要定義的函式 | 用途 | 獨立例子 |
|---|---|---|---|
| cout << x | friend operator<< | 印出物件 | 印出座標 |
| a + b | operator+ | 加法 | 位移相加 |
| a == b | operator== | 相等判斷 | 相同格子位置 |
| a < b | operator< | 排序、優先取值、set | 工作優先順序 |
| x[i] | operator[] | 下標讀寫 | 每小時溫度 |
| hash<T> | std::hash 特化 | unordered 容器的鍵 | 座標當查詢鍵 |
| for (auto x : c) | begin() / end() | 範圍走訪 | 逐一讀取記錄 |
① 子類別的建構式用成員初始化列表呼叫父類別的版本。
② 要多型就要 virtual,而且要透過指標或參考呼叫。
③ 想被排序、想進 set/priority_queue,給 operator<。
④ 想當 unordered_map 的鍵,給 operator== 加 std::hash 特化。
⑤ 不確定該繼承還是組合時,選組合。
| 資源 | 看什麼 |
|---|---|
| cppreference · Operator overloading | 所有可多載運算子的完整清單與慣例。 |
| cppreference · Templates | template 的完整規則,包含為什麼實作要放標頭檔。 |
| cppreference · Operators | 運算子多載的語法、限制與常見形式。 |
| C++ Gossip · 運算子多載 | 延伸比較器語法與自訂型別的介面設計。 |
每個選項都有解說:選錯也點開看看為什麼錯。全對之後再往下翻詞彙卡。
#include <iostream>
using namespace std;
class Base {
public:
void who() { cout << "Base"; }
};
class Derived : public Base {
public:
void who() { cout << "Derived"; }
};
int main() {
Derived d;
Base *p = &d;
p->who();
}
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
class Task {
public:
string name;
int priority;
Task(string n, int p) { name = n; priority = p; }
};
int main() {
vector<Task> v = { Task("a", 5), Task("b", 1) };
sort(v.begin(), v.end());
}
#include <iostream>
#include <string>
using namespace std;
template <typename T>
class Box {
private:
T item;
public:
Box(T v) { item = v; }
T get() const { return item; }
};
int main() {
Box<int> a(42);
Box<string> b("hi");
cout << a.get() << " " << b.get();
}
先看正面術語,心中默想定義再翻面對答案;洗牌後再過一輪,直到每張都能不假思索說出來。