Long Method
A method longer than ten lines is considered too long.
氣味的徵兆
過長的方法(Long Method)常見的特徵是在一個方法內同時想要完成太多任務。更具體的說,當一個方法的程式碼超過十行時,就很可能太長了。
每一個方法在首次簽入git版本控制時,看上去通常相當袖珍可愛,就像是哺乳類動物的幼仔通常會特別討人憐愛一樣,目的是吸引照顧者的餵食與保護。但隨著時間過去,這些嬌小可愛的貓咪有一日也可能成為駭人的巨獸,反噬這些粗心大意的開發者。每當新需求到來時,開發者總是傾向相信只要新增一兩行程式碼,加上一點額外邏輯來滿足需求與行為的改變,就可以讓這些方法能夠做到更多不同的商業邏輯。這在每次版本發布前的程式改動回顧會議時看上去都人畜無害,畢竟只是一兩行為了新需求的改動,誰能夠拒絕呢?但是隨著時間迭代,每次一行兩行的新增程式碼逐漸會讓方法成為失控且搖搖欲墜的疊疊樂,在不知不覺間讓方法擔負了過重也不洽當的職責。
氣味的原因
氣味之所以是氣味,必然有其足夠解釋的原因:
難以閱讀與理解: 二十行程式碼比起十行所需閱讀理解的時間更長,而五行甚至三行又比起十行來說更好懂。簡單來說,長方法更容易產生Bug與消耗不必要的閱讀時間,容易讓開發者產生誤解。
違反單一職責原則 Single Responsibility Principle (SRP): 當方法過長時,很可能是因為這個方法企圖完成不只一件個任務。另一個判斷方式是當方法內的任務很難用方法名稱來總結概括意圖時,最好拆出更多小的方法來取代。
可能存在副作用: 長方法可能存在很多子任務,而多項子任務可能導致開發者在使用時產生非預期的結果。常見的範例如同混用查詢與修改資料狀態,例如當我們有一個
Book.get(id)
方法,預期行為是從資料庫中回傳得到一個書本類別的實體物件,但若是方法內包括了計數器去累計書本被閱讀或點擊的次數實作後,很可能在不同情境下會錯誤增加了許多非預期的紀錄。可能存在重複的片段: 當我們比較幾個結構相似的長方法時,我們很可能會發現其中存在重複的片段,值得抽出來共用。
執行時間更長: ”No code is faster than no code”. 一個常見的觀察是,最快的程式碼往往是做最少事情的程式碼,這也是鼓勵我們盡量保持程式碼簡潔的原因之一。
增加測試成本: 當方法越長越複雜,我們自然需要更多不同邏輯與路徑去維護單元測試。同時長方法也可能讓測試所需時間增加,而不僅僅是維護測試本身所消耗的成本。
總體而言,我們必須定期檢視方法中的程式碼片段是否長度超乎必要。當我們把長方法透過各種手段技巧拆解為更小的單元並分散管理,便可以加快閱讀理解、有利於測試,也更好維護。
氣味對應的重構技巧
在Sandi的分享裡,她提到對照表上的重構方法可以分別對應到兩本重構領域的經典之作,分別是"Improving the Design of Existing Code",作者是 Martin Fowler;以及 "Refactoring to Patterns",作者是Joshua Kerievsky。在對照表中分別以「F」與「K」代表兩書,並附上書籍頁碼。可惜的是,或許因為版本差異因素,我個人並沒有辦法依照頁碼對應到指定的重構方法,反而是依靠名稱從書籍目錄中找到對應的區塊。
以下是對照表所列出的Long Method長方法可以對應的重構技巧,因為無法對應所以移除頁碼索引部分:
Extract Method
Compose Method
Introduce Parameter Object
Move Accumulation to Collecting Parameter
Move Accumulation to Visitor
Decompose Conditional
Preserve Whole Object
Replace Conditional Dispatcher with Command
Replace Conditional Logic with Strategy
Replace Method with Method Object
Replace Temp with Query
原本天真的我打算參考對照表,針對重構技巧逐一進行介紹。但當我開始著手調查研究時,很快發現一個問題:除了對照表的意見外,網路上實際存在各家門派不同的看法。最巨大的差異是,如同氣味可以分為五大類,重構技巧當然也可以分門別類,而對照表中所列的「Compose Method」在大部分的參考資料中,實際上是作為重構技巧的分類存在,Compose Method之下又包含了七種重構技巧,包括了對照表中的第一項重構「Extract Method」。
考慮到重構技巧分類存在的合理性,不得不打破原本對照表的設計,根據參考資料重新彙整而成為全新的二階版本:
Composing Method
Extract Method
Inline Method
Inline Temp
Extract Variable (Introduce Explaining Variable)
Replace Temp with Query
Replace Method with Method Object
Substitute Algorithm
Simplifying Method Calls
Introduce Parameter Object
Preserve Whole Object
Simplifying Conditional Expressions
Decompose Conditional
Replace Conditional with Polymorphism
Replace Conditional Dispatcher with Command
Move Accumulation
Move Accumulation to Collecting Parameter
Move Accumulation to Visitor
初始是這樣規劃,但是完成所有氣味後發現,多數的氣味所對應的重構技巧並沒有複雜到需要以二階層方式來呈現,所以直接省略重構技巧類別。
Composing Method
關於這個重構類別,我主要參考來自三個不同出處,分別是Martin Fowler所著作的「Improving the Design of Existing Code」第六章節頁89「Composing Methods」、Joshua Kerievsky所著作之「Refactoring to Patterns」內頁62「Compose Method」一章、以及知名重構網站Refactoing Guru有關「Composing Methods」的介紹。雖然我會盡我所能地吸收、統整、消化後來呈現出自己的觀點,但我依然相當推薦各位對於「重構」這個主題有興趣的朋友,有機會都可以去查閱比較三處不同的原始版本介紹,或許也能夠獲得與我不同的啟發。
Compose Method是指微小精妙且易於理解閱讀的方法(method),而「Composing」是動名詞,描述以「Compose Method」為目標的一系列重構行為。可以留意Martin與Joshua分別在此處採用不同的命名方式,我的解讀是前者更在意過程中的技巧手法(Composing Methods),後者則更重視目標(Compose Method)。華文翻譯或可稱為「重新組織方法(函數)」或是「優化方法(函數)」,但我都不是很滿意,故華文翻譯暫且保留只留英文。
作為重構技巧的分類,Composing Methods之下共有九種圍繞在「方法」之上的重構技巧,但並非全部都是針對「Long Method」這種氣味。考慮到本文是聚焦在如何重構Long Method的對應技巧之上,這邊忍痛放棄Composing Methods的深度介紹與以下的所有重構技巧說明,只披露其中七種對應Long Method的手法。
Extract Method
多數時候當我們想要簡化Long Method時,第一個想到的便是Extract Method (抽出方法)。讓我們直接看範例:
當我們觀察到存在鄰近的程式碼區塊執行相似的任務,並且需要註解來輔助表示意圖,代表我們很有可能可以抽取出成為獨立的方法,並從原本的方法中引用。重構後的範例如下:
物件導向中其中一個很重要的概念是「封裝」。多數時候我們無需逐行去了解註解「Print details」下方每一行程式碼具體而言要做些什麼有沒有副作用與意外,我們只要單純去呼叫printDetails()
便可,如此就可以省下不必要的閱讀理解時間。
Inline Method
如果一個方法的實作本身比起命名更能表達其意圖,直接使用實作來取代意味不明的方法呼叫。或許可以視為Extract Method的反向手法。
numberOfLateDeliveries()
在這個範例中,並沒有比起實作帶來更多資訊,對於多數開發者來說,直接看程式碼實作反而更好理解。因此當我們觀察到這樣的氣味,可以採取以下作法:
當我們省略冗余的額外方法呼叫,改而採用能夠更直觀理解的表示時,我們就成功地讓方法變得比原來更短、更整潔。我們必須小心避免讓方法只是另外一個方法的傳聲筒,而其中卻沒有包含任何額外意圖。
Inline Temp
如果你在方法內發現有一個臨時變數(temporary variable)的功用僅僅只是表達自身而沒有其他邏輯包括在其中,或許我們可以考慮將其省略,這樣的重構手法稱之為 Inline Temp。
如範例所示,basePrice
的唯一功用就是暫存order.basePrice()
回傳的數值。在這樣的情境中,我們可以直接取用order.basePrice()
,請見以下重構後的結果:
是不是更短更簡潔了呢?
Extract Variable (Introduce Explaining Variable)
Extract Variable(抽出變數)也可稱呼為 Introduce Explaining Variable(引入解釋性參數),我認為恰好與 Inline Temp手法相反,主要是為了提升可讀性而存在。這個技巧與我們首先介紹的 Extract Method相當類似,只是抽出的對象分別是方法與變數的不同。
在範例中我們可以見到if
條件句中存在相當冗長的判斷,請注意當我們討論 Long Method氣味時,除了垂直方向程式碼行數的長度外,當然也需要留意水平方向每行程式碼的長度。合宜的程式碼長度並沒有統一固定的標準,過去通常會建議在一個螢幕寬,但隨著螢幕越做越寬到可以分割畫面的水準,我認為螢幕寬已經不是放諸四海皆準的標準。在Ruby中通常會建議每行不要超過150的字元,我認為大方向來說,同一行程式碼所包括的概念越少越好。
在這個範例中,我們可以作以下的重構:
如你所見,雖然renderBanner()
方法的行數比起重構之前更多,但關鍵意義上的邏輯判斷,if
判斷句內的isMacOs && isIE && wasInitialized() && wasResized
變得更容易理解也一目瞭然。隨著相似的程式碼片段出現,後續這些元素也可以考慮 Extract Method 抽出為獨立的方法進行共用,來做到下一步更簡潔的瘦身效果。
Replace Temp with Query
當你發現存在一個臨時變數用來接住查詢結果,我們可以考慮將之抽出為獨立的查詢方法。
觀察上述的範例,我們發現basePrice
除了用來判斷是否大於一千外,還用做計算新價格的基準。這其中並沒有重複給值,因此我們可以作以下重構:
這樣的技巧與 Extract Method 有些接近,兩者的差異在於抽出的是一個臨時變數,並且抽出為查詢方法(Query Method)。
Replace Method with Method Object
這是一個相對進階的重構技巧,需要具備對物件導向的理解才有辦法實作,將方法用新的方法物件取代。
舉例來說當你有一個方法內包含許多高度綁定的區域變數(local variables),讓你無法輕易地使用 Extract Method 抽出方法來進行重構時,我們可以先將這些區域變數打包為一個全新的類別,接著在原方法內宣告一個新類別的實體,這樣就可以將複雜冗長的實作搬移到新類別當中。
讓我們看看範例程式:
如你所見,price()
方法內包含了許多區域變數如primaryBasePrice
等,這會讓抽出處理變得困難。此時我們可以透過一個新的類別(Class)來進行封裝。
請將焦點放在我們所要處理的核心部位,也就是price()
之上。將訂單(order)物件自身作為參數,宣告一個新的價格計算類別(class PriceCalculator)的實體後,我們可以將原本複雜扭曲的邏輯輕鬆用一行表示為new PriceCalculator(this).compute()
,簡直如同魔法一樣神奇。
當然可能會有人懷疑表示,新增的價格計算類別可能會反而使得專案程式碼總行數增加,但是這樣一個將長方法(Long Method)拆解為Compose Method與另一個中等尺寸類別的行為,正是我們此處重構技法的核心精神:化大為小,一分為二。
Substitute Algorithm
當你發現在方法內存在複雜的演算(complicated algorithm),我們可以考慮將其用更小更簡潔的演算來取代。具體實作方式可能因為不同的程式語言而有很大的差異,但此處我以Java作為示範:
這個範例是在迴圈內存在多個沒有效率的if
判斷式進行字串比對,熟悉Java或平時有在刷題的朋友,或許能很快找到更好更短的表達,譬如以下的重構:
我們使用Arrays.asList
創造出一個包括姓名的List
,並且使用contains
來比對字串,取代原本幾乎是寫死的if
判斷式。這樣的好處是便於擴充,當有新的人名出現時,只需要改動candidates
,而無需增加一個新的if
判斷子句。我必須強調這樣的範例只能示範 Substitute Algorithm 的一小部分,同時如何在不同語言內實作優化演算法表示,還請自行努力。
Simplifying Method Calls
根據Refactoring Guru網站的資料,Simplifying Method Calls之下共有14種重構技法。但此處依照對照表,我們能夠對應到 Long Method 氣味的只有其中兩種。一樣礙於篇幅請恕我省略其餘12種重構技巧與 Simplifying Method Calls的深入頗析。
Simplifying Method Calls 顧名思義是企圖簡化方法的呼叫,而在Martin Fowler所著作的「Improving the Design of Existing Code」一書中,第十章(頁 220)則以「Making Method Calls Simper」稱呼之,我認為兩者只是可以省略的命名差異。只是我更偏愛簡短的版本來符合避免 Bloster 氣味的精神,所以省略多餘的動名詞(Making)。
Simplifying Method Calls 與 Composing Method 相似的地方在於都是聚焦在優化方法(method)之上,只是前者會更限縮於圍繞在「方法呼叫」(Method Calls),而後者則是方法本身。
Introduce Parameter Object
將方法中過長的呼叫參數轉化整合成為單一物件。這個重構方法與另一個我們在後面幾天也會介紹到一樣屬於 Bloaters 的程式碼氣味 Data Clumps (資料團塊)高度相關,但後者的氣味泛指任何資料團塊而不限縮於方法中的參數。
最常見的例子就是開始與結束日期,例如我們範例如下:
實際上開始與結束日期總是成對出現而且缺一不可,所以可以重構如下:
如此一來,方法的取用跟呼叫就變得更簡單,也可以略為提升程式碼閱讀速度。
Preserve Whole Object
當你從不同的物件中取得數值並傳入方法中作為參數使用,我們可以直接將物件本身傳入。
從範例中我們可以發現,low
與high
都是從daysTempRange
物件取得的值。所以實際上我們並不需要分別作為參數傳入,可以重構如下:
此處省略了示範如何修改withinRange(low, high)
到withinRange(daysTempRange)
的過程,但這只是變更呼叫getLow()
與getHigh()
的位置,我相信懂得就懂無須多言。
Simplifying Conditional Expressions
當我們發現有過於複雜的條件判斷存在時,最好考慮將其適度簡化。與多數重構分類相同,在這個分類下一共有8種不同的重構技巧,但此處我們只優先討論符合 Long Method 長方法氣味相符的其中三種。
Decompose Conditional
當你發現存在複雜的條件判斷邏輯(不管是if
判斷式或是switch
判斷式),我們可以嘗試將其拆解包裝成方法,並且引用。
例如我們有一段判斷目前是否為夏天,並據此有不同計算邏輯的條件判斷式,在物件導向的世界裡,我們鼓勵將複雜的實作包裝起來, 眼不見為淨 能夠更聚焦於當下的任務。
即使是最初階的開發者,只要略懂英文,都可以輕鬆讀出重構後的意圖。當目前日期(date)是夏天時,則以夏日費率計價;當非夏天時,則以非夏日費率計價。這樣的邏輯簡單清楚到讓說明或註解都顯得多餘,就是我們理想中讓程式碼自身表達意圖的效果,而不需要額外依賴註解、文件、測試來讓開發者明白商業邏輯。好的程式碼應該解釋自身。
Replace Conditional with Polymorphism
對照表上稱為「Replace Conditional Logic with Strategy」,但在Joshua Kerievsky的書中(頁 22),他稱為「Replace Conditional Calculations with Strategy」;而在 Refactoring Guru與 Refactoing.com網站上,則是被稱為「Replace Conditional with Polymorphism」,我個人認為後者的稱呼更為貼切。這些名稱差異讓我困惑了一下,差一點誤以為是不同技巧。
簡言之,當我們發現存在複雜冗長的條件判斷,而判斷基準是物件本身的屬性時,我們可以考慮物件導向中的「多形」(Polymorphism)來取代條件判斷。這樣的手法則是被Joshua稱呼為 Strategy Object ,但在實作上是相同的。
直接看範例會更容易理解:
假設我們有一個鳥的類別,而不同物件實體在歐洲與非洲等不同條件下存在不同速度。這樣的條件判斷很容易出現在任何專案當中,而且看上去運作良好,開發者很可能會誤以為這樣的程式碼已經「足夠」良好,但是我們依然有技巧可能嘗試簡化,請看重構後的範例:
當我們為了不同屬性的鳥創造出更多不同行為的子類別,然後共同繼承鳥的父類別,透過物件導向子類別可以改寫父類別行為的方式,來省略複雜的條件判斷。當我們手上有一個鳥的實體,只需要簡單呼叫bird.getSpeed()
,就可以依據物件當前的子類別是European
或African
,而回傳不同邏輯。
這種技巧通常不會在專案或功能初始就被採用,而是隨著時間逐年複雜度越來越高之後,會發現分離出更多的子類別是管理邏輯的適切技巧。
Replace Conditional Dispatcher with Command
這個重構技巧與上一個類似,但不同之處是將複雜的邏輯以 Command objects 取而代之。讓我們直接看範例:
當我們有複雜的邏輯判斷字串requestType
去實作不同的命令時,我們可以把每一個命令任務的實體抽出來為一個命令類別的物件(Command Object)。
當各種命令的類別準備好以後,我們回到原有的程式碼,呼叫我們剛剛創造的命令物件。
請注意command
的類別會由commandMap.get(requestType)
給定,因此我們只需要簡單呼叫command.execute(data)
,便可以確保資料data
有傳入正確的命令類別中執行。
Moving Accumulation
我無法在現有的資料中找到這個重構手法的分類,但又發現有兩種相近的手法。如同大多數開發者的習慣,當我們發現缺少什麼時,就自己創建一個,所以全新的重構分類「Moving Accumulation」誕生了!如果有朋友發現更好的分類方式或參考資料,歡迎與我分享。
Move Accumulation to Collecting Parameter
這個重構手法也可見於Joshua 書中的同名章節(頁 55),但卻無法在 Refactoing Guru 與 Refactoring.com 網站上找到。在查找資料時,真是深切感受到命名不統一的困擾。
實作上可能因為不同程式語言會有差異,但基本概念是將準備回傳的集合成果收集為參數傳入,而不是直接在迴圈中累加。讓我們看範例:
整數sum
在迴圈中的功能很單純,就是合計數字集合int[] numbers
中所有符合偶數的總和。這樣的寫法固然正確回傳我們所要的結果,但可能導致可重新取用性差以及不好維護的問題。我們可以考慮以下的重構:
在重構範例中,我們新增了一個集合evenNumbers
去搜集每一個偶數,然後簡單的合計總和得到一樣的結果。許多朋友看到這裡可能會質疑,即使是在範例中,方法也因此變得更長,怎麼可以算是 Long Method 的對應重構技巧呢?
我們可以將這個範例視為一個重構的中間過程,事實上,當邏輯更清晰以後,我們有許多部分是可以進一步抽出來簡化,這部分可以參考上面所提及的技巧。即使只是單純考慮行數,在重構的過程中暫時增長是非常正常且自然的事情,正如同Sandi曾經在不同的分享中也提及「清楚明白地重複也好過糟糕的抽象化」(Duplication is cheaper than wrong obstraction)。在尚未清楚類別抽出方向時,先讓相似的程式碼靠近排列,是一種實務上很常使用的技巧。
Move Accumulation to Visitor
這個重構技巧與我們剛剛介紹的方法高度相似,但是把資料搬移到Visitor Object(Visitor Pattern)。
訪問者模式(Visitor Pattern)是指一種設計模式,可以傳入操作或命令,但卻不需要改變其資料結構或修改物件本身。核心概念是把資料結構與物件本體和操作(operation)或命令(command)分離開來。
我必須坦白承認,現階段的我尚且無法完全理解這個重構技巧。所以比起半桶水的囫圇吞棗,我決定果斷放棄,並且推薦網友可以參照 Joshua 所開設的顧問公司 Industrial Logic 官網上的介紹來進行了解。
Last updated