在任何一位學徒被允許碰畫筆之前,畫室的門楣上,早就刻好了五條規矩(SOLID)
不是技法,不是配色,是更底層的東西
「一幅畫該怎麼分工、一個角色該扛多少責任、換人代班會不會壞事」
接下來 28 天,會帶你認出 23 種具體的壞味道
但每一種壞味道,回頭問到底,都是在違反這五條規矩的其中一條
今天,就是把這五條規矩,一次刻清楚
昨天問了一句話:「這段程式碼,值不值得留下來?」
但「值不值得」不能只靠直覺,你需要一套標準
就像畫室師傅不會只說「這裡怪怪的」,他能指出「這裡的透視錯了」
因為他心裡有一套關於「什麼是對的透視」的規矩
Code Smell 是症狀,SOLID 是病理學
聞到味道,是第一步
知道這個味道對應哪一條規矩被打破,才能真正說清楚「為什麼要改」,而不是「我覺得要改」
先把規矩刻清楚,後面認味道,才不會只是死背 23 個名詞。
一幅畫,只有一位總負責的構圖師
畫室接一張大型委託,會指定一位總負責的構圖師,決定整張畫「該長什麼樣子」
顏料庫存誰在管、跟催收款誰在跑,是另外兩個人的事
如果構圖師同時要管顏料庫存、又要跟催收款,任何一件事出狀況,都會打斷他手上真正該做的事
決定這幅畫該長什麼樣子
public class OrderReportGenerator
{
public OrderSummary BuildSummary(List<Order> orders)
{
return new OrderSummary(orders.Sum(o => o.Total), orders.Count);
}
public string ToHtml(OrderSummary summary)
{
return $"<div>總金額:{summary.Total},共 {summary.Count} 筆</div>";
}
public void SaveToFile(string html, string path)
{
File.WriteAllText(path, html);
}
}
這個類別,有三個完全不相干的理由會讓它變動:
三個互不相關的理由,卻共用同一個類別
改其中一件事,都有可能不小心影響到另外兩件
public class OrderSummaryBuilder
{
public OrderSummary Build(List<Order> orders) =>
new OrderSummary(orders.Sum(o => o.Total), orders.Count);
}
public class OrderReportFormatter
{
public string ToHtml(OrderSummary summary) =>
$"<div>總金額:{summary.Total},共 {summary.Count} 筆</div>";
}
public class ReportStorage
{
public void SaveToFile(string content, string path) =>
File.WriteAllText(path, content);
}
現在,稅金規則變了,只動 OrderSummaryBuilder;模板變了,只動 OrderReportFormatter
三個角色,各自只對一個理由負責。
單一職責原則(Single Responsibility Principle):一個類別,應該只有一個引起它變動的理由
這正是模組一「臃腫者」的核心,後續會提到的 Long Method、Large Class,都是這條規矩在方法與類別層級,各自失守的樣子
新技法用「疊加一層」的方式加進去,原本已經畫好、也已經風乾的部分,完全不受影響
畫室接受新配方顏料,不需要把舊畫的底層刮掉重畫
public decimal CalculateShippingFee(Order order, string region)
{
if (region == "TW") return 60m;
if (region == "HK") return 120m;
if (region == "JP") return 150m;
// 明天要支援新加坡,又要多一行 if
return 200m;
}
這個方法明明已經在 Production 跑得好好的、也已經被測試過,卻因為「多一個地區」這種完全不影響既有邏輯的需求,被迫重新修改、重新測試一次
public interface IShippingFeeStrategy
{
decimal Calculate(Order order);
}
public class TaiwanShippingFeeStrategy : IShippingFeeStrategy
{
public decimal Calculate(Order order) => 60m;
}
public class HongKongShippingFeeStrategy : IShippingFeeStrategy
{
public decimal Calculate(Order order) => 120m;
}
呼叫端透過一個查表用的 Dictionary<string, IShippingFeeStrategy> 取得對應的策略
要支援新加坡,只要新增一個 SingaporeShippingFeeStrategy 原本的呼叫邏輯,一行都不用動
開放封閉原則(Open/Closed Principle):對擴充開放,對修改封閉
新增行為,靠「加一個新類別」,而不是回頭修改一段已經在運作的舊程式碼
Switch Statement 會正式命名這個 Code Smell:以多型取代條件式。
客戶跟老師傅談好一張委託,如果當天由代班學徒接手
客戶不該感覺到任何差異——委託當初講好的每一件事,代班的人都得撐得住
如果代班學徒某個環節做不到,甚至還偷偷加了一條客戶事前不知道的但書
這場代班,就不算真正頂替得住原本的角色
public abstract class DiscountCalculator
{
public abstract decimal Apply(Order order);
}
public class SeasonalDiscountCalculator : DiscountCalculator
{
public override decimal Apply(Order order) => order.Total * 0.9m;
}
public class MemberOnlyDiscountCalculator : DiscountCalculator
{
public override decimal Apply(Order order)
{
if (!order.Customer.IsMember)
throw new InvalidOperationException("非會員不適用此折扣");
return order.Total * 0.8m;
}
}
呼叫端只認得父類別的約定:
public decimal ApplyBestDiscount(DiscountCalculator calculator, Order order) =>
calculator.Apply(order);
這段程式碼,換一顆 SeasonalDiscountCalculator 進去,跑得好好的
換一個 MemberOnlyDiscountCalculator 進去,同一張非會員訂單,直接就爆了
呼叫端完全沒有辦法從 DiscountCalculator 這個型別本身,看出「換了子類別,會多一條它沒承諾過的規則」
里氏替換原則(Liskov Substitution Principle):子類別要能安全地取代父類別,出現在任何用到父類別的地方
Refused Bequest,會用另一種更常見的違反樣貌(子類別覆寫方法卻直接拋例外)把這條規矩的代價講得更完整
畫室依專長分工具箱,畫框師傅的工具箱,不會被塞進調色師傅才用得到的乳缽跟研磨棒
每個角色,只需要扛自己專長會用到的那一份工具,不必為了別人的專長,背整套用不到的重物
public interface IOrderService
{
void Process(OrderRequest request);
decimal CalculateShippingFee(Order order);
string GenerateInvoice(Order order);
void SendNotification(Order order);
void ExportToAccountingSystem(Order order);
}
報表模組只需要「產生發票」這一件事,卻因為要實作 IOrderService,被迫連帶交代四個用不到的方法:
public class ReportingOrderService : IOrderService
{
public string GenerateInvoice(Order order) => $"發票:{order.Id}";
public void Process(OrderRequest request) =>
throw new NotSupportedException("報表模組不處理訂單");
public decimal CalculateShippingFee(Order order) =>
throw new NotSupportedException("報表模組不算運費");
public void SendNotification(Order order) =>
throw new NotSupportedException("報表模組不發通知");
public void ExportToAccountingSystem(Order order) =>
throw new NotSupportedException("報表模組不匯出會計系統");
}
這其實也會讓里氏替換原則跟著爆炸,但根源不在繼承
在介面本身塞了太多不相干的能力,逼著每一個實作者都得為用不到的方法負責
public interface IOrderProcessor {
void Process(OrderRequest request);
}
public interface IShippingFeeCalculator {
decimal CalculateShippingFee(Order order);
}
public interface IInvoiceGenerator {
string GenerateInvoice(Order order);
}
public class ReportingOrderService : IInvoiceGenerator
{
public string GenerateInvoice(Order order) => $"發票:{order.Id}";
}
ReportingOrderService 現在只需要對它真正做得到的那件事負責,不用假裝自己會處理訂單、算運費、發通知
介面隔離原則(Interface Segregation Principle):不要強迫任何角色依賴自己用不到的方法
這條規矩不會單獨對應到某一天的壞味道,但接下來每次看到「乾淨的小介面」,背後多半都是這條規矩在撐著
畫室跟供貨商簽約,認的是行會公告的顏料規格,不是某個特定商人的個人手法
換一個供貨商,只要新規格的顏料符合行會規格,畫室的作業方式完全不用跟著調整
因為畫室從一開始,就只依賴規格,不依賴某個具體的人
// 直接依賴一個具體的實作,連連線字串都寫死在裡面
public class OrderProcessor
{
private readonly SqlOrderRepository _repository =
new SqlOrderRepository("Server=prod-db;...");
public void Process(OrderRequest request)
{
_repository.Save(request.ToOrder());
}
}
OrderProcessor 不只依賴「怎麼存訂單」這件事,還依賴了「用 SQL Server 存、連線字串長這樣」的具體細節。
想幫它寫測試,或改用別的儲存方式,都得先拆開這一行 new
public interface IOrderRepository
{
void Save(Order order);
}
public class OrderProcessor
{
private readonly IOrderRepository _repository;
public OrderProcessor(IOrderRepository repository) => _repository = repository;
public void Process(OrderRequest request) => _repository.Save(request.ToOrder());
}
OrderProcessor 現在只認一份規格——IOrderRepository 說了什麼,它就依賴什麼。 規格背後是 SQL Server、PostgreSQL,還是測試用的記憶體假物件,OrderProcessor 完全不需要知道
依賴反轉原則(Dependency Inversion Principle):高層模組(業務邏輯)不該依賴低層模組(技術細節)的具體實作,兩者都該依賴同一份抽象規格
事實上,Day 01 那段
OrderProcessor建構子注入IEmailService、ISmsService的寫法,就已經是這條規矩的正確示範。接下來每一段範例的建構子注入,都是延續這條規矩
| 規矩 | 一句話定義 | 判斷問題 |
|---|---|---|
| S 單一職責 | 一個類別,只該有一個引起它變動的理由 | 這個類別/方法,是不是同時對好幾個不相干的理由負責? |
| O 開放封閉 | 對擴充開放,對修改封閉 | 新增一種行為,是不是得回頭修改一段已經在運作的舊程式碼? |
| L 里氏替換 | 子類別要能安全取代父類別,出現在任何用到父類別的地方 | 換一顆子類別進去,呼叫端的行為會不會偷偷變了? |
| I 介面隔離 | 不要強迫任何角色依賴自己用不到的方法 | 這個介面,是不是有實作者得為用不到的方法負責? |
| D 依賴反轉 | 高層模組跟低層模組,都該依賴同一份抽象規格 | 拿掉某個具體實作換一個,呼叫端的程式碼要不要跟著改? |
23 種壞味道,之後每認出一種,都可以回頭對照這張表,問一句:「它踩到的,是哪一條規矩?」
NotSupportedException?new 出一個具體的技術實作,而不是依賴一份介面?門楣上的五條規矩,刻好了
明天,我們去一個真實存在、到今天都還立在佛羅倫斯天空下的工地看看 「一座蓋了快一百年沒人敢動工的圓頂」
第一站:過長方法(Long Method),正式開工