iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

文藝復興:這段程式碼,好像有點味道系列 第 11

Day 11|繼承了畫室招牌,卻不用畫室的技法:拒絕的遺贈 (Refused Bequest)

  • 分享至 

  • xImage
  •  

文藝復興的畫室,招牌不是隨便掛的掛上某位大師的招牌,代表你承諾了這間畫室的一整套技法
濕壁畫怎麼在灰泥未乾前完工、油彩怎麼分層堆疊

客戶看到招牌,就會預期,「不管接的是哪一種案子,這間畫室都能照著承諾的技法完成」

如果掛著招牌,卻在客戶指定某種技法時說「這個我們不接」,那塊招牌,就成了一句謊言

訂單處理,加入了付款方式

系統要支援多種付款方式,第一版設計,是一個共同的父類別:

public abstract class PaymentMethod
{
    public abstract void Charge(decimal amount);
    public abstract void Refund(decimal amount);
}

招牌上寫著「所有付款方式,都支援請款,也都支援退款」

信用卡付款,兩件事都做得到:

public class CreditCardPayment : PaymentMethod
{
    public override void Charge(decimal amount)
    {
        Console.WriteLine($"向信用卡請款 {amount:C}");
    }

    public override void Refund(decimal amount)
    {
        Console.WriteLine($"信用卡退款 {amount:C}");
    }
}

但貨到付款呢?

public class CashOnDeliveryPayment : PaymentMethod
{
    public override void Charge(decimal amount)
    {
        Console.WriteLine($"貨到付款,待配送員收取 {amount:C}");
    }

    // 貨到付款沒有「自動退款」這回事,錢是現金,只能人工處理
    public override void Refund(decimal amount)
    {
        throw new NotSupportedException("貨到付款不支援自動退款,請走人工退款流程");
    }
}

招牌承諾了退款,CashOnDeliveryPayment 卻拒絕兌現

這句拒絕,會在意想不到的地方爆炸

問題不在於「貨到付款真的不能自動退款」,這是事實,沒有錯

問題在於,CashOnDeliveryPayment 明明繼承了 PaymentMethod卻繼承了一個它做不到的承諾

任何一段程式碼,只要拿到一個 PaymentMethod,都會合理地相信可以呼叫 Refund

public void ProcessRefundRequest(PaymentMethod payment, decimal amount)
{
    payment.Refund(amount);
    Console.WriteLine("退款已處理");
}

這段程式碼在信用卡上跑得好好的,換成貨到付款,直接拋出例外

呼叫端完全沒有辦法從 PaymentMethod 這個型別本身,看出「有一種付款方式不支援退款」

招牌上的承諾,只有真的敲客戶的門才會被拆穿,這正是 Day 02 有提到的
里氏替換原則(Liskov Substitution Principle)被打破的樣子

「子類別沒辦法安全地取代父類別,在任何父類別出現的地方使用」

招牌,不該承諾做不到的事

解法不是讓 CashOnDeliveryPayment 硬著頭皮「假裝支援」退款,也不是繼續讓它拋例外

「是重新設計招牌本身,不要承諾所有付款方式都一樣」

第一步,把「請款」跟「退款」拆成兩層,最基礎的招牌,只承諾大家都做得到的事:

public abstract class PaymentMethod
{
    public abstract void Charge(decimal amount);
}

第二步,另外開一塊「支援退款」的招牌,只給真正做得到的付款方式掛:

public abstract class RefundablePaymentMethod : PaymentMethod
{
    public abstract void Refund(decimal amount);
}

第三步,讓每種付款方式,繼承它真正能夠兌現的那塊招牌:

public class CreditCardPayment : RefundablePaymentMethod
{
    public override void Charge(decimal amount) =>
        Console.WriteLine($"向信用卡請款 {amount:C}");

    public override void Refund(decimal amount) =>
        Console.WriteLine($"信用卡退款 {amount:C}");
}

public class CashOnDeliveryPayment : PaymentMethod
{
    public override void Charge(decimal amount) =>
        Console.WriteLine($"貨到付款,待配送員收取 {amount:C}");

    // 不再需要實作 Refund——因為它繼承的招牌,從來沒承諾過這件事
}

ProcessRefundRequest 的簽章也跟著誠實起來:

public void ProcessRefundRequest(RefundablePaymentMethod payment, decimal amount)
{
    payment.Refund(amount);
    Console.WriteLine("退款已處理");
}

現在,編譯器會直接擋下任何想把 CashOnDeliveryPayment 傳進 ProcessRefundRequest 的嘗試
錯誤不用等到執行期才爆炸,在編譯的當下就現形

繼承,還是委派?

不是每一次「拒絕的遺贈」,都該用拆招牌的方式解決。有時候問題出在根本不該有這層繼承關係

  • 如果子類別和父類別,確實是同一種東西的不同層級(像今天的付款方式),拆分出更精確的招牌是好方法
  • 如果子類別只是想「借用」父類別的幾個方法,兩者本質上並非同一種東西
    改用組合(委派給一個內部物件),而不是繼承,才是正確的路

判斷的關鍵只有一句話

這個子類別,能不能在所有用到父類別的地方,安全地取代父類別?
如果不能,這層繼承關係,從一開始就掛錯招牌了

自我檢查清單

  1. 有沒有子類別覆寫了父類別的方法,內容卻是空的、或直接拋出例外?
  2. 這個子類別,能不能安全地在所有用到父類別的地方,被拿來取代?
  3. 這層繼承關係,是真正的「是一種」,還是只是想借用幾個方法?
  4. 如果拆掉這個繼承關係,改用組合,程式碼會不會更誠實?
  5. 呼叫端能不能光看型別,就知道這個物件支援哪些操作,而不必等執行期才發現?

明日預告

明天我們看兩個類別,做著一模一樣的事,介面卻各自為政
一個叫 Send,一個叫 Dispatch,一個叫 Execute

模組二第四站:異曲同工的類別(Alternative Classes with Different Interfaces)


上一篇
Day 10|畫布上只在某幾筆才用到的顏料槽:暫時欄位 (Temporary Field)
系列文
文藝復興:這段程式碼,好像有點味道11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言