在前面的文章中,我們介紹了 《提升編程效率:重構》 以及 《何時開始重構?》。了解了那些能夠更好的輔助團隊或者個人進行重構,但是要讓重構真正產生作用是需要能夠代碼中的壞味道,并消除代碼中的壞味道。
如下圖是工作中常見的代碼的壞味道:
上圖中的壞味道出自《重構》這本書,雖然并不是全部,但是涵蓋了日常中最常見的一些代碼壞味道。
接觸這些壞代碼可以分為三類:
見名知意的代碼壞味道:
稍微解釋即可掌握的代碼壞味道;
通過一些例子即可掌握的代碼的壞味道;
本文主要聚焦在“見名知意的代碼壞味道”,后續兩類壞味道將在后續的文章中解釋說明:
1. 重復代碼
簡單的復制/粘貼,或者無意間添加了相同邏輯的代碼都是有可能的導致重復代碼出現的。
那么為什么重復的代碼是一種壞味道?
最明顯的就是重復的代碼容易造成修改時的遺漏,修改遺漏導致一個問題需要修改多次才能才能確定最終修改完成。如果有一部分修改了,另外一部分沒有修改且沒有被發現,日后再遇到感覺類似,實則不同的代碼會花費大量的時間確定業務上的需求,實現上應該如何處理。
重復代碼這類壞味道產生的成本很低,但是帶來的影響卻是很大。
如何解決重復代碼問題?
- Simple Design 為我們提供了參考參考原則:“通過測試,揭示意圖,消除重復,最少元素”。
- 如果重復代碼發生在一個類中,且兩段代碼完全重復,可以借助 Extract Method (提煉函數)這個重構手法來消除重復;提煉函數時 IDE 一般都會自動提示是否同時修改重復的代碼,減少重構的工作量。
- 如果重復代碼發生在一個類中,且兩段代碼之后部分重復。那么可以將部分重復的代碼通過 Extract Method 的手法,提煉到單獨的方法中,并替換掉部分重復的代碼。
- 如果重復的代碼在不同的類中,且這些類是兄弟類,可以使用 Pull Up Method,將重復的代碼提煉到父類,并讓原本的類繼承父類。
- 如果重復的代碼在不同的類中,且這些類之間關聯性不大,那么可以 Extract Class,將重復的挪動到一個新的類中,原本出現重復的地方來調用這個新產生的類的方法。
- 消除重復之后,檢測代碼表達的意圖是否準確、完成,Extract Method 時可以通過良好的方法名來解釋提煉的函數的作用和意圖。
2 長函數
顧名思義,長度過長的函數。其中包括兩種情況,橫向過長,縱向過長。
為什么長函數是一種壞味道?
橫向過長時,往往一眼無法快速了解該行代碼要表達的意思和中間的過程。當出現 Bug 定位問題時也不容易一次性定位到問題所在。
縱向過長時,往往會感覺某個函數內部邏輯復雜、晦澀難懂。修改代碼中也會因為無法照顧到要修改的方法中的其他行代碼,而顧此失彼,最終導致難度難修改。經過多次修改后甚至原有的基本結構都會遭到破壞,導致后續修改難度逐漸增加。
如何解決長函數的問題?
- 橫向過長的代碼,可以通過代碼格式化、CheckStyle插件來發現和消除。比如,Lambda 表達式,可以選擇在出現第一個“.”時就就開始換行。
List<Node> nodes = items.stream().filter(Item::isFree).filter(Item::notWork).map(Formater::format).filter(Node::hadChildren).filter(Node::hadMarked).collection(Collectors.toList());
這行代碼我們需要仔細讀 才能清楚中間的過程。采用首個“.”出現換行的將會是如下格式:
List<Node> nodes = items
.stream()
.filter(Item::isFree)
.filter(Item::notWork)
.map(Formater::format)
.filter(Node::hadChildren)
.filter(Node::hadMarked)
.collection(Collectors.toList());
通過對橫向代碼格式化能夠為代碼帶來更好的可讀性。當然你可以在提交代碼到倉庫時勾選上 commit 時自動格式化代碼的選項,避免沒有 Check Style 等工具來守護代碼,遺漏掉格式問題。
- 縱向過長的代碼。往往多個實現細節堆疊在一個方法中造成的,這種情況下使用 Inline Temp(內聯局部變量)、 Extract Method 的重構手法來提煉小的函數。一個類中有很多零散的小函數也是常見的,因此提煉函數的同時記住,提煉函數的也是也是考慮創建新的類時候,將不同作用的函數提煉到響應職責的類中。
- 縱向過長的代碼,往往存在職責不夠單一的情況,保持方法職責的單一有助于維護代碼的可讀性。通過 2 中 提到的 Extract Method,那么某個具體實現細節可以被提煉到一個小函數中,而原來的函數則職責就編程調度作用。所以方法的單一職責,更清晰的描述應該是一類事情,要么只在處理實現細節,要么處理調度協調代碼調用。
public class OrderService{
...
public Order create(OrderDTO orderDTO) {
// 創建條件是否符合 4 行
...
// 貨幣轉換 4 行
...
// 折扣計算 5 行
...
// 將 OrderDTO 轉換為 Order 對3行
...
// 存儲 Order 1 行
...
// 通知下有業務 5 行
...
return order;
}
}
看遺留系統時和面試作業的時候,總是看到這類代碼,可以通過提煉函數并遵守方法的單一職責原則,就能夠簡單的重構實現一個邏輯更為清晰的代碼結構,如下:
public class OrderService {
...
public Order create(OrderDTO orderDTO) {
verify(orderDTO);
Order order = orderRepository.save(orderMapper.toOrder());
notifyService.notify(order);
return order;
}
private void verify(OrderDTO orderDTO) {
// 創建條件是否符合 4 行
...
}
}
public interface OrderMapper {
...
public Order toOrder() {
// 將 OrderDTO 轉換為 Order 對3行
Currency currency = CurrentyTranslator.translator(currency); // 貨幣轉換
BigDecimal price = currentyTranslator.calculate(products); // 提煉函數
...
}
}
public class CurrentyTranslator {
public static Currency translate (Currency currency) {
// 貨幣轉換 4 行
...
}
}
public class PriceService {
public BigDecimal calculate(List<Product> products) {
// 折扣計算 5 行
...
return xxx;
}
}
public class NotifyService {
private void notify(Order order){
// 通知下有業務 5 行
...
}
}
上面只是一個簡單的重構方法,其中涉及到的重構手法:
? Move Field(搬移函數)將上下文相關的變量挪動的一起;
? Extract Method (提煉函數) 將某個具體的實現提煉到一個職責單一的方法中。
? Extract Method (提煉類)一個類尤其單獨的職責,因此將那些和原本的該類的職責關聯性不大的邏輯方法提煉到特定的類中。
? Inline Field(內聯臨時變量)如果一個變量對語意理解并沒有什么幫助,那么就可以采用內聯臨時變量的方法,消除顯示的定義變量,從而減少代碼的行數,同時閱讀代碼時也會更加清爽、聚焦。
更具實際業務場景還可以借助一些注解、工具類、AOP 來讓驗證、轉換、通知部分變得更加簡潔。通過提煉函數的重構手法,能夠讓后續的重構更加方便可靠。
如果翻閱一些開發規范會發現有的團隊規定一個方法不超過 15 行,其實知道這個規范只能獲取到一個參考量,注意到行數多對,更重要的時候發現問題后的小步重構。
3 過大的類
顧名思義就是一個類做了太多的事情。SOLID 原則告訴我們類的職責應該是單一的,而一個過大類很可能意味著承擔了多個/多類職責。
過大的類為什么是一種壞味道?
由于過大的類承擔了過多的職責,很容易導致 重復代碼 且 重復代碼 不容易被發現,而這往往是壞味道的開始。
如果過大的類對外提供服務發生了變動,并不容易快速響應這樣的變化,可以對比一下一個小而職責單一的類中進行修改方便還是在多很多職責。
當過大的類因為某個地方發生變化,很可能導致不相關的調用方的代碼也會發生變化,這是一種耦合性的表現。
當過大的類被繼承時很可能導致其他的壞味道,例如遺留的饋贈。
因此,保持小而職責單一的類將會對系統的設計有很大的幫助。當然也可以參考 Simple Design,避免過度設計的前提下保持簡單的設計。
如何解決過大的類的代碼壞味道?
- 觀察這個過大的類的屬性,看是否有關聯的幾個屬性能夠代表一定的業務意思,如果可以使用 Extract Class,將這幾個屬性挪動到一個新的類中,并將相關操作挪動到新的類中。循環往復,這樣一個大的類能夠拆分成多個小的且職責較為單一的類。
- 觀察這個大類中的方法,看是否存在兄弟關系的方法,如果有可以使用 Extract Subclass (提煉子類)的方法,將相關方法提煉到子類中,并考慮使用繼承父類還是面向接口使用 Extract Interface(提煉接口)。這樣相似行為的行為聚集在一個類中,拆分到多個類中,并可以進一步和方法的調用發來解耦。
- 進一步觀察剩余類的行為,如果這些行為在處理一類事情,那么可以停止了,在處理多類事情,可以按照處理邏輯的類型進一步拆分。
簡而言之,使用一個亙古不變的法則:分治法。將過大的類,拆分成多個職責單一的小類,手段是 Extract Class,Extract Subclass,Extract Interface。
4 過長參數列表
當方法的參數列表過長時這也是一種代碼的壞味道。
?
為什么參數過長是一種壞味道?
參數過長和過大的類、過長的函數、重復代碼一樣,起初并不會導致什么錯誤,但是代碼隨著時間向前演變過程,會給代碼帶來很多麻煩。
長參數函數的可讀性很差,尤其是存在多個類似長參數方法時,并不容易判斷出應該使用哪個方法。
當需要為長參數函數添加新的參數時,將會促使調用方發生變化,且新參數的位置也將讓這個方法更加難以理解。
如何解決長參數的代碼壞味道?
- 如果傳遞的幾個參數都出自一個對象,那么可以選擇使用 Preserve Whole Object(保持完整對象)直接傳遞該對象。
- 如果方法的參數來自不同的對象,可以選擇使用 Introduce Parameter Object(引入參數對象)將多個參數放入一個新的類中,原來方法傳遞多個分開的參數,現在傳遞一個包含多個屬性的一個對象。
- 如果調用者先計算調用 A 方法得到計算結果,然后將計算結果在傳遞給這個長參數函數,那么可以考慮去除這個參數,改為在長參數函數中直接調用 A 得到結果,從而消除傳遞的部分參數,這個重構過程可以參考 Replace Parameter With Method(使函數替換參數)??。
需要的注意的是,有些情況下長參數的存在也是合理的,因為在一定程度上可以避免某些依賴關系的產生。可以通過觀察長參數函數變化的頻率,并采用“事不過三,三則重構“的原則,保持進行重構的準備。
5 Switch 語句
Switch 語句代表一類語句,比如 if...else, switch... case 語句都是 switch 語句。
為什么 Switch 語句是一種代碼壞味道?
首先并不是所有的 Switch 語句都是壞味道,Swith 語句開發中常見的語句。這里帶有壞味道的 Switch 語句指的是那些造成重復代碼的 Switch語句。例如:根據某個狀態來判斷執行執行哪個動作。
public Order nextStep(...) {
if (state == 1) {
// do something
} else if (state == 2) {
// do something
} else if (state == 3) {
// do something
} else {
// do something
}
}
這種實現方法很多代碼中都會出現,但是多數人使用這種方式添加代碼,并不意味著這是一種好的代碼。這樣的實現方式很容易造成長函數,而且每次修改的位置要非常精準,需要在多個條件中逐個遍歷找到最終需要的那個,再修改,可讀性上無疑也是很差的。
如何處理 Switch 語句這種代碼壞味道呢?**
- 如果 swtich 語句是某個方法的一部分,那么不妨使用 Extract Method(提煉函數)將其先提煉出一個單獨的方法,縮小上下文范圍。
- 觀察多個條件中的動作的關聯關系,是否符合多態,如果是將符合多態的幾個條件創建對應的類,并使用 Move Method (移動函數)移動到新創建的類中。
- 使用狀態模式、枚舉等多種實現手段消除其中的 swtich 語句。
如果對有限狀態機感興趣可以參考文章:《Java有限狀態機的4種實現對比》
總而言之,一旦打算通過疊加新的 swtich case 來添加新邏輯,那么就應該關注一下代碼設計,因為這種操作很有可能就是為后續的代碼在挖坑。同時理解清楚那些swtich 語句是具有壞味道的語句。
6 夸夸其談的未來性
這是工作中最常見的一類問題,比如如果你聽到這句話“我將文件上傳的實現做了調整 ... 未來再使用的時候將會 ...”就應該警覺起來。
為什么夸夸其談的未來型是一種代碼壞味道?
未來意味著當下并不是必須的,過度的抽象和提升復雜性也會讓系統難以理解和維護,同時也容易分散團隊的注意力,如果用不到,那么就不值得做。
除非你在進行假設驅動開發,否則代碼上總是談未來容易綁架團隊的思想,拿未來不確定的事情來解釋事情的合理,會讓那些務實者,關注投入產出比的抉擇。并且容易讓團隊進入一個假象。
當業務上變動時,并不能及時的將代碼進行變動,因為原來的代碼中包含了一種對未來假設的實現,無形中增加了代碼的復雜度,而且很容易增加團隊溝通成本。
如何解決夸夸其談的未來性的代碼壞味道?
Simple Design (簡單設計原則)能夠幫助我們作出抉擇。當實現業務代碼時考慮”通過測試、揭示意圖、消除重復、最少元素“。
當發現為未來而寫的代碼時,可以:
- 刪除那些覺的未來有用的參數、代碼、方法調用。
- 修正方法名,使方法名揭示當下業務場景的意圖,避免抽象的技術描述詞。
通過上面兩個過程將代碼原本的要表達的意思還原回來。
工作中有兩類未來性。一類是假設調用方可以怎么使用;一類是未來必然發生的業務功能。代碼的壞味道更多的指的是第一種情況,第二種情況可以開發之前體現進行簡單設計和拆分,從而避免過度設計,同時可以避免談未來性,來讓代碼隨著功能一起小步重構并演進。
7 令人迷惑的臨時字段
在一些場景下為了在實現上的臨時方便性,有的開發者會直接在某個對象上添加一個屬性,后續使用在需要的時候使用該屬性。
令人迷惑的臨時字段的是什么代碼壞味道?
一個類包含屬性和方法,屬性都是該類相關的。而臨時向類中添加的字段,雖然臨時有關聯性,但是單獨來看這個類中的屬性時,卻會讓人覺得非常費解。有些接口的返回值就是也是類似原因導致的結果,每次為了方便像類中直接添加一些臨時屬性,滿足了當時的需要,但是后續再使用的時候卻并不能區分哪些屬性時必須的,哪些是不必須的,以及哪些被添加的字段的上下文分別是什么。
如何解決令人迷惑的臨時字段?
- 問題的原因是隨意向類上添加字段,解決的方法就是將這個臨時字段移走,可以為這個字段找到一個合適的類來存放,也可以使用 Extract Class (提煉類)將這個字段添加到一個新類中,然后將該字段的相關的邏輯移動到該類中,并確定該類的職責。
- 可以將臨時字段作為參數進行傳遞,但是為了避免過長參數的出現,可以選擇將臨時字段提煉到一個新的類中。
8 過多的注釋
這是注釋降低代碼可讀性,甚至誤導了代碼要要表達的意圖。
為什么過多的注釋是一種代碼壞味道?
首先并不是所有的注視都是壞味道。
如果想通過注釋來表達代碼的意思,那么代碼修改了注釋也需要同步進行修改,如果代碼修改了但是沒有修正這是注釋就有可能導致誤導。
還有一種注釋的壞味道,指的是不使用的代碼通過注釋掉來表示其棄用。后續代碼的閱讀者會經常收到斷斷續續的注釋掉的代碼影響。降低讀代碼和改代碼的速度。
在 《Clean Code》 中羅列了一些注釋的壞味道:
喃喃自語
多余的注釋
誤導性注釋
循規方注釋
日志式注釋
廢話注釋
用注釋來解釋變量意思
用來標記位置的注釋
類的歸屬的注釋
-
注釋掉的代碼
...
如何解決過多的注釋的代碼壞味道?
造成使用注釋的原因很多,可以考慮移除這些注釋:
- 刪除被注釋掉不再使用的代碼
- 如果某段代碼沒有辦法輕松的解釋清楚,可以使用 Extract Method 來,并使用提煉的方法名來表達意圖。
- 刪除多余的注釋,誤導性注釋,如有必要可以將方法重命名,解釋意圖。
- 用來說明變量意思的注釋刪除掉,對變量進行重命名,如果這個變量并不是必須的可以選擇將變量進行 Inline Temp。
上面介紹了代碼中常見的 8 中代碼壞味道,這些壞味道見名知意,每種壞味道通過簡單的幾步重構即可解決。面對這些壞味道應該避免延遲解決,隨時保持代碼的整潔。