程式碼整潔之道 clean code

程式猿進階發表於2020-11-11

整潔的程式碼clean code):清晰、漂亮的程式碼,相信大多數工程師都希望自己能寫出這樣的程式碼。

本文主要針對物件導向程式設計的 clean code來闡述,程式導向程式碼的思路會比較不同,不在本文的討論範疇。

程式碼大部分時候是用來維護的,而不是用來實現功能的


這個原則適用於大部分的工程。我們的程式碼,一方面是編譯好讓機器執行,完成功能需求;另一方面,是寫給身邊的隊友和自己看的,需要長期維護,而且大部分專案都不是朝生夕死的短命鬼。大部分情況下,如果不能寫出清晰好看的程式碼,可能自己一時爽快,後續維護付出的代價和成本將遠高於你的想象。對清晰好看程式碼的追求精神,比所有的技巧都要重要。

優秀的程式碼大部分是可以自描述的,好於文件和註釋


當你翻看很多開原始碼時,會發現註釋甚至比我們自己寫的專案都少,但是卻能看的很舒服。當讀完原始碼時,很多功能設計就都清晰明瞭了。通過仔細斟酌的方法命名、清晰的流程控制,程式碼本身就可以拿出來當作文件使用,而且它永遠不會過期。相反,註釋不能讓寫的爛的程式碼變的更好。如果別人只能依靠註釋讀懂你的程式碼的時候,你一定要反思程式碼出現了什麼問題(當然,這裡不是說大家不要寫註釋了)。

說下比較適合寫註釋的兩種場景: 1. public interface,向別人明確釋出你功能的語義,輸入輸出,且不需要關注實現。 2. 功能容易有歧義的點,或者涉及比較深層專業知識的時候。比如,如果你寫一個客戶端,各種config引數的含義等。

設計模式只是手段,程式碼清晰才是目的


之前見過一些所謂“高手”的程式碼都比較抽象,各種工廠、各種繼承。想找到一個實現總是要山路十八彎,一個工程裡大部分的類是抽象類或者介面,找不到一兩句實現的程式碼,整個讀起程式碼來很不順暢。我跟他聊起來的時候,他的主要立場是:保留合適的擴充套件點,克服掉所有的硬編碼。

其實在我看來,也許他的程式碼被“過度設計”了。首先必須要承認的是,在同一個公司工作的同事,水平是參差不齊的。無論你用瞭如何高大上的設計,如果大多數人都不能理解你的程式碼或者讀起來很費勁的話,其實這是一個失敗的設計。當你的系統內大部分抽象只有一個實現的時候,要好好思考一下,是不是設計有點過度了,清晰永遠是第一準則

記住原則後,我們開始進入實踐環節,先來看下有哪些促成clean code的常見手段。

code review


很多大公司會用 git的 pull request機制來做 code review。我們重點應該 review什麼?是程式碼的格式、業務邏輯還是程式碼風格?我想說的是,凡是能通過機器檢查出來的事情,無需通過人。比如換行、註釋、方法長度、程式碼重複等。除了基本功能需求的邏輯合理沒有 bug外,我們更應該關注程式碼的設計與風格。比如,一段功能是不是應該屬於一個類、是不是有很多相似的功能可以抽取出來複用程式碼太過冗長難懂等等。

我個人非常推崇集體code review,因為很多時候,組裡相對高階的工程師能夠一眼發現程式碼存在較大設計缺陷,提出改進意見或者重構方式。我們可以在整個小組內形成一個好的文化傳承和風格統一,並且很大程度上培養了大家對clean code的熱情。

勤於重構


好的程式碼,一般都不是一撮而就的。即使一開始設計的程式碼非常優秀,隨著業務的快速迭代,也可能被改的面目全非。

為了避免重構帶來的負面影響(delay需求或者帶來bug),我們需要做好以下的功課: ① 掌握一些常見的“無痛”重構技巧,這在下文會有具體講解。 ② 小步快跑,不要企圖一口吃成個胖子。改一點,測試一點,一方面減少程式碼 merge的痛苦,另一方面減少上線的風險。 ③ 建立自動化測試機制,要做到即使程式碼改壞了,也能保證系統最小核心功能的可用,並且保證自己修改的部分被測試覆蓋到。 ④ 熟練掌握 IDE的自動重構功能。這些會很大程度上減少我們的體力勞動,避免犯錯。

靜態檢查


現在市面上有很多程式碼靜態檢查的工具,也是發現 bug和風格不好的比較容易的方式。可以與釋出系統做整合,強制把主要問題修復掉才可以上線。目前技術團隊內部的研發流程中已經普遍接入了 Sonar質量管理平臺

