iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

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

Day25: DRY:重複的不是 code,而是 knowledge

  • 分享至 

  • xImage
  •  

昨天的結論是:一個 decision 只有一個地方知道,它改變的時候,就只有那裡需要改。

這件事有一個更有名的名字:DRY,Don't Repeat Yourself。

多數人學到的 DRY 是這樣的:看到重複的 code,就抽成一個 function。我自己一開始也是這樣理解的。這個做法大部分時候沒有問題,但它會在兩個地方出錯:有些長得一樣的 code 不該合併,有些重複的東西,長得一點都不像。

DRY 原本的定義

DRY 出自 The Pragmatic Programmer,原文是:

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

系統裡的每一項 knowledge,都應該只有一個明確、權威的表示。

主詞是 knowledge,不是 code。

Knowledge 和昨天的 decision 有關,但不是同一件事。Decision 是被做出來的選擇:折扣怎麼算、資料用什麼格式存。Knowledge 則是一段 code 為了完成工作,必須知道的事。它可以大到一整套促銷的策略,也可以小到一份 contract、一個欄位的意思。

一個 decision 只做一次,知道它的地方卻可以有很多個。DRY 數的,就是「知道的有幾份」。

所以它問的不是「這兩段 code 像不像」,而是:

這兩個地方,是不是各自保存了同一份 knowledge?

「長得像不像」與「是不是同一份 knowledge」是兩個獨立的問題,組合起來有四種情況:

同一份 knowledge 不同的 knowledge
長得一樣 ① 真的重複 ② 碰巧一樣
長得不一樣 ③ 看不見的重複 ④ 沒有關係

「看到重複就抽 function」只看得見上面那一列。① 它會做對:同一條折扣規則在 cart、order、invoice 各寫了一份,就該收進 pricing。但它會在 ② 合併不該合併的東西,並且完全看不到 ③。

碰巧一樣:長得一樣,卻是兩份 knowledge

pricing 裡有兩條規則:滿 1000 折 100,滿 1000 免運。

# shop/pricing/rules.py
def discount_of(subtotal: Price) -> Price:
    return 100 if subtotal >= 1000 else 0


def shipping_fee_of(subtotal: Price) -> Price:
    return 0 if subtotal >= 1000 else 60

subtotal >= 1000 出現了兩次。照著直覺,把它抽出來:

def is_big_order(subtotal: Price) -> bool:
    return subtotal >= 1000

重複消失了。過了一個月,物流因為運費調漲,把免運門檻提高到 1500。折扣沒有要變,所以 is_big_order 不能直接改,只好加一個參數:

def is_big_order(subtotal: Price, *, for_shipping: bool = False) -> bool:
    if for_shipping:
        return subtotal >= 1500
    return subtotal >= 1000

再過一個月,行銷想讓會員滿 800 就能折抵,於是又多一個 is_member。

回頭看發生了什麼事。折扣門檻由行銷決定,免運門檻由物流決定;它們從一開始就是兩件事,只是在某一天剛好都是 1000。合併之後,兩段原本互不相干的 code 被綁在一起:改其中一邊,就得顧慮另一邊,每個 client 還得透過參數說明「我是哪一種」。

重複的 code 是線索,不是證據。合併兩段 code,等於宣告它們以後會一起改。

其實有一個訊號很早就出現了:名字。is_big_order 是什麼?沒有任何人做過「多少錢算大訂單」這個決定。真正的 knowledge 都叫得出名字:「折扣門檻」、「免運門檻」。如果抽出來的東西只能用它的長相來命名,它背後很可能並沒有一份 knowledge。

看不見的重複:長得不一樣,卻是同一份 knowledge

反過來的情況更難發現。訂單要匯出成檔案,給報表使用:

# shop/order/export.py
def export_orders(orders: list[Order], path: Path) -> None:
    lines = [f"{o.id},{o.total},{o.status}" for o in orders]
    path.write_text("\n".join(lines))


# shop/report/sales.py
def load_paid_totals(path: Path) -> list[int]:
    totals = []
    for line in path.read_text().splitlines():
        fields = line.split(",")
        if fields[2] == "paid":
            totals.append(int(fields[1]))
    return totals

