運算子多載 (Operator Overloading) 讓自定義型別也能使用 +-<<[] 等運算子, 使程式碼更直覺、更貼近數學或自然語言。 它本質上只是「換個語法的函式呼叫」——a + b 其實就是呼叫 operator+(a, b)


學習目標

讀完本章,你應該能夠:

  1. 知道哪些運算子可以被多載,哪些不行,以及背後的原因
  2. 區分「成員函式」與「非成員函式(自由函式)」兩種形式,並知道何時選哪一種
  3. 掌握各運算子的回傳型別慣例(回傳值 vs 回傳參考)
  4. 實作算術運算子 (+, -, *, /) 與一元負號
  5. 複合指派運算子 (+=) 實作算術運算子,避免重複
  6. 實作比較運算子,理解它們之間的關係,並認識 C++20 的 <=>(太空船運算子)
  7. 正確實作串流運算子 (<<, >>),並理解為何必須是非成員 friend
  8. 實作下標 []、函式呼叫 ()、前綴/後綴 ++/--
  9. 實作型別轉換運算子並用 explicit 防止意外轉換
  10. 理解 operator= 應回傳參考、自我指派檢查等規範
  11. 避開常見陷阱(多載 &&/||/, 喪失短路、過度多載等)

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 valueFraction)。
  • 複合指派 += -= …:修改自身,回傳 by referenceFraction&)。

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 boolexplicit 後,在 ifwhile&&|| 等「布林情境」仍可使用,但不會被誤用於算術,這正是 std::unique_ptrstd::optional 的作法。


11. 常見錯誤與陷阱

  1. 忘記自我指派檢查operator= 中若 this == &rhs 仍照常「先刪除再複製」,會讀到已釋放的記憶體。加上 if (this != &rhs) 或用 copy-and-swap。

  2. operator<< 寫成成員函式:會變成 obj << std::cout 的怪語法。串流運算子必須是非成員。

  3. 後綴 ++ 忘了 int 假參數operator++()operator++(int) 靠那個 int 區分,漏掉編譯器無法分辨前後綴。

  4. 回傳型別錯誤operator+ 應回傳新物件(by value);operator+=operator=、前綴 ++ 應回傳 *this(by reference)。搞反會導致無法鏈式呼叫或產生多餘複製。

  5. 多載 &&|| 喪失短路求值:內建的 &&/|| 有「短路」特性(左邊決定結果就不算右邊)。一旦多載成函式,兩個運算元都會先被求值,短路語意消失,可能造成嚴重 bug。幾乎永遠不要多載 &&||

  6. 多載逗號 ,:同樣會破壞內建逗號的求值順序保證,不要多載

  7. 比較運算子不一致==< 的邏輯若彼此矛盾(例如 a==b 為真但 a<b 也為真),會讓 std::sortstd::set 行為錯亂。用 ==/< 推導其餘,保持一致。

  8. 隱式轉換造成歧義:轉換建構子與轉換運算子都沒加 explicit 時,編譯器可能找到多條轉換路徑而報「ambiguous」,或默默選錯。一律審慎使用 explicit

  9. 過度多載 / 語意不直覺:讓 + 做非加法的事、為冷僻型別硬加一堆運算子,會降低可讀性。只在符合慣例時多載。

  10. 回傳區域物件的參考operator+ 千萬不要 return *this; 或回傳區域變數的參考——應回傳全新的值物件。


重點整理

概念 說明
本質 a @ boperator@(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.02.0 * v)、內積、==<<[](索引 0/1)。

  • 提示2.0 * v 的左運算元是 double必須用非成員函式;== 用浮點容差 eps 比較;[] 越界拋 std::out_of_range

練習 3(中級)— Complex 複數類別

實作 Complex(複數),支援 + - * /、一元 -、共軛、模長、==<<,並支援 double + ComplexComplex + double 兩種混合運算。

  • 提示:混合運算用非成員 friend;複數除法 (a+bi)/(c+di) 先乘上分母的共軛;<< 依虛部正負輸出 +-

練習 4(中級)— SafeArray 安全陣列

設計 SafeArray,內部用 std::vector<int>,多載 [] 並加入邊界檢查(越界拋 std::out_of_range),提供 const 與非 const 兩版本,再多載 << 印出所有元素,以及 == 比較內容。

  • 提示[] 兩版本回傳型別分別是 int&const int&<< 走訪元素以空白分隔輸出。

練習 5(中級)— Counter 與前綴/後綴

設計 Counter 類別,正確實作前綴與後綴 ++ / --==<<<。寫一段測試比較 ++cc++ 的回傳值差異,並用註解說明後綴為何較慢。

  • 提示:前綴回傳 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::stringstd::vector<int> 儲存任意長度整數),實作 +-==!=<<<、前綴與後綴 ++。先支援非負整數即可。

  • 提示:加法做逐位進位;比較先比位數再逐位比;<< 直接輸出數字字串;++ 可用 *this += BigInt(1) 實作。

對應程式碼檔案

檔案 說明
operator_overload.cpp Vector2D、Fraction 的完整運算子多載(算術、比較、複合指派、[]++/--、型別轉換)
friend_functions.cpp Complex 複數、friend 函式、friend class、Temperature/Money 的非成員運算子、何時該用 friend