多讀開原始碼和身邊優秀同學的程式碼


感謝開源社群,為我們提供了這麼好的學習機會。無論是 JDK的原始碼,還是經典的 Netty、Spring、Jetty,還是一些小工具如 Guava等,都是 clean code的典範。多多學習,多多反思和總結,必有收益。

前面的內容都屬於熱身,讓大家有個整體巨集觀的認識。下面終於進入乾貨環節了,我會分幾個角度講解編寫整潔程式碼的常見技巧和誤區。

通用技巧


單一職責


這是整潔程式碼的最重要也是最基本的原則了。簡單來講,大到一個module、一個package,小到一個class、一個method乃至一個屬性,都應該承載一個明確的職責。要定義的東西,如果不能用一句話描述清楚職責,就把它拆掉。

我們平時寫程式碼時,最容易犯的錯誤是:一個方法幹了好幾件事或者一個類承載了許多功能。

先來聊聊方法的問題。個人非常主張把方法拆細,這是複用的基礎。如果方法幹了兩件事情,很有可能其中一個功能的其他業務有差別就不好重用了。另外語義也是不明確的。經常看到一個get()方法裡面竟然修改了資料,這讓使用你方法的人情何以堪?如果不點進去看看實現,可能就讓程式陷入bug,讓測試陷入麻煩。

再來聊聊類的問題。我們經常會看到“又臭又長”的service/biz層的程式碼,裡面有幾十個方法,幹什麼的都有:既有增刪改查,又有業務邏輯的聚合。每次找到一個方法都費勁。不屬於一個領域或者一個層次的功能,就不要放到一起。

我們 team在 code review中,最常被批評的問題,就是一個方法應該歸屬於哪個類。

優先定義整體框架


我寫程式碼的時候,比較喜歡先去定義整體的框架,就是寫很多空實現,來把整體的業務流程穿起來。良好的方法簽名,用入參和出參來控制流程。這樣能夠避免陷入業務細節無法自拔。在腦海中先定義清楚流程的幾個階段,併為每個階段找到合適的方法/類歸屬。

這樣做的好處是,閱讀你程式碼的人,無論讀到什麼深度,都可以清晰地瞭解每一層的職能,如果不 care下一層的實現,完全可以跳過不看,並且方法的粒度也會恰到好處。

簡而言之,我比較推崇寫程式碼的時候“廣度優先”而不是“深度優先”,這和我讀程式碼的方式是一致的。當然,這件事情跟個人的思維習慣有一定的關係,可能對抽象思維能力要求會更高一些。如果開始寫程式碼的時候這些不夠清晰,起碼要通過不斷地重構,使程式碼達到這樣的成色。

清晰的命名


老生常談的話題,這裡不展開講了,但是必須要 mark一下。有的時候,我思考一個方法命名的時間,比寫一段程式碼的時間還長。原因還是那個邏輯:每當你寫出一個類似於”temp”、”a”、”b”這樣變數的時候,後面每一個維護程式碼的人,都需要用幾倍的精力才能理順。

並且這也是程式碼自描述最重要的基礎。

避免過長引數


如果一個方法的引數長度超過4個,就需要警惕了。一方面,沒有人能夠記得清楚這些函式的語義;另一方面,程式碼的可讀性會很差;最後,如果引數非常多,意味著一定有很多引數,在很多場景下,是沒有用的,我們只能構造預設值的方式來傳遞。

解決這個問題的方法很簡單,一般情況下我們會構造 paramObject。用一個 struct或者一個 class來承載資料,一般這種物件是 value object,不可變物件。這樣,能極大程度提高程式碼的可複用性和可讀性。在必要的時候,提供合適的 build方法,來簡化上層程式碼的開發成本。

避免過長方法和類


一個類或者方法過長的時候,讀者總是很崩潰的。簡單地把方法、類和職責拆細,往往會有立竿見影的成效。以類為例,拆分的維度有很多,常見的是橫向/縱向。例如,如果一個service,處理的是跟一個庫表物件相關的所有邏輯,橫向拆分就是根據業務,把建立/更新/修改/通知等邏輯拆到不同的類裡去;而縱向拆分,指的是 把資料庫操作/MQ操作/Cache操作/物件校驗等,拆到不同的物件裡去,讓主流程儘量簡單可控,讓同一個類,表達儘量同一個維度的東西。

讓相同長度的程式碼段表示相同粒度的邏輯


這裡想表達的是,儘量多地去抽取 private方法,讓程式碼具有自描述的能力。舉個簡單的例子

