重複自己
在我從事軟體工作的生涯中,最常聽見的建議之一就是「不要重複自己」(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
尋找正確的抽象化沒有錯,但別為此過度糾結。當複製程式碼能幫助你保持動能、並找到正確的抽象化時,就別害怕這麼做。
值得再說一次:「重複自己吧。」
舉例可參考 Ferris 開發 Rustendo64 的過程 或 tokiospliff 開發 C++ 遊戲引擎的過程。↩
這也是我寫文章的方式:我先寫出草稿,壓抑內心的批判聲音,然後再扮演編輯/批判者的角色來「重構」文章。這樣我就能兼得兩者的好處:既有不阻礙創意的快速回饋循環,又能得到更精緻、結構更完整的成品。當然,這個方法不是我發明的。如果你想更了解這個技巧,我推薦閱讀 Anne Lamott(安妮·拉莫特)的著作 Bird by Bird: Instructions on Writing and Life(《一隻鳥接著一隻鳥:寫作與人生指引》)中的「Shitty first drafts」一文。↩
這類似於 OODA loop(OODA 循環)的概念,也就是「觀察、定位、決策、行動」(Observe, Orient, Decide, Act)。它由軍事戰略家 John Boyd(約翰·博伊德)提出。戰鬥機飛行員會運用它,等到最後的負責任時刻才決定行動方針,讓他們能根據當下情勢與可用資訊做出最佳決策。↩
隨機一篇部落格