Repeat Yourself

Matthias Endler

重複自己

在我從事軟體工作的生涯中,最常聽見的建議之一就是「不要重複自己」(don’t repeat yourself),也就是所謂的 DRY 原則。很長一段時間裡,我都照單全收,從未質疑過它的正確性。

直到我看到真正的高手是怎麼寫程式碼的:他們一直在複製程式碼1。我才意識到,重複自己其實有不少好處。

為什麼大家這麼推崇 DRY

常見的說法是,如果你重複自己,就得在多個地方修同一個錯誤;但如果你有一個共用的抽象化,就只需要修一次。

我們避免重複的另一個原因是,這會讓我們感覺自己很聰明。「你看,我懂得各種避免重複的聰明技巧!我會用介面、泛型、高階函式和繼承!」

這兩個理由其實都有誤導性。重複自己其實有很多好處,長期來看可能更能讓我們接近目標。

保持動能

寫程式碼時,你會想保持動能,進入心流狀態。如果一直停下來設計完美的抽象化,很容易就會失去動能。

相反地,如果你允許自己複製貼上程式碼,就能讓思緒持續運轉,專注在眼前的問題上。你也不會同時引進另一個「尋找正確抽象化」的難題。

通常更簡單的做法是,先複製現有的程式碼並加以修改,直到它變成一種負擔時,再去重構它。

我認為「撰寫模式」和「重構模式」是兩種不同的程式設計模式。在撰寫模式中,你應該專注於把想法寫下來,壓抑內心那個不斷告訴你「程式碼寫得很爛」的批判聲音。到了重構模式,你則扮演相反的角色:化身為批判者。你會尋找正確的抽象化、消除重複、並提升可讀性,藉此改善程式碼。

讓這兩種模式保持分離。不要試圖同時進行兩者。2

要找到正確的抽象化很難

剛開始寫程式碼時,你還不知道正確的抽象化是什麼。但如果你複製程式碼,正確的抽象化就會自己浮現;一再地複製同一段程式碼實在太繁瑣,這時你自然就會開始尋找將其抽象化的方法。對我來說,這通常發生在第一次複製同一段程式碼之後,但我會試著克制衝動,直到複製到第二或第三次才考慮抽象化。

如果你太早開始抽象化,最後可能會得到一個不適合問題的糟糕抽象化。你會知道它錯了,因為它用起來很卡。一些典型的徵兆包括:

  • 命名過於籠統、無法傳達意圖,例如 render_pdf_file 而不是 generate_invoice
  • 少了額外脈絡就難以理解
  • 該抽象化只在一兩個地方被使用
  • 與實作細節高度耦合

錯誤的抽象化很難擺脫

我們很容易就接受腦中浮現的第一個抽象化,但大多數時候,那並不是正確的。要移除錯誤的抽象化是很費工的,因為現在的資料流已經依賴它了。

我們也容易愛上自己創造的抽象化,因為它們耗費了時間與心力。這讓我們即使發現它已不再適合問題,也捨不得丟棄——這就是沉沒成本謬誤。

當其他程式設計師也開始依賴它時,情況會更糟。這時你就得小心更動它,因為可能會破壞程式碼庫的其他部分。一旦引入了一個抽象化,你就得與它共存很長一段時間,有時甚至是永遠。

如果當初你用的是複製的程式碼,就可以只在一個地方修改,而不必擔心會破壞其他東西。

重複遠比錯誤的抽象化來得便宜

—Sandi Metz(桑蒂·梅茲),The Wrong Abstraction

最好等到對問題領域已有扎實理解的最後一刻,再來確定抽象化。3

抽象化的心智負擔

抽象化能減少程式碼的重複,但它是有代價的。

抽象化可能會讓程式碼更難閱讀、理解與維護,因為你必須在多層間接層次之間跳來跳去,才能理解程式碼在做什麼。抽象化可能散落在不同的檔案、模組或函式庫中。

穿越這些層次的成本很高。厲害的程式設計師或許能在腦中記住幾層抽象化,但我們每個人的脈絡視窗都是有限的(這取決於對程式碼庫的熟悉程度)。

當你複製程式碼時,可以把所有邏輯都保留在同一個地方。你只要把整段讀完,就能理解它在做什麼。

抗拒過早抽象化的衝動

有時候,程式碼看起來很相似,用途卻截然不同。

例如,看看以下兩段透過迭代集合來計算總和的程式碼。

total = 0
for item in shopping_cart:
    total += item.price * item.quantity

