重複你自己
原文由 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 原則常被誤解為一條要不計代價避免任何重複的鐵律,這反而會導致複雜度增加。
當你為了避免重複而引入抽象時,你必須在一個遠離實際商業邏輯的地方處理所有的邊界情況。你最終會在抽象中加入多餘的檢查與條件,只是為了確保它在所有情況下都能運作。之後,你可能會忘記當初加入那些檢查的理由,但還是抱著「以防萬一」的心態把它們留著,因為你不想破壞任何呼叫端。結果就是產生了一堆增加程式碼庫複雜度的無用程式碼;全都是因為你想避免重複自己。
一般認為,如果你重複自己,就得在多個地方修正同一個錯誤。但這個假設前提是,錯誤存在於所有的複本中。實際上,每個複本都可能以不同的方式演變,而錯誤可能只存在於其中一個。
當你建立了一個共用的抽象,抽象中的一個錯誤會讓所有呼叫端都出錯,一次就破壞多個功能。而如果是重複的程式碼,錯誤只會被隔離在某個特定的使用情境中。
事後再整理
要確定你在共用的抽象中沒有破壞任何東西,遠比檢查單一份程式碼複本要困難得多。當然,如果你有很多份複本,也有可能會漏掉某個地方忘了修正。
讓這個方法可行的關鍵,是事後要記得整理。這可以在提交程式碼之前,或是在程式碼審查時進行。
在這個階段,你可以檢視自己複製的程式碼,看看是要維持原樣比較合理,還是已經能看出正確的抽象。我會等到對問題有更深入的理解後才嘗試重構,但不會更早。
要復原一個糟糕的抽象,有個技巧是把程式碼重新內聯回原本使用它的地方。有一陣子,你的程式碼庫會再次變得「重複自己」,但那沒關係。根據你掌握的新資訊重新思考問題,往往就能找到更適合問題的、更好的抽象。
當抽象是錯的,最快的向前之路就是往回走。
—Sandi Metz,The Wrong Abstraction
tl;dr
尋找正確的抽象是好事,但別為此過度糾結。當複製程式碼能幫助你保持動能、找到正確的抽象時,就別害怕這麼做。
這句話值得再說一遍:「重複你自己。」
舉例來說,請參考 Ferris 開發 Rustendo64 的過程 或 tokiospliff 開發 C++ 遊戲引擎的過程。 ↩
我寫文章也是用同樣的方式:先寫出初稿,暫時壓抑內心的批判聲音,然後再扮演編輯/批判者的角色來「重構」文章。這樣我就能兼得兩者的好處:快速的回饋循環不會阻礙創造力,而最終的成品則更加精緻、結構更完整。當然,這個方法不是我發明的。如果你想更深入了解這個技巧,我推薦閱讀 Anne Lamott 的著作 Bird by Bird: Instructions on Writing and Life 中的「Shitty first drafts」一章。 ↩
這類似於 OODA 循環(OODA loop)的概念,也就是「觀察(Observe)、定向(Orient)、決策(Decide)、行動(Act)」。它是由軍事戰略家 John Boyd 所提出。戰鬥機飛行員會運用它,等到最後負責任的時刻才決定行動方案,以便根據當下的情勢與可用資訊做出最佳決策。 ↩
隨機一篇部落格
留言
登入後參與討論