public void doSomeThing(Map params1,Map params2){
   Do1 do1 = getDo1(params1);
   Do2 do2 = new Do2();
   do2.setA(params2.get("a"));
   do2.setB(params2.get("b"));
   do2.setC(params2.get("c"));
   mergeDO(do1,do2);
}
private void getDo1(Map params1);
private void mergeDo(do1,do2){...};

類似這種程式碼,在業務程式碼中隨處可見。獲取do1是一個方法,merge是一個方法,但獲取 do2的程式碼卻在主流程裡寫了。這種程式碼,流程越長,讀起來越累。很多人讀程式碼的邏輯,是“廣度優先”的。先讀懂主流程,再去看細節。類似這種程式碼,如果能夠把構造do2的程式碼,提取一個private 方法,就會舒服很多。

物件導向設計技巧


貧血與領域驅動


不得不承認,Spring已經成為企業級 Java開發的事實標準。而大部分公司採用的三層/四層貧血模型,已經讓我們的編碼習慣,變成了面向 DAO而不是物件導向。缺少了必要的模型抽象設計環節,使得程式碼冗長,複用程度比較差。每次擼程式碼的時候,從 mapper擼起,好像已經成為不成文的規範。

好處是上手簡單,學習成本低。但是每次都不能重用,然後面對兩三千行的類看著眼花的時候,我的心是很痛的。關於領域驅動的設計模式,本文不會展開去講。迴歸物件導向,還是跟大家 share一些比較好的 code技巧,能夠在一個通用的框架下,儘量好的寫出漂亮可重用的code。

個人認為,一個好的系統,一定離不開一套好的模型定義。梳理清楚系統中的核心模型,清楚的定義每個方法的類歸屬,無論對於程式碼的可讀性、可交流性,還是和產品的溝通,都是有莫大好處的。

為每個方法找到合適的類歸屬,資料和行為儘量要在一起


如果一個類的所有方法,都是在操作另一個類的物件。這時候就要仔細想一想類的設計是否合理了。理論上講,物件導向的設計,主張資料和行為在一起。這樣,物件之間的結構才是清晰的,也能減少很多不必要的引數傳遞。不過這裡面有一個要討論的方法:service物件。如果操作一個物件資料的所有方法都建立在物件內部,可能使物件承載了很多並不屬於它本身職能的方法。

例如,我定義一個類,叫做person。這個類有很多行為,比如:吃飯、睡覺、上廁所、生孩子;也有很多欄位,比如:姓名、年齡、性格。很明顯,欄位從更大程度上來講,是定義和描述我這個人的,但很多行為和我的欄位並不相關。上廁所的時候是不會關心我是幾歲的。如果把所有關於人的行為全部在 person內部承載,這個類一定會膨脹的不行。

這時候就體現了 service方法的價值,如果一個行為,無法明確屬於哪個領域物件,牽強地融入領域物件裡,會顯得很不自然。這時候,無狀態的 service可以發揮出它的作用。但一定要把握好這個度,迴歸本質,我們要把屬於每個模型的行為合理的去劃定歸屬。

警惕static


static方法,本質上來講是程式導向的,無法清晰地反饋物件之間的關係。雖然有一些程式碼例項(自己實現單例或者 Spring託管等)的無狀態方法可以用 static來表示,但這種抽象是淺層次的。說白了,如果我們所有呼叫 static的地方,都寫上import static,那麼所有的功能就由類自己在承載了。而單例的膨脹,很大程度上也是貧血模型帶來的副作用。如果物件本身有血有肉,就不需要這麼多無狀態方法。

static真正適用的場景:工具方法,而不是業務方法。

巧用method object


method object是大型重構的常用技巧。當一段邏輯特別複雜的程式碼,充斥著各種引數傳遞和是非因果判斷的時候,我首先想到的重構手段是提取 method object。所謂 method object,是一個有資料有行為的物件。依賴的資料會成為這個物件的變數,所有的行為會成為這個物件的內部方法。利用成員變數代替引數傳遞,會讓程式碼簡潔清爽很多。並且,把一段過程式的程式碼轉換成物件程式碼,為很多物件導向程式設計才可以使用的繼承/封裝/多型等提供了基礎。舉個例子,上文引用的程式碼如果用method object表示大概會變成這樣

 class DoMerger{
    map params1;
    map params2;
    Do1 do1;
    Do2 do2;
    public DoMerger(Map params1,Map params2){
       this.params1 = params1;
       this.params2 = parmas2;
    }
    public void invoke(){
        do1 = getDo1();
        do2 = getDo2();
       mergeDO(do1,do2);
    }
    private Do1 getDo1();
    private Do2 getDo2();
    private void mergeDo(){
        print(do1+do2);
     }
 }

面向介面程式設計