這兩段 code 沒有任何一行相同,找重複的工具不會把它們列出來,report 也沒有 import export。但它們知道同一件事:用逗號分隔、第二欄是金額、第三欄是狀態、付款完成寫作 paid。

哪天在金額前面加一欄會員編號,export 的 test 會通過,因為它驗證的是新格式;report 的 test 也會通過,因為它讀的是自己準備的舊格式資料。而報表從此一筆訂單都讀不到,也沒有任何錯誤訊息。

這種重複有很多種樣子:

  • 讀與寫:上面的匯出與讀取、序列化與反序列化。
  • 資料的結構:好幾個 function 各自讀同一個 dict[str, Any],每一個都得先弄懂它長什麼樣子。
  • 定義與分支:訂單狀態定義在一處,if status == "paid" 散在各處。
  • 存下來的值與算出它的規則:資料庫存著 total,它又能由商品與折扣算出來。
  • code 與文字:README 寫著「滿 1000 免運」,test 裡又寫死一次 1000。
  • 跨語言與系統:同一條驗證規則,前端、後端、資料庫各寫一次。

它們的共同點是:兩個地方必須對同一件事有共識,卻沒有任何地方寫著它們必須一致。換句話說,這是一份沒寫出來的 contract。

解法是替這份 knowledge 找一個家。把格式交給一個 module(例如 order_format),由它同時提供 dump() 與 parse();export 與 report 都透過它讀寫,不必自己再知道一次格式。收不進同一個 module 的,例如前端與後端,就共用一份 schema,或由其中一邊產生另一邊。

這其實就是建立 abstraction,它也是 DRY 最主要的手段。但反過來不成立:DRY 的前提是同一份 knowledge 現在已經有兩份;為了還沒出現的做法先準備 interface,是另一個問題。

什麼時候該合併

判斷只需要問一件事:

它們會因為同一個理由一起改嗎?

  • 會:合併,不必等。三處各寫一份的折扣規則就是這種。
  • 不會:不合併,即使今天一字不差。折扣門檻與免運門檻就是這種。
  • 還看不出來:先留著。

先留著,是因為兩種錯誤的代價並不對稱。

留著一份真的重複,之後發現了再合併並不難:兩段 code 都還在,各自的需求也還看得清楚。合併了兩個碰巧一樣的東西,代價卻會隨時間增加:每個新需求都往共用的 function 裡多加一個參數、一個 if,等到想拆開,已經分不清哪個 client 依賴的是哪一種行為。

常聽到的 Rule of Three(第三次重複才抽)也是這個意思。「三」沒有什麼魔力,它只是在等證據。

重複比錯誤的 abstraction 便宜。還看不出來的時候,先讓它重複。

DRY 真正想表達的事

回到開頭。DRY 不是「不要有長得一樣的 code」,而是:

同一份 knowledge,只留一份;不同的 knowledge,長得再像,也不要合成一份。

對 coding agent 而言,② 與 ③ 都特別容易發生:

  • 照著長相合併:agent 能比對的只有文字。請它「整理重複的 code」,它會把長得像的都抽出來,包括碰巧一樣的。
  • 改了一邊,不知道還有另一邊:看不見的重複沒有 import,也沒有共用的名稱,搜尋不一定找得到。agent 改了匯出的格式,不會知道報表在另一頭照著舊格式讀。

這兩種結果,一樣能通過所有的檢查。所以與 agent 合作時,可以多做兩件事:

  • 檢查時,看被抽出來的東西:它的名字描述的是一份 knowledge,還是一種長相?有沒有用來區分 client 的參數?它的每個 client,會因為同一個理由要求它改變嗎?
  • 交代任務時,把「同」與「不同」都說出來:「匯出的格式只有 order_format 可以知道」是一種;「折扣門檻與免運門檻數字相同是巧合,不要合併」是另一種。後者值得直接寫在 code 旁邊,因為只看 code,看不出這份重複是刻意留下的。

兩段 code 該不該合併,從長相看不出來。要看的是:它們知道的,是不是同一件事。

Reference


上一篇
Day24: Information Hiding:藏的不是 code,而是 decision
系列文
AI時代下的軟體工程 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言