Swift 與 Objective-C

方法簽名包含回傳型別嗎?

被問到「什麼是方法簽名?」時,多數人會含糊地回答:「就是方法名稱和參數……之類的。」

閱讀 6 分鐘
方法簽名包含回傳型別嗎? 封面圖

被問到「什麼是方法簽名?」時,多數人會含糊地回答:「就是方法名稱和參數……之類的。」

這樣說並沒有錯,但再深入一步就會分出差異。「那回傳型別包含在簽名裡嗎?」從這裡開始,答案往往變得不確定。這不只是技術面試的常見題目,也是判斷為何能不能多載的關鍵。

更有趣的是,這個問題的答案依語言而異。在 Java 中,回傳型別不屬於簽名;但在 Swift 中,即使只有回傳型別不同,也能多載。

本文將依序整理簽名的精確定義、各語言的差異,以及簽名在實務上為何重要。


簽名是方法的身分證

方法簽名簡單說就是編譯器用來區分這個方法與其他方法的識別資訊

把它想成人的身分證即可。只靠姓名無法區分同名者,因此身分證除了姓名,還會包含出生日期等額外資訊。方法也是如此。同名方法可以有多個(多載),所以必須用名稱以外的資訊精確指定其中一個。

一般而言,構成簽名的元素如下。

  • 方法名稱
  • 參數型別
  • 參數數量
  • 參數順序
// 這四個方法的簽名都不同
void print(int value)              // print(int)
void print(String value)           // print(String) — 型別不同
void print(int a, int b)           // print(int, int) — 數量不同
void print(String s, int n)        // print(String, int)
void print(int n, String s)        // print(int, String) — 順序不同

有一點要注意:參數名稱不是簽名print(int value)print(int number)具有相同的簽名。對編譯器而言,只看呼叫端的print(3)無法加以區分。

存取控制修飾詞(publicprivate)、staticfinal和例外宣告(throws)也不包含在簽名中。簽名只包含「決定要呼叫哪個方法所需的資訊」。


所以,回傳型別包含在內嗎?

以 Java 來說,答案是不包含。Java 語言規格(JLS 8.4.2)將簽名定義為「方法名稱和引數型別」。

因此,只有回傳型別不同的多載會造成編譯錯誤。

int parse(String input) { ... }

// 編譯錯誤: 'parse(String)' is already defined
String parse(String input) { ... }

看看呼叫端,就能理解這樣設計的原因。

parse("42");  // 如果不接收回傳值,就無法決定要呼叫哪個 parse

在 Java 中,忽略回傳值並呼叫方法永遠是允許的。此時,編譯器沒有依據判斷兩個parse該呼叫哪一個。因此,回傳型別乾脆被排除在簽名之外。C++ 也基於相同理由禁止只有回傳型別不同的多載。

有個值得知道的細節。在 JVM 位元組碼層級,回傳型別也包含在區分方法的資訊中。在類別檔案裡,方法會以parse(Ljava/lang/String;)I這類描述元識別,最後的I就是回傳型別(int)。也就是說,「回傳型別不是簽名」是 Java語言的規則,不是 JVM 這個執行環境的規則。泛型橋接方法等機制之所以可行,也多虧了這個差異。

方法簽名組成元素圖 — 名稱、參數型別、數量與順序是 Java 和 Swift 共通包含的項目;引數標籤與回傳型別只有 Swift 包含
同一個問題在 Java 和 Swift 有不同答案 — 橘色的兩格就是語言差異

Swift 的答案不同

把同樣的問題套到 Swift,答案會反過來。在 Swift 中,即使只有回傳型別不同也能多載

func random() -> Int { Int.random(in: 0...100) }
func random() -> Double { Double.random(in: 0...1) }

let n: Int = random()     // Int 呼叫版本
let x: Double = random()  // Double 呼叫版本

Swift 編譯器會連呼叫結果被當成哪種型別使用(型別脈絡)也一併參考,以解決多載。但沒有脈絡時就會發生錯誤。

let value = random()  // 編譯錯誤: ambiguous use of 'random()'

