seansie's blog

C++ 多載介紹 (運算子多載&函數多載) | seansie blog

簡介

多載(overloading)這個概念有點像是 破音字 的概念,例如”吃”這個字,在”吃東西”這個語境下,念(音:痴),而在”口吃”這個字念(音 : 級),同一個字,可以根據上下文來決定不同意義。

而C++的多載也一樣,也就是同一個東西(例如函數、運算子等等),可能會根據上下文來決定不同的意義。

函數多載

為什麼要用這個?

想像一下,我們要實作一個相加(這只是為了舉例方便而已,實際上更難)的函數,而因為 C++有不同的 資料型態 ,所以每個資料型態都要分別定義自己的函數。

int addInt(int a, int b) {
    return a + b;
}

double addDouble(double a, double b) {
    return a + b;
}

float addFloat(float a, float b) {
    return a + b;
}

string addString(string a, string b) {
    return a + b;
}

然後要使用的時候,還要分別記住 addIntaddDoubleaddFloataddString,不僅沒有效率,這樣要用的時候還容易出錯,且程式碼會變得冗長、難以維護。

int main() {
    int intResult = addInt(5, 3);          // 使用 addInt
    double doubleResult = addDouble(2.5, 1.5); // 使用 addDouble
    float floatResult = addFloat(1.0f, 2.0f);   //使用addFloat
    std::string stringResult = addString("Hello", " World"); // 使用 addString

    // ... (使用這些結果) ...
    return 0;
}

多載解決方案

其實在C++中,編譯器是允許相同名字的函數的,如同開頭的破音字譬喻一樣,名字一樣沒關係,只要有能分辨的上下文就可以。

那怎麼分辨呢? 用 **函數原型 ,**來分辨,如下所示。

// int version
int add(int a, int b) {
    return a + b;
}

// double version
double add(double a, double b) {
    return a + b;
}

// float version
float add(float a, float b) {
    return a + b;
}

// string version (concatenation)
std::string add(const std::string& a, const std::string& b) {
    return a + b;
}

在上面這個程式碼中,讓我們來看看每個函數的原型。

  • 第一個 int add(int,int)
  • 第二個 double add(double,double)
  • 第三個 float add(float,float)
  • 第四個 string add(string,string)

很明顯,每個原型都不一樣,而多載的上下文就是原型,因此這四個 同名 函數是可以合法的存在的。

而怎麼使用呢,如下所示

int main() {
    std::cout << add(5, 3) << std::endl;          // Output: 8 (int)
    std::cout << add(2.5, 3.7) << std::endl;      // Output: 6.2 (double)
    std::cout << add("Hello", " World") << std::endl; // Output: Hello World (string)
    return 0;
}

在這個例子中,第一行中 add(5,3) ,裡面的參數可以被識別出來是 (int,int) 的資料型態,因此能根據剛剛的上下文,推論出要執行這個int add(int,int) 對應的函數。

注意: const intint 在多載函數中的原型下,會被視為相同的!

預設參數

有時候,有些約定俗成的東西,一直重複真的很麻煩 (請完成這個句子) ,不如直接設定成預設參數,省時省力。

void greet(const std::string& name, const std::string& greeting = "Hello", const std::string& punctuation = "!") {
  std::cout << greeting << ", " << name << punctuation << std::endl;
}

在這個打招呼的函數 greet ,大家打招呼除非另有要求,不然大概都是千篇一律的 "Hello" 吧,此外,結尾的標點符號,也通常是 ! ,這些在上面的函數就可以設定成 預設參數

而設定的方法就是在函數定義中,在參數定義後面加上 std::string& greeting = "Hello" ,就是後面的 = "Hello"

如此一來,如果這個參數沒有填入,就會自動填入預設值,極為 "Hello"

int main() {
  // 使用所有參數
  greet("Alice", "Good morning", ".");

  // 只提供名字,使用預設的問候語和標點符號
  greet("Bob");

  // 提供名字和問候語,使用預設的標點符號
  greet("Charlie", "Hi");
    
  //不好的做法,一直重複"How are you", "?"
  greet("Dave", "How are you", "?");
  greet("Eve", "How are you", "?");

  return 0;
}

運算子多載

最常見的例子

在C+++中, string 這個物件應該非常常用吧,應該不少人已經用過了其運算子多載的功能。

#include <iostream>
#include <string>

int main() {
    std::string s1 = "Hello, ";
    std::string s2 = "world!";
    std::string s3 = s1 + s2;       // 使用 + 连接
    std::cout << s3 << std::endl; // 输出: Hello, world!
    return 0;
}

