前三天談的,都是 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 再改一次:
if 會再出現一次;每多一種管道,就多一個可能漏改的地方。每一次的需求都是「新增」,做的事卻都是「修改」,而且改的是已經寫好、測過、正在運作的 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 的兩件事:
所以 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)。
擴充點看起來很划算。但 Channel 擋住的,只有「新增一種管道」這一種 change。換成其他需求:
| Change | Channel 幫得上忙嗎 |
|---|---|
| 新增簡訊 | 加一個 class,其他不動 |
| 晚上十點後不要通知 | 幫不上,要改的是別的地方 |
| 通知要附上一張圖片 | 幫不上,還得改 Channel 與每一個實作 |
最後一列最值得注意:需求一旦碰到 send() 的參數,要改的就是擴充點本身,而它底下的實作越多,這次 change 越貴。
沒有一種結構能對所有的 change 封閉。開一個擴充點,等於押注 change 會從這個方向來。
那麼回到只有 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 還得繞著它改。
讀到這裡,兩個原則看起來是衝突的:
癥結在「多餘」兩個字。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 需要什麼出發,而不是從做法能提供什麼出發。
send() 就是這樣來的。更進一步,甚至可以由 client 自己定義它需要的 interface,再讓各種做法來配合。這是另一個原則的主題,之後再談。
兩種情況都不符合時,就先不抽。但有一件事第一天就可以做:把 decision 留在一個地方。它幾乎不花成本,等到真的需要 interface 的那一天,要改的也只有那裡。
不必猜中未來的 change,只要讓「晚一點再開」保持便宜。
坦白說,這個判斷沒有公式。根據夠不夠、interface 該長什麼樣子,都要看過夠多的 change 才會有感覺;上面兩種情況,只是比較不容易出錯的起點。
回到開頭的問題:第二種做法出現時,code 要怎麼接住它?又該不該先把位置準備好?
擴充點要有根據才開,而且從 client 的需要開。在那之前,把 decision 留在一個地方,讓晚開也不貴。
對 coding agent 而言,新的做法進來時,最省事的路有兩條:
if 後面再接一個 elif。功能完成了,test 也通過了,codebase 卻一步一步變成只看結果的樣子。兩條路都能通過所有的檢查,也都跳過了同一件事:判斷現在的根據夠不夠。
所以當新的做法進來、而且要與既有的做法並存時,先不要急著請 agent 把功能做完,而是和它一起把 interface 討論出來:
要能這樣討論,我們得先知道 repo 裡有哪些 boundary、哪些做法已經存在。不必讀懂每一行;但如果連目前有幾種通知管道都說不出來,就只能接受 agent 交出來的結果,而它交出來的,往往就是多一個 if。
未來會往哪裡變,沒有人知道。能做的是別太早下注,並且讓晚一點下注的成本夠低。