也談程式碼 —— 重構兩年前的程式碼

為什麼我們談論程式碼?
也許有人會認為,談論程式碼已經有點落後了——程式碼不再是問題,我們應當關注模型、需求、功能設計上。運用 Google 的自動程式設計框架 AutoML 和UIzard 的 pix2code就可以自動生成程式碼,看起來我們正在臨近程式碼的終結點。
但是,注意但是!我們永遠無法拋棄程式碼, 就算語言繼續抽象,領域特定語言數量繼續增加,也終結不了程式碼。因為程式碼呈現了需求的細節,這些細節無法被忽略或抽象,必須明確之。將需求明確到機器可以執行的細節程度,就是程式設計要做的事。
我們可以創造各種與需求接近的語言,我們可以創造幫助把需求解析和彙整為框架結構的各種工具。 然而,我們永遠無法拋棄需求中的精確性和細節 —— 所以程式碼永存。
為什麼我們總是在寫爛程式碼?
有的人是因為大量的業務工作導致失去思考,有的人則是把提高程式碼質量寄希望於重構,但是往往在寫爛程式碼的人不知道自己寫的就是爛程式碼,他們沒有掌握好程式碼的技巧,或者根本沒有見過好程式碼,從而不知道什麼是好的實踐。
針對這種情況,下面會介紹好程式碼的特性和重構的技巧。從一個小的命名規範開始、逐步講到函式、再到類、再到模組單元、乃至整個設計。
培養你的程式碼感
就好像好的讀者不一定是好的作者,能分辨整潔程式碼和骯髒程式碼,也不意味著會寫整潔程式碼。
寫整潔程式碼,需要遵循大量的小技巧,貫徹刻苦習得的「程式碼感」。這種「程式碼感」就是關鍵所在。有些人生而有之,有些人費點勁才能得到。缺乏「程式碼感」的程式員,看混亂是混亂,無處著手。有「程式碼感」的程式設計師才能從混亂中看出其他的可能與變化,選出最好的方案。
想要得到「程式碼感」,最根本的途徑是反覆練習,接下來我將介紹大量的重構技巧和 demo ,強烈建議你在閱讀時帶上思考,對照自己的程式碼。
好程式碼需要遵循什麼?重構有哪些技巧?
技巧一:起一個清晰、合理、有意義的命名
有意義的命名是體現表達力的一種方式。
-
方法名應當是 動詞 或動詞短語,表達你的意圖。如 deletePage 或 savePage。
-
類名和物件名應該是 名詞 或名詞短語。如 Customer、WikiPage、Account和AddressParser。
-
不要以數字來命名,除非是
changeJson2Map()
這種情況。 -
單字母名稱僅用於短方法中的本地變數。比如迴圈體內的 i,但是你不應該在類變數裡使用 i , 名稱長短應與其作用域大小相對應 。
-
別給名稱新增不必要的語境。 對於 Address 類的實體來說,AccountAddress 和 CustomerAddress 都是不錯的名稱,不過用在類名上就不太好了。
-
遵循專業的術語。如果名詞無法用英文表達,一定要用中文拼音,則 不能用拼音縮寫 。
命名我往往會修改好幾次才會定下名字來,藉助 IDE 重新命名的代價極低,所以當你遇到不合理的命名時,不要畏懼麻煩,直接修改吧!
技巧二:保持函式短小、少的入參、同一抽象層級
我們都知道函式要儘量短小,職責要單一。但是你有沒有忽視過其他的問題?
來看下這段糟糕的示例程式碼:
private String url; private void startDownload(boolean isFormat, String userChannel, String userLevel, String regSource) { if (isFormat) { url = String.format(URL, userChannel, userLevel, regSource); if (url.length() != 0) { service.getData(url) .onSuccess(new Action() { Toast.showToast(context, "success").show(); }).onError(new Action() { Toast.showToast(context, "error").show(); }); } } else { // ... } }
這段程式碼至少犯了 4 個錯誤:
-
用了標識引數
isFormat
,這樣方法簽名立刻變得複雜起來,也表示了函式不止做一件事情,這應該把函式一分為二。 -
使用了多元引數。如果函式需要兩個、三個或三個以上的引數,就說明其中一些引數應該封裝成類了。
-
使用了輸出引數
url
,輸出引數比複雜的輸入引數還要難以理解。讀函式時,我們慣於認為資訊通過引數輸入函式,通過返回值從函式中輸出。我們不期望資訊通過引數輸出,輸出引數往往包含著陷阱。如果函式要對輸入引數進行轉換操作,轉換結果就該體現為返回值上。例:
appendFooter(s);
這個函式是把s
新增到什麼東西后面嗎?或者它把什麼東西新增到了s
後面?s
是輸入引數還是輸出引數?如果是要給s
添加個Footer
,最好是這樣設計:s.appendFooter();
。 -
沒有保持同一抽象層級。函式中混雜不同抽象層級,去拼接程式碼的同時又發起了網路請求,還處理了請求結果,這往往讓人迷惑。
思考下,你會怎麼重構這段程式碼?
我們展示重構後的情況:
private void startDownloadWhenNotFormat() { // ... } private void startDownloadWhenFormat() { UserProperty property = createUserProperty(); String url = jointUrl(property); startDownload(url); } private UserProperty createUserProperty(){ //... } private String jointUrl(UserProperty property) { return String.format(URL , property.getUserChannel() , property.getUserLevel() , property.getRegSource()); } private void startDownload(String url) { if (url.isEmpty()) { return; } service.getData(url) .onSuccess(new Action() { onGetDataSuccess(); }).onError(new Action() { onGetDataError(); }); } public class UserProperty { private String userChannel; private String userLevel; private String regSource; //... }
閱讀這樣的程式碼你會覺得很舒服,程式碼擁有 自頂向下的閱讀順序 ,主程式就像是一系列 TO 起頭的段落,每一段都描述當前抽象層級,並引用位於下一抽象層級的後續 TO 起頭段落,呈現出總-分的結構。
技巧三:短小、單一權責、內聚的類,暴露操作,隱藏資料細節
類的名稱其實就表現了權責,如果無法為某個類命以精確的名稱,說明這個類太長了,就應該拆分為幾個高內聚的小類。
那麼怎麼評估類的內聚性?類中的方法和變數互相依賴、互相結合成一個邏輯整體,如果類中的每個變數都被每個方法所使用,則該類具有最大的內聚性。
保持內聚性就會得到許多短小的類,僅僅是將較大的函式切分為小函式,就將導致更多的類出現。想想看一個有許多變數的大函式。你想把該函式中某一小部分拆解成單獨的函式。不過,你想要拆出來的程式碼使用了該函式中宣告的 4 個變數。是否必須將這 4 個變數都作為引數傳遞到新函式中去呢?
完全沒必要!只要將 4 個變數提升為類的實體變數,完全無需傳遞任何變數就能拆解程式碼了。將函式拆分為小塊後,你會發現類也喪失了內聚性,因為堆積了越來越多被少量函式共享的實體變數。
等一下!如果有些函式想要共享某些變數,為什麼不讓它們擁有自己的類呢?當類的變數越來越多,且變數的無關性越來越大,就拆分它! 所以,將大函式拆為許多小函式,往往也是將類拆分為多個小類的時機。
你以為這就結束了?停止你亂加取值器和賦值器的行為! 我們不能暴露變數的資料細節和資料形態,應該以抽象形態表述資料。
著名的得墨忒耳律認為:模組不應瞭解它所操作物件的內部情形,即每個單元(物件或方法)應當對其他單元只擁有有限的瞭解,不應該有鏈式呼叫。
哈?我們覺得方便的鏈式呼叫風格,實際上暴露了其他單元的內部細節??
我認為是要區別情況來對待,鏈式呼叫風格比較整潔和有表現力,但是不能隨意濫用,舉個簡單例子:
a.getB().getC().doSomething()
這種鏈式呼叫就違反了得墨忒耳定律,如果把 a.getB().getC().doSomething()
改成 a.doSomething()
,仍然違反了得墨忒耳定律。因為 a
裡面會有 b.getC().doSomething()
,所以 b 類中還應該有一個 doSomething()
方法去呼叫 c 的 doSomething()
, a.doSomething()
再來呼叫 b.doSomethine()
, a
對 b
的具體實現不可知。
鏈式風格用在 a.method1().method2().method3();
這種情況會比較合理。所以能不能用鏈式,需要看鏈的是一個類的內部還是不同類的連線。
技巧四:分離不同的模組
系統應將初始化過程和初始化之後的執行時邏輯分離開,但我們經常看到初始化的程式碼被混雜到執行時程式碼邏輯中。下面就是個典型的例子:
public Service getService() { if (service == null) { service = new MyServiceImpl(...); } return service; }
你會自以為很優雅,因為延遲了初始化,在真正用到物件之前,無需操心這種物件的構造,而且也保證永遠不會返回 null 值。
然而,就算我們不呼叫到 getService()
方法,MyServiceImpl 的依賴也需要匯入,以保證順利編譯。 如果MyServiceImpl 是個重型物件,單元測試也會是個問題。我們必須給這些延遲初始化的物件指派恰當的測試替身(TEST DOUBLE) 或仿製物件(MOCK OBJECT)。
我們應當將這個初始化過程從正常的執行時邏輯中分離出來,方法有很多:
1. 交給 init 模組
將全部構造過程移到 init 模組中,設計其他模組時,無需關心物件是否已經構造,預設所有物件都已正確構造。
2. 抽象工廠方法
系統其他模組與如何構建物件的細節是分離開的,它只擁有抽象工廠方法的介面,具體細節是由 init 這邊的介面實現類實現的。但其他模組能完全控制實體何時建立,甚至能給構造器傳遞引數。
3. 依賴注入中的控制反轉
物件不負責例項化對自身的依賴,而是把工作移交給容器,實現控制的反轉。比如 Android Dagger2 和 JavaEE Spring 都是這方面的實踐。
4. Builder 模式
可以簡單地把構造和構造的細節分離。
我們拆分了初始化和正常執行時邏輯,還有什麼可以繼續拆分的呢?
正常執行時邏輯除了業務邏輯,往往還混合了持久化、事務、列印日誌、埋點等模組,如果說 OOP 是把問題劃分到單個模組的話,那麼 AOP 就是把涉及到眾多模組的某一類問題進行統一管理。比如按 OOP 思想,設計一個列印日誌 LogUtils 類,但是這個類是橫跨並嵌入眾多模組裡的,在各個模組裡分散得很厲害,到處都能見到。而利用 AOP 思想,我們無需再去到處呼叫 LogUtils 了,宣告哪些方法需要列印日誌,AOP 會在編譯時把列印語句插進方法切面。AOP 思想有很多實踐:
1. 代理
代理適用於簡單的情況,例如在單獨的物件或類中包裝方法呼叫。然而,JDK提供的動態代理僅能與介面協同工作。對於代理類,你得使用位元組碼操作庫,比如CGLIB、ASM或Javassist 。
2. AOP 框架
把持久化工作用 AOP 交給容器,使用描述性配置檔案或 API 或註解來宣告你的意圖,驅動依賴注入(DI)容器,DI容器再實體化主要物件,並按需將物件連線起來。
後續我將會單獨談談Android中的 AOP 思想、框架選型和具體應用場景,敬請期待。
概言之, 最佳的系統架構由模組化的關注面領域組成,每個關注面均用純 Java 物件實現。不同的領域之間用最不具有侵害性的「方面」或「類方面」工具整合起來。
技巧五:用異常代替錯誤碼,但不傳遞異常,不傳遞 null
if (deletePage(page) == SUCCESS)
,咋看之下好像沒什麼問題,但是返回錯誤碼,就是在要求呼叫者立刻處理錯誤。你馬上就會看到這樣的場景:
if (deletePage(page) == SUCCESS) { mView.onSuccess(); } else { mView.onError(); }
熟悉不?更噁心的是這種情況:
int code = deletePage(page); if (code == CODE_404) { mView.onError1(); } else if(code == CODE_403){ mView.onError2(); } else if(code == CODE_505){ mView.onError3(); }
當你開始編寫錯誤碼時,請注意!這意味著你可能在程式碼中到處存在 if(code == CODE)
,其他許多類都得匯入和使用這個錯誤類。當錯誤類修改時,所有這些其他的類都需要重新編譯。 而且,錯誤碼和狀態碼一樣,會引入大量的 if-else 和 switch,隨著狀態擴充套件,if 就像麵條一樣拉長。回憶一下,你是不是用了不同的 code 來區分不同的錯誤?不同的使用者狀態?不同的表現場景?
所以忠告有 2 點:
-
使用異常替代返回錯誤碼,將錯誤處理程式碼從主路徑中分離
-
不僅僅分離錯誤處理程式碼,還要把 try-catch 程式碼塊的主體部分抽離出來,另外形成函式,函式應該只做一件事。錯誤處理就是一件事。因此,處理錯誤的函式不該做其他事。
重構後:
public void delete(Page page) { try { deletePageAndAllReferences(page); } catch (Exception e) { logError(e); } } private void deletePageAndAllReferences(Page page) throws Exception { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } private void logError(Exception e) { logger.log(e.getMessage()); }
在上例中,異常使我們把正常程式碼和錯誤程式碼隔離開來,但是我不建議你濫用異常,思考一下,如果你在低層的某個方法中丟擲異常,而把 catch 放在高階層級,你就得在 catch 語句和丟擲異常處之間的每個方法簽名中宣告該異常。每個呼叫這個函式的函式都要修改,捕獲新異常,或在其簽名中新增合適的throw子句。以此類推,最終得到的就是一個從最底端貫穿到最高階的修改鏈。封裝完全被打破了,在丟擲路徑中的每個函式都要去了解下一層級的異常細節。
所以不要傳遞異常,在合適的地方,及時解決它!
還有另一種情況你經常看到:
UserData userData = service.getUserData(url); if(userData!=null){ if(userData.getUserName!=null&&userData.getUserName.length>0){ userNameTextView.setText(userData.getUserName); } else{ userNameTextView.setText("--"); } if(userData.getRegChannel!=null&&userData.getRegChannel.length>0){ regChannelTextView.setText(userData.getRegChannel); } else{ regChannelTextView.setText("WHAN"); } }
真是可怕!到處都是判空和特殊操作! 如果你打算在方法中返回 null 值,不如丟擲異常,或是返回空物件或特例物件。 你可以學習 Collections.emptyList( )
的實現,建立一個類,把異常行為封裝到特例物件中。
對付返回 null 的第三方 API 也是如此,我們可以用新方法包裝這個 API,從而幹掉判空。
技巧六:保持邊界整潔,掌控第三方程式碼
我們經常會使用第三方開源庫,怎麼將外來程式碼乾淨利落地整合進自己的程式碼中。是每個工程師需要掌握的技巧,我們希望每次替換庫變得簡單容易,所以首先要縮小庫的引用範圍!怎麼縮小?
-
封裝:不直接呼叫第三方api,而是包裝多一層,從而控制第三方程式碼的邊界,業務程式碼只知道包裝層,不關心工具類的具體實現細節。在你測試自己的程式碼時,打包也有助於模擬第三方呼叫。 打包的好處還在於你不必綁死在某個特定廠商的API 設計上。你可以定義自己感覺舒服的API。
-
使用 ADAPTER 模式
程式碼整潔之道還提出個有意思的做法,為第三方程式碼編寫學習性測試。
我們可以編寫測試來遍覽和理解第三方程式碼。在編寫學習性測試中,我們通過核對試驗來檢測自己對 API 的理解程度。測試幫助我們聚焦於我們想從 API 得到的東西。
當第三方開源庫釋出了新版本,我們可以執行學習性測試,馬上看到:程式包的行為有沒有改變?是否與我們的需要相容?是否影響了舊功能?
技巧七:保持良好的垂直格式和水平格式
垂直格式上
- 最頂部展示高層次的概念和演算法,細節往下漸次展開,越是細節和底層,就應該放在原始檔的越底部。
- 緊密相關或相似的程式碼應該互相靠近,呼叫者應該儘可能放在被呼叫者的上面,實體變數要靠近呼叫處,相關性弱的程式碼用空行隔開。
水平格式上
- 程式碼不宜太寬,避免左右拖動滾動條的差勁體驗。
- 用空格字元把相關性較弱的事物分隔開。
- 遵守縮排規則。
技巧八:為程式碼新增必要的註釋,維護註釋
請注意,我說的是 必要的 註釋,只有當代碼無法自解釋時,才需要註釋。
好的程式碼可以實現自文件,自注釋,只有差的程式碼才需要到處都註釋。
如果你開始寫註釋了,就要思考下:是否程式碼有模糊不清的地方?命名是否有表達力?是否準確合理?函式是否職責過重,做了太多事情,所以你必須為這個函式寫長長的註釋?如果是這樣,你應該重構程式碼,而不是寫自認為對維護有幫助的註釋。很多情況下只需要改下命名、拆分函式,就可以免去註釋。
不要以為寫完註釋就完了, 註釋和程式碼一樣,需要維護。
如何規避重構的風險?
在寫程式碼之前, 強烈建議你先完成單元測試 ,然後一邊實現功能一邊調整單測覆蓋場景。
實現功能時,程式碼一開始都冗長而複雜,完成功能後,通過單元測試和驗收測試,我們可以放心地重構程式碼,每改動一小塊,就及時執行測試,看功能是否被破壞,不斷分解函式、選用更好的名稱、消除重複、切分關注面,模組化系統性關注面,縮小函式和類的尺寸,同時保持測試通過。
如何保持程式碼的優雅?
只要遵循以下規則,程式碼就能變得優雅:
-
編寫更多的測試,用測試驅動設計和架構。
測試編寫得越多,就越能持續走向編寫較易測試的程式碼,持續走向簡單的設計,系統就會越貼近 OOP 低耦合高內聚的目標。沒有了測試,你就會失去保證生產程式碼可擴充套件的一切要素。正是單元測試讓你的程式碼可擴充套件、可維護、可複用。原因很簡單:有了測試,你就不擔心對程式碼的修改!沒有測試,每次修改都可能帶來缺陷。無論架構多有擴充套件性,無論設計劃分得有多好,沒有了測試,你就很難做改動,因為你擔憂改動會引入不可預知的缺陷。
-
保持重構,當加入新功能的時候,要思考是否合理,是否需要重構這個開啟修改的模組。
-
不要重複,重複程式碼代表遺漏了抽象,重複程式碼可能成為函式或乾脆抽成另一個類。
-
保持意圖清晰,選用好的命名,短的函式和類,良好的單元測試提高程式碼的表達力。
-
儘可能減少類和方法的數量,避免一味死板地遵循以上 4 條原則,從而導致類和方法的膨脹。
開始重構,逐步改進
衡量成長比較簡便的方法,就是看三個月前,一年前,自己寫的程式碼是不是傻逼,越覺得傻逼就成長越快;或者反過來,看三個月前,一年前的自己,是不是能勝任當下的工作,如果完全沒問題那就是沒有成長。
既然聊到程式碼規範和重構技巧,Talk is cheap. Show me the code. 就以自己兩年前的程式碼為例,但當我拿起兩年前的專案時……

簡單粗暴放上 gif,重構過程更直觀。

更換命名、幹掉switch、拆分成子函式

選用更準確的命名

選用有表達力的命名

按最少知道原則修改方法
最後
技巧是可以學習掌握的,重點是有意識培養自己的程式碼感,培養解耦的思想。不要生搬硬套技巧,不要過度設計,選擇當下最適合最簡單的方案。
同時我們需要不斷回顧自己寫過的程式碼,如果覺得無需改動,要麼是設計足夠優秀,要麼就是沒有輸入,沒有成長。
如果你對自己有更高的要求,希望以下的資料可以幫助你。
幫助你管理程式碼質量的工具
- SonarLint
- 阿里編碼規範外掛
更多方法論書籍
- 「重構-改善既有程式碼的設計」
- 「程式碼整潔之道」
- 「設計模式-可複用面向物件軟體的基礎」
- 「馴服爛程式碼」
- 「修改程式碼的藝術」
- 「編寫可讀程式碼的藝術」
有意思的網站
- ofollow,noindex">https://refactoring.guru/