可是在C++運算子的介紹, + 理論上只能針對數字阿,字串怎麼能相加呢?

因此,其實字串根本沒有數學意義上的相加,而是呼叫了類似 strcat 的字串合併函數,並且多載到 + 運算子上,其上下文是該運算子的左邊跟右邊的運算元,這個例子下是 string+string

如此一來,如果當編譯器讀取到 foo+bar ,而確定 foobar 都是 string 物件後,就會呼叫多載的對應函數,而對於 string 函數來說, 相加的意思就是把兩個字串相連接,因此會呼叫 連接(foo,bar) ,然後該函數會回傳相連接好的字串。

怎麼使用

這裡以分數運算的例子為例,然後因為篇幅考量,本次只實作輸入輸出、乘法與除法,因為比較簡單。

首先先定義分數的類別

class frac{
	public:
		frac(int a,int b){
			this->nu=a;
			this->de=b;
			 if (b == 0) {
        throw std::invalid_argument("Denominator cannot be zero."); // 分母不可為0
	    }
		}
		int getNu(){
			return nu;
		}
		int getDe(){
			return de;
		}
	private:
		int nu,de // nu=numerator (分子) de=demoninator (分母)
}

其中,按照慣例資料要封裝起來,因此 nude 即分母與分子,要被放在 private 中,限制存取。

而分母跟分子還是要有介面可供外界存取,因此在 public 中提供個兩個函數(介面),即 getNugetDe

然後還要有建構子來方便的初始化分數物件,順帶一題 this 關鍵字在建構子是個指標,指向欲建立的新物件,因此如果要透過 this 來存取其內部成員,會用 this->nu 裡面的 -> 箭頭運算子。

多載運算子語法

在類別定義內,如果要定義多載函數,語法如下

friend 回傳的物件 operator 要多載的運算子 (const 物件參考 , const 物件參考 ,,,,,,){函數本體}
  • friendC++ 的關鍵字,是讓這個函數宣告既在類別定義中,而又可以存取類別內部的私有成員,就如同朋友一樣。
  • operator 要多載的運算子 : 例如 string 的相加多載運算子就要寫成 operator +
  • (const 物件參考 , const 物件參考 ,,,,,,) :

這裡面是參數清單,最佳實踐是用 const 參考,而有幾個參數取決於運算子性質,例如一元運算子(e.g. 負號 -10 參數只有一個),這樣參數就只有一個。

而二元運算子 (e.g. 加號 1+2 參數有 1 和 2 有兩個),這樣參數就會有兩個,而順序就跟 1 跟2 一樣,例如 1+2 就會變成 operator+(1,2)

為甚麼要用參考? 因為有些時候物件很大,用參考可以減少複製的時間提升效率。

分數物件多載乘除實作

friend frac operator*(const frac& lhs, const frac& rhs) {
        return frac(lhs.nu * rhs.nu, lhs.de * rhs.de);
    }

    // Friend function definition for division (inside the class)
    friend frac operator/(const frac& lhs, const frac& rhs) {
        if (rhs.nu == 0) {
            throw std::invalid_argument("Cannot divide by zero-fraction.");
        }
        return frac(lhs.nu * rhs.de, lhs.de * rhs.nu);
    }

其中 lhsrhs 是英文 左手邊跟右手邊的意思 ,例如 1/2+1/3 中,左手邊是 1/2

friend與 public是可以存取 任何從frac類別實體化的物件 !! 不要誤會成他只能存取該物件自己,他是可以存取很多物件的,原因很簡單,因為她是在類別(模板、藍圖)定義,而每個根據該類別產生出來的物件都是相同類別的,當然也要被一視同仁。

分物數件輸入輸出實作

為了讓 frac 類別更完整,我們需要能夠方便地輸入和輸出分數。這可以透過多載 << (輸出運算子) 和 >> (輸入運算子) 來實現。

C++

#**include** <iostream>
#**include** <sstream> // 用於 stringstream#**include** <numeric> // 用於 std::gcd (C++17) 或 __gcd (C++11/14)class frac {
.... 中略
public:
    // ... (其他程式碼保持不變) ...
    frac(int a, int b) {
        this->nu = a;
        this->de = b;
        if (b == 0) {
            throw std::invalid_argument("Denominator cannot be zero."); // 分母不可為0
        }
        reduce(); // 在建構子中加入化簡
    }

    // Friend function for output (<< operator)
    friend std::ostream& operator<<(std::ostream& os, const frac& f) {
        os << f.nu << "/" << f.de;
        return os;
    }

    // Friend function for input (>> operator)
    friend std::istream& operator>>(std::istream& is, frac& f) {
        char slash; // 用來讀取 '/' 字元
        is >> f.nu >> slash >> f.de;
        if (slash != '/') {
            is.setstate(std::ios::failbit); // 設定錯誤狀態
        }
        if (f.de == 0) {
            throw std::invalid_argument("Denominator cannot be zero.");
        }
        f.reduce(); // 輸入後化簡
        return is;
    }
    

