🎯 什麼情境該想到我

當你「有一個繼承階層要存進資料庫,想用一張表全裝、查詢時不用 join」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:把整個繼承階層的所有類別都存進同一張表,用一個型別欄位區分每列屬於哪個子類別。

做法要點:

  • 一張表包含所有子類別可能用到的欄位;某列用不到的欄位就留空(NULL)。
  • 用一個 type / discriminator 欄位標明該列對應的具體類別。
  • 讀寫任何子類別都不需要 join,查詢快、對應簡單。
  • 三種繼承對應中最省 join 的一種。

★★ 先講 Go:這個模式的前提在 Go 不成立

// ★ Go 沒有繼承,而嵌入沒有虛擬分派(見 [[工具-Go嵌入機制]])
type Base struct{}
func (b Base) Name() string  { return "base" }
func (b Base) Greet() string { return "hi, " + b.Name() }   // ★ b 永遠是 Base
type Child struct{ Base }
func (c Child) Name() string { return "child" }
Child{}.Greet()   // ★★ "hi, base"

★★ 所以「把繼承階層存進資料庫」這個問題,在 Go 裡換成
「把一組介面實作/判別聯集存進資料庫」。

★ 三種對應方式仍然適用,只是「子類」變成「實作型別」(見 以子類取代型別碼)。

★ Go 的落地:一張表 + kind 欄位 + 分派

CREATE TABLE payments (
    id     text PRIMARY KEY,
    kind   text NOT NULL,                 -- ★ 'card' | 'transfer' | 'voucher'
    amount bigint NOT NULL,
    -- ★ card 專屬
    card_last4  text,
    card_brand  text,
    -- ★ transfer 專屬
    bank_code   text,
    account_no  text,
    -- ★★ 每個子型別用不到的欄位都是 NULL
    CHECK (
      (kind = 'card'     AND card_last4 IS NOT NULL AND bank_code IS NULL) OR
      (kind = 'transfer' AND bank_code  IS NOT NULL AND card_last4 IS NULL) OR
      (kind = 'voucher')
    )   -- ★★ 這個 CHECK 是單表繼承唯一能挽回資料完整性的手段
);

★★ 那個 CHECK 約束是這個模式的關鍵:
沒有它,你的表可以存在「kind=‘card’ 但有 bank_code」的列——
而這種資料一旦出現,就沒有任何程式碼能正確處理它。
⚠️ 但 CHECK 會隨子型別增加而膨脹,而且改子型別要 migration。

// ★ Go 端的讀取:一次查詢,依 kind 分派
func fromRow(r db.Payment) (Payment, error) {
    switch r.Kind {
    case "card":
        return CardPayment{Amount: r.Amount, Last4: r.CardLast4.V, Brand: r.CardBrand.V}, nil
    case "transfer":
        return TransferPayment{Amount: r.Amount, BankCode: r.BankCode.V}, nil
    }
    return nil, fmt.Errorf("unknown payment kind %q", r.Kind)   // ★★ 一定要處理
}

★★ default 分支不可省略:Go 沒有窮盡檢查,
而資料庫裡可能有一個新版程式碼寫入的 kind,而舊版 pod 還在跑(滾動部署)。

★★ 三種繼承對應一次比較(這是本頁最重要的表)

單表繼承類別表繼承具象表繼承
表數★★ 11 + N(父 + 每個子)N(每個具體類一張)
讀取一筆★★ 0 次 JOIN★ N 層 JOIN★ 0 次 JOIN(但要先知道型別)
查「所有子型別」★★ 一次查詢一次 JOIN★★ UNION N 張表
欄位 NULL★★ 很多★ 無無
非空約束★★ 做不到(只能靠 CHECK)★ 可以★ 可以
加一個子型別★ ALTER TABLE 加欄位★ 新增一張表★ 新增一張表
改共同欄位★★ 改一處★ 改一處★★ 改 N 張表
空間★ 浪費(NULL 也佔位,但 Postgres 有 null bitmap 較省)省省
★ 適合★★ 子型別少、差異小、查詢多差異大、要約束子型別間幾乎不共查

★★ 判準(按重要度):

① ★★ 你多常「跨子型別一起查」?
   很常(列出所有付款)→ ★ 單表繼承(一次查詢)或類別表繼承
   幾乎不會 → 具象表繼承
② ★★ 子型別的差異有多大?
   小(2–3 個專屬欄位)→ ★★ 單表繼承
   大(十幾個)→ ★ 類別表繼承(否則表會有一半是 NULL)
③ ★ 需要非空約束嗎?
   需要 → ★★ 單表繼承做不到(只能 CHECK)

★ 什麼時候單表繼承是明確的錯誤選擇

  • ★★ 子型別超過 5–6 個,且各有 5+ 專屬欄位 → 表會有 40 個欄位、90% 是 NULL。
  • ★ 子型別的數量會持續成長(外部決定的支付方式)→ 每次都要 ALTER TABLE。
  • ★ 需要每個子型別各自的唯一索引/外鍵 → 部分索引(partial index)能救一部分:
    CREATE UNIQUE INDEX ON payments (bank_code) WHERE kind = 'transfer';
    ★ 但這個技巧本身就是在補單表繼承的洞。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 表會有很多空欄位,浪費空間、語意較弱、不易加非空約束。

  • 所有子類別欄位擠一張表,欄位多時難維護、容易變寬表。

  • 想正規化、少空欄位改用類別表繼承;想各自獨立表改用具象表繼承。

  • ★★ 沒有 CHECK 約束時,表可以存在「kind 與欄位不一致」的列——而那種資料沒有任何程式碼能正確處理。

  • ★★ 無法對子型別專屬欄位加 NOT NULL。這是這個模式最根本的損失。

  • ★★ default 分支不可省略:滾動部署期間,新版可能寫入舊版不認識的 kind。

  • ★ 子型別超過 5–6 個且各有多個專屬欄位 → 用 類別表繼承,否則表 90% 是 NULL。

  • ★ CHECK 約束會隨子型別增加而膨脹,改子型別要 migration。

  • ★ 部分索引(WHERE kind = 'x')能補一部分洞,但那本身就是在補這個模式的缺陷。

  • 子型別數量由外部決定(第三方支付方式)→ 不要用單表繼承,每次都要 ALTER TABLE。

  • Go 沒有繼承,這裡的「子類」是介面實作或判別聯集(見 以子類取代型別碼)。

🔗 相關工具