iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

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

Day 02|行會規範:畫室開工前,刻在門楣上的五條規矩(SOLID)

  • 分享至 

  • xImage
  •  

在任何一位學徒被允許碰畫筆之前,畫室的門楣上,早就刻好了五條規矩(SOLID)
不是技法,不是配色,是更底層的東西
「一幅畫該怎麼分工、一個角色該扛多少責任、換人代班會不會壞事」

接下來 28 天,會帶你認出 23 種具體的壞味道
但每一種壞味道,回頭問到底,都是在違反這五條規矩的其中一條

今天,就是把這五條規矩,一次刻清楚


為什麼先講規矩,不是先講味道

昨天問了一句話:「這段程式碼,值不值得留下來?」
但「值不值得」不能只靠直覺,你需要一套標準

就像畫室師傅不會只說「這裡怪怪的」,他能指出「這裡的透視錯了」
因為他心裡有一套關於「什麼是對的透視」的規矩

Code Smell 是症狀,SOLID 是病理學

聞到味道,是第一步
知道這個味道對應哪一條規矩被打破,才能真正說清楚「為什麼要改」,而不是「我覺得要改」

先把規矩刻清楚,後面認味道,才不會只是死背 23 個名詞。


S 單一職責原則(Single Responsibility Principle)

一幅畫,只有一位總負責的構圖師
畫室接一張大型委託,會指定一位總負責的構圖師,決定整張畫「該長什麼樣子」

顏料庫存誰在管、跟催收款誰在跑,是另外兩個人的事

如果構圖師同時要管顏料庫存、又要跟催收款,任何一件事出狀況,都會打斷他手上真正該做的事
決定這幅畫該長什麼樣子

一個類別,管了三件不相干的事

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);
    }
}

這個類別,有三個完全不相干的理由會讓它變動:

  • 彙總邏輯變了——例如要多算一欄稅金
  • 排版樣式變了——例如行銷要求換一套 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,都是這條規矩在方法與類別層級,各自失守的樣子


O 開放封閉原則(Open/Closed Principle):加新配方,不必刮掉舊畫的底層

新技法用「疊加一層」的方式加進去,原本已經畫好、也已經風乾的部分,完全不受影響
畫室接受新配方顏料,不需要把舊畫的底層刮掉重畫

每加一個地區,就要回頭改一次舊方法

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:以多型取代條件式


L 里氏替換原則(Liskov Substitution Principle):代班學徒,撐不撐得住原本談好的委託

客戶跟老師傅談好一張委託,如果當天由代班學徒接手
客戶不該感覺到任何差異——委託當初講好的每一件事,代班的人都得撐得住

如果代班學徒某個環節做不到,甚至還偷偷加了一條客戶事前不知道的但書
這場代班,就不算真正頂替得住原本的角色

子類別,偷偷加嚴了父類別沒承諾過的條件

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,會用另一種更常見的違反樣貌(子類別覆寫方法卻直接拋例外)把這條規矩的代價講得更完整


I 介面隔離原則(Interface Segregation Principle):工具箱按專長分,不強迫每人都背整套

畫室依專長分工具箱,畫框師傅的工具箱,不會被塞進調色師傅才用得到的乳缽跟研磨棒
每個角色,只需要扛自己專長會用到的那一份工具,不必為了別人的專長,背整套用不到的重物

一個介面,塞了五種不相干的能力

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):不要強迫任何角色依賴自己用不到的方法

這條規矩不會單獨對應到某一天的壞味道,但接下來每次看到「乾淨的小介面」,背後多半都是這條規矩在撐著


D 依賴反轉原則(Dependency Inversion 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 建構子注入 IEmailServiceISmsService 的寫法,就已經是這條規矩的正確示範。接下來每一段範例的建構子注入,都是延續這條規矩


五條規矩,對照表

規矩 一句話定義 判斷問題
S 單一職責 一個類別,只該有一個引起它變動的理由 這個類別/方法,是不是同時對好幾個不相干的理由負責?
O 開放封閉 對擴充開放,對修改封閉 新增一種行為,是不是得回頭修改一段已經在運作的舊程式碼?
L 里氏替換 子類別要能安全取代父類別,出現在任何用到父類別的地方 換一顆子類別進去,呼叫端的行為會不會偷偷變了?
I 介面隔離 不要強迫任何角色依賴自己用不到的方法 這個介面,是不是有實作者得為用不到的方法負責?
D 依賴反轉 高層模組跟低層模組,都該依賴同一份抽象規格 拿掉某個具體實作換一個,呼叫端的程式碼要不要跟著改?

23 種壞味道,之後每認出一種,都可以回頭對照這張表,問一句:「它踩到的,是哪一條規矩?」

自我檢查清單

  1. 這個類別/方法,能不能用一句話講完它的職責?如果要用「和」才講得完,可能不只一個職責
  2. 上次新增一種類型或規則,是不是要回頭修改一段舊邏輯,而不是新增一個新類別?
  3. 換一顆子類別進去,呼叫端的行為會不會意外改變、甚至丟出例外?
  4. 有沒有介面塞了太多方法,逼著某些實作者對著用不到的方法拋 NotSupportedException
  5. 程式碼裡有沒有直接 new 出一個具體的技術實作,而不是依賴一份介面?

明日預告

門楣上的五條規矩,刻好了

明天,我們去一個真實存在、到今天都還立在佛羅倫斯天空下的工地看看 「一座蓋了快一百年沒人敢動工的圓頂」

第一站:過長方法(Long Method),正式開工


上一篇
Day 01|文藝復興宣言:當 AI 開始寫程式,誰來守住工藝?
下一篇
Day 03|一氣呵成的迷思:過長方法 (Long Method)
系列文
文藝復興:這段程式碼,好像有點味道4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言