private:
    int nu, de; // nu = numerator (分子), de = denominator (分母)
};

程式碼說明:

  1. operator<< (輸出運算子多載):
    • friend std::ostream& operator<<(std::ostream& os, const frac& f):
      • friend: 允許這個函數存取 frac 的私有成員。
      • std::ostream& os: 參考到一個輸出串流物件 (例如 std::cout)。
      • const frac& f: 參考到要輸出的 frac 物件 (使用 const 表示不會修改它)。
      • os << f.nu << "/" << f.de;: 將分數以 “分子/分母” 的形式輸出到串流。
      • return os;: 傳回輸出串流物件,以便進行串接 (例如 std::cout << f1 << " " << f2;)。
  2. operator>> (輸入運算子多載):
    • friend std::istream& operator>>(std::istream& is, frac& f):
      • friend: 允許這個函數存取 frac 的私有成員。
      • std::istream& is: 參考到一個輸入串流物件 (例如 std::cin)。
      • frac& f: 參考到要讀取資料的 frac 物件 (這裡 沒有 const,因為我們會修改它)。
      • char slash;: 宣告一個字元變數來讀取預期的 ‘/’ 字元。
      • is >> f.nu >> slash >> f.de;: 從輸入串流讀取分子、斜線、分母。
      • if (slash != '/') { is.setstate(std::ios::failbit); }: 檢查是否成功讀取到 ‘/’,如果沒有,設定輸入串流的錯誤狀態。
      • 如果分母為0會丟出例外。
      • f.reduce():對分數進行化簡。
      • return is;: 傳回輸入串流物件,以便進行串接。
    • 錯誤處理:
      • 如果輸入格式不正確 (例如 “1/2/3” 或 “1a2”),std::istream 的狀態會被設定為 failbit,表示輸入失敗。
      • 如果分母為 0 會丟出例

使用範例:

C++

#**include** <iostream>
#**include** <stdexcept>// ... (frac 類別定義) ...

int main() {
    frac f1(3, 6); // 建立分數 3/6
    frac f2(1, -2); // 建立分數 1/-2

    std::cout << "f1: " << f1 << std::endl; // 輸出: f1: 1/2
    std::cout << "f2: " << f2 << std::endl; // 輸出: f2: -1/2

    frac f3;
    std::cout << "Enter a fraction (numerator/denominator): ";
    std::cin >> f3; // 輸入分數

    if (std::cin.fail()) {
        std::cerr << "Invalid input format!" << std::endl;
    } else {
        std::cout << "You entered: " << f3 << std::endl; // 輸出輸入的分數
    }

    frac f4 = f1 * f2;
    std::cout << "f1 * f2: " << f4 << std::endl;

    try {
        frac f5 = f1 / f2;
        std::cout << "f1 / f2: " << f5 << std::endl;
    } catch (const std::invalid_argument& e) {
        std::cerr << "Error: " << e.what() << std::endl;
    }
    return 0;
}

總結

透過多載 <<>> 運算子,我們可以像使用基本資料型態 (如 intdouble) 一樣,自然地使用 std::coutstd::cin 來輸入和輸出 frac 物件。這使得程式碼更易讀、更易用。同時,加入了化簡和錯誤處理,使 frac 類別更加健壯。

與多型的關係

C++ 運算子多載是多型的一種,屬於編譯時多型(靜態多型/特定多型),是在 運算子上的多型 ,因為相同介面,但是不同操作,即用相同方法做不同事情。

它允許程式設計師重新定義運算子(如 +-==)的行為,使其能用於自定義的類別。

編譯器在編譯階段會根據運算元的類型,決定使用內建的運算子定義,還是類別中多載的運算子函數(例如 string+string 可以推論出其實要用字串加法,而不是內建的加法)。

這種機制讓程式碼更簡潔、直觀,因為可以用相同的運算子符號來操作不同類型的物件,實現不同的功能,展現了「多種形態」的概念,這正是多型的核心。