iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Software Development

AI時代下的軟體工程系列 第 26 篇

Day26: OCP 與 YAGNI:設計越多,越好?

  • 分享至 

  • xImage
  •  

前三天談的,都是 repo 中已經存在的設計:哪些放在一起、哪些留在裡面、哪些合成一份。

今天談還不存在 repo 中的設計。系統會長大,同一件事遲早會出現第二種做法:通知多一種管道,付款多一家金流。Code 要怎麼處理它?又該不該在它出現之前,就先把位置準備好?

關於這個問題,有兩個很有名的原則 OCP & YAGNI。但我們先來看個例子。

當第二種做法出現

notification 目前只會寄 email:

# shop/notification/notifier.py
def order_placed(member: Member, total: Price) -> None:
    _send(member, f"訂單成立,共 {total} 元")


def _send(member: Member, message: str) -> None:
    mailer.send(member.email, message)

客服希望會員可以改用 LINE 收通知。最直接的改法,是在 _send() 裡加一個 if:

def _send(member: Member, message: str) -> None:
    if member.channel == "line":
        line.push(member.line_id, message)
    else:
        mailer.send(member.email, message)

只有兩種管道時,這樣寫沒有問題。但之後每多一種(簡訊、App 推播),就要打開同一個 function 再改一次:

  • 加簡訊的人,得先讀懂 email 與 LINE 的分支,確認自己沒有碰壞它們。
  • 每種管道自己的細節(email 的主旨、LINE 的字數上限、簡訊的費用),全部擠在同一個地方。
  • 別的地方如果也要分辨管道,例如檢查會員有沒有留下聯絡方式,同樣的 if 會再出現一次;每多一種管道,就多一個可能漏改的地方。

每一次的需求都是「新增」,做的事卻都是「修改」,而且改的是已經寫好、測過、正在運作的 code。

OCP:新增做法,不必修改舊的 code

OCP(Open-Closed Principle,開放封閉原則)由 Bertrand Meyer 在 1988 年提出,後來成為 SOLID 的第二條。最常見的說法是:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

對擴充開放,對修改封閉。兩個詞說的是同一段 code 的兩件事:

  • Open:它能做的事可以增加。新的做法出現時,接得上去。
  • Closed:增加的時候,不必打開它已經寫好的 code 來改。

所以 OCP 問的是:

新增一種做法的時候,要改幾個已經寫好的地方?

乍看之下,這兩件事互相矛盾:code 不改,能做的事怎麼會變多?答案是把會變的與不變的分開。這個例子裡,會變的是「怎麼送」,不變的是「送給誰、送什麼」。在兩者之間放一個 abstraction:

# shop/notification/channel.py
class Channel(ABC):
    @abstractmethod
    def send(self, member: Member, message: str) -> None: ...


class EmailChannel(Channel):
    def send(self, member: Member, message: str) -> None:
        mailer.send(member.email, message)


class LineChannel(Channel):
    def send(self, member: Member, message: str) -> None:
        line.push(member.line_id, message)


CHANNELS: dict[str, Channel] = {
    "email": EmailChannel(),
    "line": LineChannel(),
}
# shop/notification/notifier.py
def _send(member: Member, message: str) -> None:
    CHANNELS[member.channel].send(member, message)

現在要新增簡訊,只需要多寫一個 SmsChannel,再到 CHANNELS 登記一行。_send() 不必動,email 與 LINE 的 code 也沒有人碰過,自然不會被改壞。

像 Channel 這樣,能接上新的做法、又不必修改既有 code 的位置,以下稱為擴充點(extension point)。

封閉,只針對某一種 change

擴充點看起來很划算。但 Channel 擋住的,只有「新增一種管道」這一種 change。換成其他需求:

Change Channel 幫得上忙嗎
新增簡訊 加一個 class,其他不動
晚上十點後不要通知 幫不上,要改的是別的地方
通知要附上一張圖片 幫不上,還得改 Channel 與每一個實作

最後一列最值得注意:需求一旦碰到 send() 的參數,要改的就是擴充點本身,而它底下的實作越多,這次 change 越貴。

沒有一種結構能對所有的 change 封閉。開一個擴充點,等於押注 change 會從這個方向來。

YAGNI:還沒需要,就先不要做

那麼回到只有 email 的第一天。既然管道「以後可能會增加」,要不要先把 Channel 準備好?