而在程式碼的另一處,我們有:

total = 0
for item in package_items:
    total += item.weight * item.rate

兩種情況下,我們都是迭代一個集合並計算總和。你可能會想導入一個輔助函式,但這兩種計算其實非常不同。

經過幾次迭代後,這兩段程式碼可能會朝不同的方向演變:

def calculate_total_price(shopping_cart):
    if not shopping_cart:
        raise ValueError("Shopping cart cannot be empty")
    
    total = 0.0
    for item in shopping_cart:
        # Round for financial precision
        total += round(item.price * item.quantity, 2)
    
    return total

相比之下,運費計算可能會變成這樣:

def calculate_shipping_cost(package_items, destination_zone):
    # Use higher of actual weight vs dimensional weight
    total_weight = sum(item.weight for item in package_items)
    total_volume = sum(item.length * item.width * item.height for item in package_items)
    dimensional_weight = total_volume / 5000  # FedEx formula
    
    billable_weight = max(total_weight, dimensional_weight)
    return billable_weight * shipping_rates[destination_zone]

如果我們太早套用「不要重複自己」的原則,就會失去每種計算各自的脈絡與具體需求。

DRY 可能帶來複雜度

DRY 原則常被誤解為一條必須不計代價避免任何重複的通則,這反而可能導致複雜度。

當你試圖透過引入抽象化來避免重複,就必須在遠離實際商業邏輯的地方處理所有邊界情況。你最終會在抽象化中加入多餘的檢查與條件,只為了確保它在所有情況下都能運作。之後,你可能會忘記這些檢查背後的理由,卻因為不想破壞任何呼叫端而抱著「以防萬一」的心態把它們留著。結果就是產生了增加程式碼庫複雜度的死碼;這一切都只是因為你想避免重複自己。

常見的說法是,如果你重複自己,就得在多個地方修同一個錯誤。但這個假設前提是錯誤存在於所有複本中。實際上,每個複本可能已經以不同的方式演變,而錯誤可能只存在於其中一個。

當你建立一個共用的抽象化時,該抽象化中的一個錯誤會讓每一個呼叫端都出錯,一次就破壞多個功能。若是重複的程式碼,錯誤則只會侷限在某一個特定的使用情境中。

事後再整理

要確定你沒有弄壞共用抽象化中的任何東西,遠比檢查單一複本的程式碼困難得多。當然,如果你有很多複本,確實有忘記全部修到的風險。

讓這個做法可行的關鍵在於事後整理。這可以在提交程式碼之前,或是在程式碼審查期間進行。

在這個階段,你可以檢視自己複製的程式碼,判斷是保持原樣比較合理,還是已經能看出正確的抽象化。我會在對問題有更深入理解後才嘗試重構程式碼,而不是更早。

要撤銷一個糟糕的抽象化,有個技巧是把程式碼內聯回原本使用它的地方。有一陣子,你會在程式碼庫中再次「重複自己」,但那沒關係。根據你掌握的新資訊重新思考問題,往往就能找到更適合問題的抽象化。

當抽象化是錯誤的,前進最快的方式就是往回走。

—桑蒂·梅茲,The Wrong Abstraction

tl;dr

尋找正確的抽象化沒有錯,但別為此過度糾結。當複製程式碼能幫助你保持動能、並找到正確的抽象化時,就別害怕這麼做。

值得再說一次:「重複自己吧。」

  1. 舉例可參考 Ferris 開發 Rustendo64 的過程tokiospliff 開發 C++ 遊戲引擎的過程

  2. 這也是我寫文章的方式:我先寫出草稿,壓抑內心的批判聲音,然後再扮演編輯/批判者的角色來「重構」文章。這樣我就能兼得兩者的好處:既有不阻礙創意的快速回饋循環,又能得到更精緻、結構更完整的成品。當然,這個方法不是我發明的。如果你想更了解這個技巧,我推薦閱讀 Anne Lamott(安妮·拉莫特)的著作 Bird by Bird: Instructions on Writing and Life(《一隻鳥接著一隻鳥:寫作與人生指引》)中的「Shitty first drafts」一文。

  3. 這類似於 OODA loop(OODA 循環)的概念,也就是「觀察、定位、決策、行動」(Observe, Orient, Decide, Act)。它由軍事戰略家 John Boyd(約翰·博伊德)提出。戰鬥機飛行員會運用它,等到最後的負責任時刻才決定行動方針,讓他們能根據當下情勢與可用資訊做出最佳決策。

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