「契約寫的是必須做到什麼,從不規定你是誰;認人,先認他兌現契約的方式。」
——《阿帕契開源審計錄》¹ 卷二·多型契約篇
幕間
狼窩裡,三號指著二號怒斥:「你到底是不是狼?你三天沒推過任何人!」
二號平靜覆誦:「我三天沒推過人……因為我在等乾淨的點出手。」
敘事者連忙出來打圓場:「都自己人,別內鬨。」
第四世的第一夜,法官的聲音照舊在大廳裡落下:九人局,三狼三神三民,屠邊、絕發、警長一票半。規則我已經聽過三輪,一個字都沒變。
我越來越覺得,能讓九個人、一世又一世都卡在同一套字句裡動不了半個字的,不會只是個報時的東西——法官比他那把法槌想讓人以為的,要龐大得多。
可是我終於看懂一件以前忽略的事:同一份規則書,九個人有九種打法。一號開口就報座位、報時間、報結論,像在讀一份格式固定的表單;四號句子鬆散,繞了三圈才把懷疑講出口;坐我斜對面的二號沒有第一個提刀口,等三號把目標講完,他才接一句「那就照三號說的」。
輪到七號時,她沒急著發言。她從袖袋裡抽出一卷捲得發皺的羊皮紙,又摸出一截炭筆,在紙角寫了兩行——我瞄到的只有「誰先開口」「誰附和」六個字。她不推理,不下判斷,只記錄發言的順序。
九個人,同一套契約,九種兌現方式。這在程式裡,就是多型(polymorphism)——呼叫端只認契約,執行期才綁定到不同的實作。那麼,四大語言各自怎麼把「一份契約、多種實作」這件事撐起來?
多型的關鍵不在「有幾個子類別」,而在呼叫端能不能只依賴抽象、完全不碰具體型別。達成這件事有兩條哲學:一條靠繼承,把行為掛在型別的血緣上;一條靠組合,把行為當成零件拼進來。四大語言剛好落在這條光譜的不同位置。
這件事在開源協作裡尤其要緊。當一個 PR 送進 Kafka 或 Kubernetes,維護者不可能逐行重讀所有呼叫端;他們信任的是「呼叫端只依賴介面,換掉任何一個實作都不會爆」。反過來,前幾世我當 AI 魁儡時最常犯的錯,就是讓一段程式碼直接依賴某個具體結構的內部欄位——表面上能跑,一旦那個結構改版,牽一髮動全身。多型不是炫技,它是把「未來會有別種實作」這件事先講清楚,讓改動被侷限在一個點上。這也對應到牌桌:我不該假設「四號一定是平民」,而該問「不管他是什麼身分,他的發言有沒有兌現一個誠實玩家該有的形狀」。
Java 的多型建立在類別階層之上。你用 interface 定義契約,用 implements 明白宣告「我簽了這份契約」,用 extends 繼承一個共同基底來複用程式碼。呼叫端拿到的是介面型別,執行期由 JVM 透過虛擬方法表(vtable)動態分派到真正的實作。
// NightRole is the shared contract every player-piece must honour.
interface NightRole {
String act(int round);
}
abstract class GodRole implements NightRole {
protected final String seatId;
protected GodRole(String seatId) { this.seatId = seatId; }
// Shared behaviour inherited by every concrete god subclass.
public String describe() { return "god at seat " + seatId; }
}
final class Seer extends GodRole {
Seer(String seatId) { super(seatId); }
@Override
public String act(int round) {
return "seat " + seatId + " inspects one player on round " + round;
}
}
血緣式繼承的代價是耦合:Seer 一旦繼承 GodRole,就永遠綁死在這條階層上,父類別任何一個保證改了,整條子孫都要跟著檢查。這也是為什麼現代 Java 專案(包含 Apache Kafka)多半「面向介面、少用實作繼承」——介面只描述能力,不強加身世。里氏替換原則講的就是這件事:任何拿到 NightRole 的地方,塞進 Seer 還是別的實作,行為都必須說得通,不能因為換了子類別就出現呼叫端沒預期的副作用。Java 從 8 開始給介面加了預設方法,某種程度上也是想在「顯式契約」和「共用實作」之間找平衡。
Go 沒有 implements 這個字。一個型別只要擁有介面要求的方法,它就自動滿足那個介面——這叫結構化型別(structural typing),俗稱鴨子型別。程式碼複用不靠繼承,而靠嵌入(embedding):把一個結構體塞進另一個結構體,方法就被提升上來,這是組合不是血緣。
// NightRole is satisfied implicitly: any type with act(int) qualifies.
type NightRole interface {
act(round int) string
}
type Seer struct{ seatID string }
func (s Seer) act(round int) string {
return fmt.Sprintf("seat %s inspects one player on round %d", s.seatID, round)
}
// baseRole holds the field every role shares; embedding promotes it, no inheritance involved.
type baseRole struct{ seatID string }
// Hunter reuses baseRole's field by embedding, not by inheriting.
type Hunter struct {
baseRole
}
func (h Hunter) act(round int) string {
return fmt.Sprintf("seat %s keeps the last bullet until round %d", h.seatID, round)
}
Kubernetes 的原始碼把這一點用到極致:一個型別可以「無意間」滿足十幾個小介面,只要它剛好有那些方法。介面因此可以拆得很小、很專一,呼叫端要什麼契約就宣告什麼契約——io.Reader、io.Writer 這種只有一個方法的介面,就是這種風格的極致。它的好處是解耦徹底,你寫一個新型別時根本不必知道有哪些介面存在;缺點是契約關係從程式碼表面看不出來,得靠工具或文件輔助。Go 也允許在介面定義裡嵌入其他介面,用組合的方式疊出更大的契約,而不是用繼承。
Rust 的 trait 介於兩者之間:契約要明白 impl NightRole for Seer 才算數(像 Java 那樣顯式),但它不是階層,而是可以往任何既有型別上「補掛」的能力集合。更關鍵的是分派方式的選擇權在你手上:用泛型就是靜態分派,編譯期把每個具體型別各生成一份程式碼,零額外成本;用 dyn NightRole 才是動態分派,走 vtable。
// NightRole is an explicit contract; each type opts in with `impl ... for`.
trait NightRole {
fn act(&self, round: u32) -> String;
}
struct Seer {
seat_id: String,
}
impl NightRole for Seer {
fn act(&self, round: u32) -> String {
format!("seat {} inspects one player on round {}", self.seat_id, round)
}
}
// `&dyn NightRole` picks dynamic dispatch; a generic `<T: NightRole>` stays static.
fn announce(role: &dyn NightRole, round: u32) {
println!("{}", role.act(round));
}
Apache DataFusion 用 trait 把「一個運算子如何執行」抽象出來,讓不同的實體算子共用同一組查詢計畫介面,卻各自享有靜態分派的效能。Trait 還能有預設方法,也能為別人寫的型別補掛能力(只要遵守孤兒規則,避免衝突)。這讓 Rust 的多型同時具備「契約明確」和「不必動到原始型別」兩個優點,代價是你得在泛型與 dyn 之間自己決定要用哪種分派,編譯器不會替你選。
Python 執行期本來就是鴨子型別:你把任何有 act() 的物件丟進去都能跑。問題是這種契約在原始碼裡看不見。typing.Protocol(PEP 544)補上這一塊——你寫一個 Protocol 類別描述期望的方法,型別檢查器就能在 CI 階段驗證「這個物件符不符合契約」,而被檢查的類別完全不需要繼承它。
from typing import Protocol
# NightRole is structural: any object with a matching act() satisfies it,
# and a type checker verifies that without any explicit subclassing.
class NightRole(Protocol):
def act(self, round: int) -> str: ...
class Seer:
def __init__(self, seat_id: str) -> None:
self.seat_id = seat_id
def act(self, round: int) -> str:
return f"seat {self.seat_id} inspects one player on round {round}"
def announce(role: NightRole, round: int) -> None:
print(role.act(round))
Apache Airflow 這類大量使用 plugin 的專案,正是靠這種「結構化契約」讓第三方擴充不必綁死在某個基底類別上。

