運算子多載 (Operator Overloading) 讓自定義型別也能使用
+、-、<<、[]等運算子, 使程式碼更直覺、更貼近數學或自然語言。 它本質上只是「換個語法的函式呼叫」——a + b其實就是呼叫operator+(a, b)。
學習目標
讀完本章,你應該能夠:
- 知道哪些運算子可以被多載,哪些不行,以及背後的原因
- 區分「成員函式」與「非成員函式(自由函式)」兩種形式,並知道何時選哪一種
- 掌握各運算子的回傳型別慣例(回傳值 vs 回傳參考)
- 實作算術運算子 (
+,-,*,/) 與一元負號 - 用複合指派運算子 (
+=) 實作算術運算子,避免重複 - 實作比較運算子,理解它們之間的關係,並認識 C++20 的
<=>(太空船運算子) - 正確實作串流運算子 (
<<,>>),並理解為何必須是非成員 friend - 實作下標
[]、函式呼叫()、前綴/後綴++/-- - 實作型別轉換運算子並用
explicit防止意外轉換 - 理解
operator=應回傳參考、自我指派檢查等規範 - 避開常見陷阱(多載
&&/||/,喪失短路、過度多載等)
0. 為什麼需要運算子多載?
假設我們有一個二維向量類別,要做向量加法:
// 沒有運算子多載:只能用方法,可讀性差
Vector2D result = a.add(b).add(c).scale(2.0);
// 有運算子多載:像數學一樣自然
Vector2D result = (a + b + c) * 2.0;
運算子多載的價值在於讓自定義型別表現得像內建型別,使數值運算、容器存取、輸出等程式碼更直覺。std::string 的 +、std::cout <<、std::vector 的 [] 全都是運算子多載。
黃金原則(C++ Core Guidelines C.160/C.161):Define operators primarily to mimic conventional usage. 只有當運算子的語意符合「一般人對該符號的直覺」時才多載它。讓
+做加法,不要讓+去做減法或送出網路請求。
1. 哪些運算子可以多載?
可以多載
+ - * / % ^ & | ~ ! = < > += -=
*= /= %= ^= &= |= << >> >>= <<= == != <= >= <=>
&& || ++ -- , ->* -> () [] new delete new[] delete[]
不可多載(連同原因)
| 運算子 | 為何不能多載 |
|---|---|
:: |
範圍解析,編譯期的純語法結構,與物件無關 |
. |
成員存取,若可多載將無法存取「成員本身」 |
.* |
成員指標存取,同上 |
?: |
三元運算子,無法保證短路語意 |
sizeof |
編譯期由型別決定 |
typeid |
型別識別,編譯/執行期語言機制 |
alignof |
編譯期對齊查詢 |
多載的基本限制
- 不能發明新運算子(如
**、<>)。 - 不能改變運算元數量(
+永遠是二元或一元,不能變三元)。 - 不能改變優先順序與結合性(
a + b * c永遠先算*)。 - 至少有一個運算元是自定義型別(不能多載
int + int)。
2. 成員函式 vs 非成員函式(自由函式)
這是運算子多載最重要的設計決策。
2.1 兩種形式對照
class Vector2D {
public:
double x, y;
// 形式 A:成員函式 —— 左運算元是 this
Vector2D operator+(const Vector2D& rhs) const {
return {x + rhs.x, y + rhs.y};
}
};
// 形式 B:非成員(自由)函式 —— 左右運算元都是明確參數
Vector2D operator+(const Vector2D& lhs, const Vector2D& rhs) {
return {lhs.x + rhs.x, lhs.y + rhs.y};
}
- 成員形式:
a + b等同a.operator+(b)。左運算元一定是*this。 - 非成員形式:
a + b等同operator+(a, b)。左右運算元對稱。
2.2 關鍵差異:對稱性與隱式轉換
考慮 Fraction 有一個 Fraction(int) 建構子,希望 2 + frac 也能運作:
// 成員版本:2 + frac 失敗!因為左運算元 2 不是 Fraction,無法呼叫 2.operator+(frac)
// 非成員版本:2 + frac 成功!編譯器把 2 隱式轉成 Fraction(2),再呼叫 operator+(Fraction(2), frac)
這是 C++ Core Guidelines C.161 的核心建議:Use non-member functions for symmetric operators. 對稱的二元運算子(
+、-、*、==、<…)應寫成非成員函式,這樣左右運算元能享有對稱的隱式轉換。
2.3 決策表
| 運算子 | 形式 | 原因 |
|---|---|---|
= [] () -> |
必須成員 | 語言規定 |
+= -= *= … 複合指派 |
建議成員 | 修改 *this,本來就不對稱 |
++ -- |
建議成員 | 修改自身狀態 |
+ - * / 對稱算術 |
建議非成員 | 支援左運算元的隱式轉換、對稱 |
== != < > … 比較 |
建議非成員 | 同上,對稱 |
<< >> 串流 |
必須非成員 | 左運算元是 std::ostream/istream |
一元 - ! ~ |
建議成員 | 只作用於自身 |
若非成員函式需要存取 private 成員,宣告為
friend(見 §9)。
3. 算術運算子與「用 += 實作 +」的慣用法
3.1 回傳型別慣例
- 二元算術
+ - * /:產生新物件,回傳 by value(Fraction)。 - 複合指派
+= -= …:修改自身,回傳 by reference(Fraction&)。
3.2 慣用法:先寫 +=,再用它實作 +
這能消除重複邏輯,是 C++ 的標準慣例:
class Fraction {
public:
Fraction(int num = 0, int den = 1) : num_(num), den_(den) { reduce(); }
// 1) 複合指派是「核心」,回傳參考
Fraction& operator+=(const Fraction& rhs) {
num_ = num_ * rhs.den_ + rhs.num_ * den_;
den_ = den_ * rhs.den_;
reduce();
return *this;
}
int num() const { return num_; }
int den() const { return den_; }
private:
int num_, den_;
void reduce();
};
// 2) operator+ 用 operator+= 實作,寫成非成員(支援 int + Fraction)
inline Fraction operator+(Fraction lhs, const Fraction& rhs) {
lhs += rhs; // 複用 +=
return lhs; // lhs 是值參數(已是副本),直接回傳
}
技巧:
operator+的左參數用傳值 (Fraction lhs),這個副本正好拿來修改並回傳,省去額外的暫存物件。這是 C++ Core Guidelines C.161 推薦的寫法。
4. 比較運算子與 C++20 太空船 <=>
4.1 比較運算子之間的關係(C++17 寫法)
只要實作 == 與 <,其餘四個都能用它們表達,避免重複與不一致:
inline bool operator==(const Fraction& a, const Fraction& b) {
return a.num() * b.den() == b.num() * a.den();
}
inline bool operator!=(const Fraction& a, const Fraction& b) { return !(a == b); }
inline bool operator< (const Fraction& a, const Fraction& b) {
return a.num() * b.den() < b.num() * a.den();
}
inline bool operator> (const Fraction& a, const Fraction& b) { return b < a; }
inline bool operator<=(const Fraction& a, const Fraction& b) { return !(b < a); }
inline bool operator>=(const Fraction& a, const Fraction& b) { return !(a < b); }
以 < 與 == 為基礎推導其餘:
a > b ⟺ b < a
a <= b ⟺ !(b < a)
a >= b ⟺ !(a < b)
a != b ⟺ !(a == b)
⚠️ 整數相乘比較大小時,注意溢位:可先轉成
long long再相乘。
4.2 C++20:太空船運算子 <=>
C++20 引入 operator<=>(three-way comparison),一次定義就自動產生 < > <= >=:
// C++20(本教材以 C++17 為主,此處僅作認識)
#include <compare>
struct Point {
int x, y;
auto operator<=>(const Point&) const = default; // 自動產生全部比較
bool operator==(const Point&) const = default;
};
<=>回傳std::strong_ordering/weak_ordering/partial_ordering。= default會做「逐成員字典序比較」,省下大量樣板程式碼。
本章範例以
-std=c++17為準,因此實作仍用傳統六個比較運算子;<=>留待學習 C++20 時深入。
5. 串流運算子 << 與 >>
5.1 為什麼必須是非成員 friend?
std::cout << frac 的左運算元是 std::ostream,右運算元才是你的型別。如果寫成成員函式,它的左運算元會變成你的型別,使用方式會變成怪異的 frac << std::cout——而且你不能修改 std::ostream 的原始碼去新增成員。所以只能寫成非成員函式。
class Fraction {
int num_, den_;
public:
// 宣告為 friend 才能存取 private num_/den_
friend std::ostream& operator<<(std::ostream& os, const Fraction& f);
friend std::istream& operator>>(std::istream& is, Fraction& f);
};
std::ostream& operator<<(std::ostream& os, const Fraction& f) {
if (f.den_ == 1) os << f.num_;
else os << f.num_ << "/" << f.den_;
return os; // 回傳 os 以支援鏈式:cout << a << b << c;
}
std::istream& operator>>(std::istream& is, Fraction& f) {
int n, d; char slash;
is >> n >> slash >> d;
f = Fraction(n, d);
return is;
}
使用範例與預期輸出:
Fraction f(3, 4);
std::cout << "f = " << f << "\n"; // 鏈式呼叫
f = 3/4
要點:
- 回傳
std::ostream&/std::istream&,才能鏈式呼叫(cout << a << b)。 - 第二參數:
<<用const T&(只讀),>>用T&(要寫入)。 - 通常宣告為
friend,以便存取 private 成員(若有完整 getter 也可不用 friend)。
6. 指派運算子 =
class MyClass {
int* data_;
std::size_t size_;
public:
MyClass& operator=(const MyClass& rhs) {
if (this != &rhs) { // 1) 自我指派檢查
int* newData = new int[rhs.size_]; // 2) 先配置,成功才動自己(例外安全)
std::copy(rhs.data_, rhs.data_ + rhs.size_, newData);
delete[] data_; // 3) 釋放舊資源
data_ = newData;
size_ = rhs.size_;
}
return *this; // 4) 回傳 *this 的參考
}
};
規則:
- 必須是成員函式(語言規定)。
- 回傳
*this的參考T&,支援連鎖指派a = b = c;。 - 做自我指派檢查
this != &rhs,避免a = a;先刪除再讀取自己。 - 進階:可用 copy-and-swap 慣用法自動獲得例外安全與自我指派安全。
7. 下標 [] 與函式呼叫 ()
7.1 下標運算子 []
class IntArray {
std::vector<int> data_;
public:
explicit IntArray(std::size_t n) : data_(n) {}
int& operator[](std::size_t i) { return data_[i]; } // 可讀寫
const int& operator[](std::size_t i) const { return data_[i]; } // 唯讀(const 物件)
};
- 必須是成員函式。
- 提供 const 與非 const 兩個版本:非 const 版回傳
int&(可被指派),const 版回傳const int&(保護 const 物件)。 - 加上邊界檢查可做成「安全陣列」(越界拋
std::out_of_range)。
7.2 函式呼叫運算子 () — 函式物件 (functor)
多載 () 讓物件能像函式一樣被呼叫,這種物件稱為函式物件 / functor:
class Multiplier {
int factor_;
public:
explicit Multiplier(int f) : factor_(f) {}
int operator()(int x) const { return x * factor_; } // 可像函式般呼叫
};
Multiplier times3(3);
std::cout << times3(10) << "\n"; // 輸出 30
預期輸出:
30
- 必須是成員函式,但參數數量任意(唯一可帶任意參數的運算子)。
- functor 可攜帶狀態(如上面的
factor_),這是它比一般函式指標強大的地方,也是std::sort等演算法常用的客製化方式。
8. 前綴與後綴 ++ / --
class Counter {
int value_ = 0;
public:
// 前綴 ++c:先加,回傳「加完後」的自己 → 回傳參考
Counter& operator++() {
++value_;
return *this;
}
// 後綴 c++:用「int 假參數」區分,回傳「加之前」的舊值 → 回傳值
Counter operator++(int) {
Counter old = *this; // 保存舊值
++value_; // 修改自己
return old; // 回傳舊值(注意:回傳的是副本)
}
int value() const { return value_; }
};
| 形式 | 簽名 | 回傳 | 效率 |
|---|---|---|---|
前綴 ++c |
Counter& operator++() |
參考(加完的自己) | 較佳 |
後綴 c++ |
Counter operator++(int) |
值(加之前的副本) | 較差(多一次複製) |
要點:
- 那個
int參數只是用來區分前綴與後綴,永遠不會被傳值、也不命名。 - 後綴必須保存並回傳舊值,所以回傳 by value 且效率較差。在迴圈中優先用
++i而非i++(對自定義型別差異明顯)。
9. friend 函式:何時用、何時不用
friend 讓非成員函式(或另一個類別)能存取本類別的 private 成員。
class Complex {
double real_, imag_;
public:
Complex(double r = 0, double i = 0) : real_(r), imag_(i) {}
friend std::ostream& operator<<(std::ostream& os, const Complex& c);
friend Complex operator+(const Complex& a, const Complex& b);
};
std::ostream& operator<<(std::ostream& os, const Complex& c) {
os << c.real_ << (c.imag_ >= 0 ? " + " : " - ") << std::abs(c.imag_) << "i";
return os;
}
Complex operator+(const Complex& a, const Complex& b) {
return Complex(a.real_ + b.real_, a.imag_ + b.imag_);
}
| 情境 | 用 friend? |
|---|---|
operator<< / operator>>(左運算元是 stream) |
✓ 是 |
| 對稱算術/比較,需存取 private 又要支援左運算元隱式轉換 | ✓ 是 |
| 兩個緊密耦合的類別需互相存取(如 Iterator 與 Container) | ✓ 考慮 friend class |
| 已有 public getter 足以完成 | ✗ 不需要,優先用 getter |
C++ Core Guidelines C.168:對稱運算子定義在類別的命名空間內(常用 friend 行內定義)。但也別濫用 friend——它會破壞封裝,能用 public 介面達成就別開後門。
10. 型別轉換運算子與 explicit
class Fraction {
int num_, den_;
public:
Fraction(int n = 0, int d = 1) : num_(n), den_(d) {}
explicit operator double() const { // 轉成 double
return static_cast<double>(num_) / den_;
}
explicit operator bool() const { // 是否非零
return num_ != 0;
}
};
Fraction f(3, 4);
double d = static_cast<double>(f); // ✓ 顯式轉換 → 0.75
if (f) { /* explicit operator bool 在條件式中可「文意轉換」 */ }
// double bad = f; // ✗ explicit 禁止這種隱式轉換
為什麼要
explicit? 沒有explicit的轉換運算子會在你意想不到的地方被編譯器偷偷套用,造成難以察覺的 bug(例如Fraction不小心被當成double參與整數運算)。C++ Core Guidelines C.164:避免隱式轉換運算子,幾乎一律加explicit。特例:
operator bool加explicit後,在if、while、&&、||等「布林情境」仍可使用,但不會被誤用於算術,這正是std::unique_ptr、std::optional的作法。
11. 常見錯誤與陷阱
-
忘記自我指派檢查:
operator=中若this == &rhs仍照常「先刪除再複製」,會讀到已釋放的記憶體。加上if (this != &rhs)或用 copy-and-swap。 -
operator<<寫成成員函式:會變成obj << std::cout的怪語法。串流運算子必須是非成員。 -
後綴
++忘了int假參數:operator++()與operator++(int)靠那個int區分,漏掉編譯器無法分辨前後綴。 -
回傳型別錯誤:
operator+應回傳新物件(by value);operator+=、operator=、前綴++應回傳*this(by reference)。搞反會導致無法鏈式呼叫或產生多餘複製。 -
多載
&&、||喪失短路求值:內建的&&/||有「短路」特性(左邊決定結果就不算右邊)。一旦多載成函式,兩個運算元都會先被求值,短路語意消失,可能造成嚴重 bug。幾乎永遠不要多載&&、||。 -
多載逗號
,:同樣會破壞內建逗號的求值順序保證,不要多載。 -
比較運算子不一致:
==與<的邏輯若彼此矛盾(例如a==b為真但a<b也為真),會讓std::sort、std::set行為錯亂。用==/<推導其餘,保持一致。 -
隱式轉換造成歧義:轉換建構子與轉換運算子都沒加
explicit時,編譯器可能找到多條轉換路徑而報「ambiguous」,或默默選錯。一律審慎使用explicit。 -
過度多載 / 語意不直覺:讓
+做非加法的事、為冷僻型別硬加一堆運算子,會降低可讀性。只在符合慣例時多載。 -
回傳區域物件的參考:
operator+千萬不要return *this;或回傳區域變數的參考——應回傳全新的值物件。
重點整理
| 概念 | 說明 |
|---|---|
| 本質 | a @ b 即 operator@(a, b) 或 a.operator@(b) |
| 成員 vs 非成員 | 左運算元是自己 → 成員;對稱運算子 → 非成員;stream → 必須非成員 |
| 必須成員 | =、[]、()、-> |
| 必須非成員 | <<、>> |
| 算術回傳 | 二元算術回傳值 T;複合指派回傳參考 T& |
| 用 += 實作 + | 先寫核心 +=,再用它寫 +,減少重複 |
| 比較運算子 | 實作 == 與 <,其餘以它們推導 |
C++20 <=> |
一次定義產生全部比較(本章以 C++17 為主) |
前綴 ++ |
回傳參考 T&(效率好) |
後綴 ++ |
帶 int 假參數,回傳值 T(保存舊值) |
[] |
提供 const 與非 const 兩版本 |
() |
functor,可帶狀態與任意參數 |
friend |
讓非成員存取 private;勿濫用 |
explicit 轉換 |
防止意外隱式轉換 |
| 別碰 | &&、\|\|、,(會破壞短路/求值順序) |
練習題
以下練習由淺入深,建議全部動手實作。每題附「提示」。
練習 1(基礎)— 完整 Fraction 類別
實作完整的 Fraction(分數)類別,支援 + - * /、== != < > <= >=、<<、>>,並在建構與運算後自動約分(用 std::gcd)。對稱運算子寫成非成員,串流運算子寫成 friend。
- 提示:先寫核心
operator+=等複合指派,再用它們實作+ - * /;比較只實作==與<,其餘推導;約分時處理負號(讓分母恆正)。
練習 2(基礎)— Vector2D 向量運算
設計 Vector2D,支援 +、-、一元 -、純量乘法(同時支援 v * 2.0 與 2.0 * v)、內積、==、<<、[](索引 0/1)。
- 提示:
2.0 * v的左運算元是double,必須用非成員函式;==用浮點容差eps比較;[]越界拋std::out_of_range。
練習 3(中級)— Complex 複數類別
實作 Complex(複數),支援 + - * /、一元 -、共軛、模長、==、<<,並支援 double + Complex、Complex + double 兩種混合運算。
- 提示:混合運算用非成員 friend;複數除法
(a+bi)/(c+di)先乘上分母的共軛;<<依虛部正負輸出+或-。
練習 4(中級)— SafeArray 安全陣列
設計 SafeArray,內部用 std::vector<int>,多載 [] 並加入邊界檢查(越界拋 std::out_of_range),提供 const 與非 const 兩版本,再多載 << 印出所有元素,以及 == 比較內容。
- 提示:
[]兩版本回傳型別分別是int&與const int&;<<走訪元素以空白分隔輸出。
練習 5(中級)— Counter 與前綴/後綴
設計 Counter 類別,正確實作前綴與後綴 ++ / --、==、<、<<。寫一段測試比較 ++c 與 c++ 的回傳值差異,並用註解說明後綴為何較慢。
- 提示:前綴回傳
Counter&,後綴回傳Counter且帶int假參數並保存舊值。
練習 6(中級)— Money 金額類別與型別轉換
設計 Money 類別(內部以「分」為單位的整數儲存,避免浮點誤差),支援 +、-、==、<、<<,並提供 explicit operator double()(回傳以「元」為單位的值)。示範若移除 explicit 會發生什麼意外轉換。
- 提示:內部存
long long total_cents_;輸出時處理$xx.xx的補零;說明explicit如何阻止double d = money;。
練習 7(挑戰)— Matrix2x2 矩陣
設計 2×2 矩陣 Matrix2x2,支援 +、-、*(矩陣乘法)、純量乘法、==、<<、以及 operator()(row, col) 存取元素(用函式呼叫運算子當二維索引)。
- 提示:矩陣乘法不可交換,需正確實作
c[i][j] = Σ a[i][k]*b[k][j];用operator()取代[][](因為[]只能單參數);輸出排成兩列。
練習 8(挑戰)— BigInt 大整數
設計 BigInt(用 std::string 或 std::vector<int> 儲存任意長度整數),實作 +、-、==、!=、<、<<、前綴與後綴 ++。先支援非負整數即可。
- 提示:加法做逐位進位;比較先比位數再逐位比;
<<直接輸出數字字串;++可用*this += BigInt(1)實作。
對應程式碼檔案
| 檔案 | 說明 |
|---|---|
| operator_overload.cpp | Vector2D、Fraction 的完整運算子多載(算術、比較、複合指派、[]、++/--、型別轉換) |
| friend_functions.cpp | Complex 複數、friend 函式、friend class、Temperature/Money 的非成員運算子、何時該用 friend |