面向介面程式設計是很多年來大家形成的共識和最佳實踐。最早的理論是便於實現的替換,但現在更顯而易見的好處是避免public方法的膨脹。一個對外 publish的介面,一定有明確的職責。要判斷每一個public方法是否應該屬於同一個interface,是很容易的。整個程式碼基於介面去組織,會很自然地變得非常清晰易讀。關注實現的人才去看實現,不是嘛?

正確使用繼承和組合


這也是個在業界被討論過很久的問題,也有很多論調。最新的觀點是組合的使用一般情況下比繼承更為靈活,尤其是單繼承的體系裡,所以傾向於使用組合 ,否則會讓子類承載很多不屬於自己的職能。個人對此觀點持保留意見,在我經歷過的程式碼中,有一個小規律,我分析一下。

protected abstract 這種是最值得使用繼承的,父類保留擴充套件點,子類擴充套件,沒什麼好說的。

protected final 這種方法,子類是隻能使用不能修改實現的。一般有兩種情況: ① 抽象出主流程不能被修改的,然而一般情況下,public final更適合這個職能。如果只是流程的一部分,需要思考這個流程的類歸屬,大部分變成 public組合到其他類裡是更合適的。 ② 父類是抽象類無法直接對外提供服務,又不希望子類修改它的行為,這種大多數情況下屬於工具方法,比較適合用另一個領域物件來承載並用組合的方式來使用。

protected 這種是有爭議的,是父類有預設實現但子類可以擴充套件的。凡是有擴充套件可能的,使用繼承更理想一些。否則,定義成 final並考慮成組合。

綜上所述,個人認為繼承更多的是為擴充套件提供便利,為複用而存在的方法最好使用組合的方式。當然,更大的原則是明確每個方法的領域劃分。

程式碼複用技巧


模板方法


這是我用得最多的設計模式了。每當有兩個行為類似但又不完全相同的程式碼段時,我總是會想到模板方法。提取公共流程和可複用的方法到父類,保留不同的地方作為 abstract方法,由不同的子類去實現。並在合適的時機,pull method up(複用)或者 pull method down(特殊邏輯)。最後,把不屬於流程的、但可複用的方法,判斷是不是屬於基類的領域職責,再使用繼承或者組合的方法,為這些方法找到合適的安家之處。

extract method


很多複用的級別沒有這麼大,也許只是幾行相同的邏輯被 copy了好幾次,何不嘗試提取方法(private)。又能明確方法行為,又能做到程式碼複用,何樂不為?

責任鏈


經常看到這樣的程式碼,一連串類似的行為,只是資料或者行為不一樣。如一堆校驗器,如果成功怎麼樣、失敗怎麼樣;或者一堆物件構建器,各去構造一部分資料。碰到這種場景,我總是喜歡定義一個通用介面,入參是完整的要校驗/構造的引數, 出參是成功/失敗的標示或者是void。然後有很多實現器分別實現這個介面,再用一個集合把這堆行為串起來。最後,遍歷這個集合,序列或者並行的執行每一部分的邏輯。

這樣做的好處是: ① 很多通用的程式碼可以在責任鏈原子物件的基類裡實現; ② 程式碼清晰,開閉原則,每當有新的行為產生的時候,只需要定義行的實現類並新增到集合裡即可; ③ 為並行提供了基礎。

為集合顯式定義它的行為


集合是個有意思的東西,本質上它是個容器,但由於泛型的存在,它變成了可以承載所有物件的容器。很多非集合的類,我們可以定義清楚他們的邊界和行為劃分,但是裝進集合裡,它們卻都變成了一個樣子。不停地有程式碼,各種迴圈集合,做一些相似的操作。其實很多時候,可以把對集合的操作顯示地封裝起來,讓它變得更有血有肉。例如一個Map,它可能表示一個配製、一個快取等等。如果所有的操作都是直接操作Map,那麼它的行為就沒有任何語義。第一,讀起來就必須要深入細節;第二,如果想從獲取配置讀取快取的地方加個通用的邏輯,例如打個log什麼的,你可以想象是多麼的崩潰。

個人提倡的做法是,對於有明確語義的集合的一些操作,尤其是全域性的集合或者被經常使用的集合,做一些封裝和抽象,如把 Map封裝成一個 Cache類或者一個 config類,再提供 GetFromCache這樣的方法。

本文從 clean code的幾個大前提出發,然後提出了實踐 clean code的一些手段,重點放在促成 clean code的一些常用編碼和重構技巧。 當然,這些只代表筆者本人的一點點感悟。好的程式碼,最最需要的,還是大家不斷追求卓越的精神。歡迎大家一起探索交流這個領域,為 clean code提供更多好的思路與方法。

相關文章