Swift 的簽名還有一個獨特元素:引數標籤(argument label)是函式名稱的一部分

func move(from start: Point, to end: Point) { ... }
func move(in direction: Direction) { ... }

這兩個函式的正式名稱不是move,而是move(from:to:)move(in:)。即使參數型別相同,只要標籤不同就是不同函式。這和 Java 完全相反,Java 會將參數名稱從簽名中完全排除。

總結如下。

組成元素 Java Swift
方法名稱 包含 包含
參數型別・數量・順序 包含 包含
參數名稱(標籤) 不包含 包含(引數標籤)
回傳型別 不包含 包含(由脈絡解決)

如果被問到「回傳型別包含在簽名裡嗎?」時,能回答「依語言而異,Java 不包含、Swift 包含」,就表示你真正理解了這個概念。


簽名在實務上重要的三個場景

只知道定義就結束,這只是考試知識。簽名實際發揮作用的場景另有其處。

場景 1 — 判斷多載與覆寫的基準

多載是「相同名稱、不同簽名」,覆寫則是「重新實作父類別的相同簽名」。沒有簽名,這兩個概念都無法定義。

覆寫時只要簽名有細微錯誤,編譯器就會將它解讀為新的多載,而不是覆寫。父方法仍然正常存在,你的方法卻不會被呼叫,形成安靜的錯誤。Java 的@Override與 Swift 的override關鍵字,正是為了在編譯時捕捉這類事故。

場景 2 — 採用介面・協定

實作介面(協定)歸根究柢就是提供與要求的簽名完全一致的方法

在 Swift 中實作了委派方法卻沒有被呼叫時,很多都是簽名不一致。參數是否為 optional、標籤是for還是at,這種微小差異都會讓它變成不同於協定需求的獨立方法。與編譯器會檢查的必要需求不同,選用需求會被靜默忽略,因此更難找出問題。

場景 3 — 變更簽名就是破壞相容性

建立函式庫或團隊共用模組時,公開方法的簽名是與外界訂立的契約

新增一個參數、將型別從Int改成Int64,或在 Swift 中只修改一個引數標籤,都會讓所有呼叫該方法的程式碼失效。這就是 breaking change(破壞相容性的變更),也是語意化版本控制中需要提升主版本的代表理由。

因此,成熟的函式庫不修改簽名,而是新增使用新簽名的方法,並將既有方法維持為 deprecated。它們不會單方面撕毀既有簽名這份契約。

修改公開 API 契約後程式碼橋梁崩塌的 breaking change 插圖 — 方法簽名變更造成的相容性破壞
一旦在公開簽名上落筆,曾呼叫該方法的橋梁就會崩塌

設計簽名時要記住的事

從簽名是契約的觀點出發,設計基準也會變得清楚。

**請以公開一次後就難以修改為前提來設計簽名。**內部方法可以隨意修改,但 public API 的簽名應在首次公開前仔細思考。之後修改的成本會高出數十倍。

**如果參數可能增加,就把它們整理成型別。**擁有 4~5 個參數的簽名容易造成順序錯誤,每增加一個參數就會成為 breaking change。將參數包在設定物件或結構中,就能在維持簽名的同時擴充。

**相同型別的參數連續出現,就是危險訊號。**如果只看簽名無法知道transfer(account1, account2)哪一個是提款帳戶,在 Swift 中可以使用transfer(from:to:)這類標籤;在 Java 中則可用有意義的型別包裝參數,讓簽名本身說明用法。


總結

  • 方法簽名是編譯器用來區分方法的識別資訊,共同核心是名稱加上參數的型別、數量與順序。
  • 是否包含回傳型別因語言而異。Java・C++ 不包含(不能只靠回傳型別不同來多載),Swift 包含(由型別脈絡解決);Swift 的引數標籤也是函式名稱的一部分。
  • 簽名是判斷多載・覆寫的基準、實作介面的匹配條件,也是公開 API 中與外界訂立的契約
  • 變更公開簽名就是 breaking change。與其修改,不如新增;而且在最初設計時就要投入最多心力。

延伸閱讀