第二個原則 YAGNI(You Aren't Gonna Need It,你不會需要它)的回答是:不要。

YAGNI 出自 Extreme Programming,Ron Jeffries 的說法是:

Always implement things when you actually need them, never when you just foresee that you need them.

真的需要了才做,不要因為預見以後會需要就先做。

名字裡的「it」,指的是為了還沒出現的需求先做的東西:還沒有人要用的功能、沒有人會傳的參數、沒有人會切換的設定。它們今天都用不到,存在的理由只有一句「以後可能會需要」。

所以 YAGNI 問的是:

這個東西,是現在的需求要用的,還是我猜以後會用到的?

YAGNI 常被誤解成「不必替未來著想」,甚至「code 能動就好」。但它管的只有上面那一類東西。把 code 寫清楚、補上 test、把 decision 留在一個地方,都不是為了某個猜想中的功能,而是為了讓 code 之後容易修改,這些不在 YAGNI 的範圍裡。

第一天的 Channel 正好是 YAGNI 說的那種東西:只有 email 一種做法,它存在的理由只有「以後可能會有別的管道」。提前開這個擴充點,有三個問題。

一、成本從第一天開始付。

只有一種做法,code 裡卻多了 Channel、EmailChannel 與 CHANNELS。想知道通知到底怎麼送出去的人,得先穿過這三樣東西,才找得到那一行 mailer.send()。不管第二種管道有沒有來,這份成本每天都在付。

二、只有一個例子,看不出它該長什麼樣子。

只看著 email 設計,Channel 很可能長這樣:

class Channel(ABC):
    @abstractmethod
    def send(self, address: str, subject: str, body: str) -> None: ...

等 LINE 真的來了才發現:它沒有主旨,收件人也不是一個地址。哪些是所有管道共有的,哪些只是 email 自己的,要有兩種真實的做法擺在一起才分得出來;只有一種的時候,只能用猜的。

三、change 不一定從這個方向來。

後來真正出現的需求,可能是「晚上不要通知」,可能是「通知要附圖片」,第二種管道卻一直沒有來。這時擴充點的成本照付,真正的 change 還得繞著它改。

OCP vs YAGNI

讀到這裡,兩個原則看起來是衝突的:

  • OCP:新的做法來了,只需要加 code。要做到這件事,得先設計好 interface。
  • YAGNI:還沒需要的東西先不要做,不要有多餘的設計。

癥結在「多餘」兩個字。YAGNI 反對的不是 interface,而是沒有根據的 interface:

沒有根據的 interface 有根據的 interface
抽的時候 只有一種做法,其餘靠猜 下一種做法已經確定
下一個 change 來時 多半形狀不合,得重新設計一次 多半加一個實作就結束
結論 多餘的設計 好的設計,也沒有違反 YAGNI

所以兩者並不衝突。OCP 描述的是好的擴充點帶來什麼,YAGNI 提醒的是沒有根據就不要開。

問題不在要不要 interface,而在抽的時候,手上的根據夠不夠。

要留意的是,一個 interface 是不是多餘,往往要等下一個 change 來了才看得出來;碰巧猜中,也不代表當初的判斷是對的。事前能檢查的,只有手上有什麼根據。有兩種情況,根據通常是夠的:

情況 手上的根據 適合做的事
確定有不只一種做法 下一個 decision 已經定案,例如同時要支援 email 與 LINE 抽出 abstraction,每一種做法各自實作
同時有多個 client client 都已經存在,看得出它們實際用到哪些東西 收窄 public interface,把複雜的知識留在後面

這兩種情況要做的事並不一樣。前者面對的是多種做法,需要一個 abstraction。後者面對的是多個 client:知道這個 decision 的地方越多,之後越難改,所以值得先把門收窄;需要的往往只是一組收窄過的 function,不一定要有 abstract class。

確定要抽之後,還有一個容易做反的地方:從 client 需要什麼出發,而不是從做法能提供什麼出發。

  • 從做法出發,interface 會帶著那一種做法的形狀。前面那個有主旨、有地址的 send() 就是這樣來的。
  • 從 client 出發,interface 只留下 client 完成工作需要的東西,下一種做法才接得上來。

更進一步,甚至可以由 client 自己定義它需要的 interface,再讓各種做法來配合。這是另一個原則的主題,之後再談。

兩種情況都不符合時,就先不抽。但有一件事第一天就可以做:把 decision 留在一個地方。它幾乎不花成本,等到真的需要 interface 的那一天,要改的也只有那裡。

不必猜中未來的 change,只要讓「晚一點再開」保持便宜。

坦白說,這個判斷沒有公式。根據夠不夠、interface 該長什麼樣子,都要看過夠多的 change 才會有感覺;上面兩種情況,只是比較不容易出錯的起點。

OCP 與 YAGNI 真正想表達的事

回到開頭的問題:第二種做法出現時,code 要怎麼接住它?又該不該先把位置準備好?

擴充點要有根據才開,而且從 client 的需要開。在那之前,把 decision 留在一個地方,讓晚開也不貴。

對 coding agent 而言,新的做法進來時,最省事的路有兩條:

  • 照著現有的寫法接下去:在既有的 if 後面再接一個 elif。功能完成了,test 也通過了,codebase 卻一步一步變成只看結果的樣子。
  • 先開了再說:多寫一層 interface 對 agent 幾乎不花力氣,「方便未來擴充」又是聽起來永遠正確的理由。於是只有一種做法的地方,也長出了 interface 與 factory。

兩條路都能通過所有的檢查,也都跳過了同一件事:判斷現在的根據夠不夠。

所以當新的做法進來、而且要與既有的做法並存時,先不要急著請 agent 把功能做完,而是和它一起把 interface 討論出來:

  1. 這幾種做法,哪些部分是共同的?哪些只屬於其中一種?
  2. client 實際需要的是什麼?
  3. interface 確定之後,再請它實作。

要能這樣討論,我們得先知道 repo 裡有哪些 boundary、哪些做法已經存在。不必讀懂每一行;但如果連目前有幾種通知管道都說不出來,就只能接受 agent 交出來的結果,而它交出來的,往往就是多一個 if。

未來會往哪裡變,沒有人知道。能做的是別太早下注,並且讓晚一點下注的成本夠低。

Reference


上一篇
Day25: DRY:重複的不是 code,而是 knowledge
下一篇
Day27: Dependency topology:dependency 可以怎麼設計?
系列文
AI時代下的軟體工程 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言