文藝復興的畫室,招牌不是隨便掛的掛上某位大師的招牌,代表你承諾了這間畫室的一整套技法
濕壁畫怎麼在灰泥未乾前完工、油彩怎麼分層堆疊客戶看到招牌,就會預期,「不管接的是哪一種案子,這間畫室都能照著承諾的技法完成」
如果掛著招牌,卻在客戶指定某種技法時說「這個我們不接」,那塊招牌,就成了一句謊言
系統要支援多種付款方式,第一版設計,是一個共同的父類別:
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 的嘗試
錯誤不用等到執行期才爆炸,在編譯的當下就現形
不是每一次「拒絕的遺贈」,都該用拆招牌的方式解決。有時候問題出在根本不該有這層繼承關係
判斷的關鍵只有一句話
這個子類別,能不能在所有用到父類別的地方,安全地取代父類別?
如果不能,這層繼承關係,從一開始就掛錯招牌了
明天我們看兩個類別,做著一模一樣的事,介面卻各自為政
一個叫 Send,一個叫 Dispatch,一個叫 Execute
模組二第四站:異曲同工的類別(Alternative Classes with Different Interfaces)