同一份契約、多種兌現方式,這件事不只發生在程式語言的型別系統裡。就拿撰寫這整個系列所使用的 Claude Code 來說——它的 Skill(技能) 機制本身就是一個活生生的多型範例。每個 Skill 是一份獨立的 Markdown 檔案,開頭固定一段 YAML frontmatter,只宣告兩件事:name(技能名稱)與 description(描述何時該被觸發)。呼叫端(也就是模型本身)在決定要不要載入某個 Skill 時,只讀這份極輕量的「介面」,完全不需要知道技能內部實際上是要跑一段 yt-dlp 指令、審核 Obsidian 的雙向連結,還是驅動 NotebookLM 產生播客——那些都是各自技能封裝在 Markdown 主體裡的具體實作,彼此毫無繼承關係,也不共享任何基底類別。
這正對應到本篇談的核心分野:Go 的隱式介面滿足,看的也是「有沒有這個方法」而非「有沒有共同祖先」;Claude Code 的 Skill 系統則是把同一套哲學搬到「自然語言任務」的層次——呼叫端只認 name 與 description 這份最小契約,執行期才根據使用者的意圖動態分派到對應的 Skill 內文:
flowchart TB
Intent["User intent / trigger phrase"] --> Dispatch["Skill dispatcher(只讀 frontmatter 契約)"]
Dispatch --> S1["vault-curator.md(實作:稽核 Obsidian)"]
Dispatch --> S2["youtube-research.md(實作:yt-dlp 轉錄)"]
Dispatch --> S3["notebooklm-pipeline.md(實作:驅動 NotebookLM)"]
換句話說,多型不是程式語言的專利,而是任何「呼叫端只依賴一份最小契約、執行期才決定實際行為」的系統都會自然長出的形狀——無論那個系統底層是 JVM 的 vtable,還是一個讀 Markdown frontmatter 的代理人調度器。
天亮前我把七號那兩行字放進心裡重看一遍。她沒有猜任何人的身分,只標了「誰先開口、誰附和」。而我盤點下來,二號從頭到尾沒有一次先開口,他每一輪都等別人把刀口講完才附議——像一個只實作了「附和」這個方法、卻從沒實作「提案」的型別。
這一世我學到的是:讀一個玩家,不是聽他講什麼結論,而是看他兌現「發言」這份契約的方式——他依賴誰、綁定在誰身上、什麼時候才動。多型讓呼叫端不必知道對面站的是真預言家還是悍跳狼,只要求他對同一個介面交出行為;牌桌上,我也該用同一把尺去量每個人。
七號還沒有做任何推論,她只是把「發言的順序」當成一份可以事後查閱的紀錄留下來。而我這一整晚忙著猜身分、忙著記結論,反而漏掉了這個最不會騙人的訊號。四種語言用四種方式回答「如何讓一份契約撐住多種實作」,但它們的共同前提都一樣:先把契約本身寫下來,實作才有東西可以對齊。這正是這一階段我該練的,也是我們 2N1P 這一輪的起點——先記錄形狀,再談內容。
讀完這篇,你應該能把一個行為抽成 interface / trait / Protocol,並寫出兩個以上互不繼承的實作,讓呼叫端只依賴契約。想再打底,roadmap.sh 的後端與程式語言路線、以及 Rust 官方 Book 的 trait 章節都有完整練習。
typing.Protocol 官方提案)
Go structural typing implicit interface、Rust trait static vs dynamic dispatch dyn、Python typing Protocol PEP 544 structural subtyping、Java interface vs abstract class polymorphism vtable、Claude Agent Skills architecture
¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。