程序代碼有雙重目的,一是供機器執行,二是供程序員閱讀。而代碼的質量,往往體現在第二點,可讀性是優秀代碼的重要指標。在寫代碼時注意形成和保持代碼的可讀性,不僅有助於別人閱讀,更有助於自己進一步的編寫和完善。 《代碼整潔之道》(Clean Code)一書提出了這樣一種觀念:”代碼質量與其整潔度成正比。干凈的代碼,既在質量上較為可靠,也為后期維護、升級奠定了良好基礎”(Robert C. Martin,Object Mentor公司的創始人和總裁)。在編寫代碼時,實現既定的功能只是最基本的要求,不僅要寫“對的代碼”,更要寫“整潔的代碼”。
對於“整潔代碼”,C++語言的創始人Bjarne Stroustrup說道:“我喜歡優雅和高效的代碼。代碼邏輯直截了當,叫缺陷難以隱藏,使之便於維護;依據某種分層戰略完善錯誤處理代碼;性能調至最優,省得引誘別人做沒規矩的優化,搞出一堆混亂來。整潔的代碼只做好一件事。”整潔代碼的特點是形式上的優雅和功能上的高效:遵循“只做好一件事”的原則,使用簡潔的邏輯解決這件事,以獲得代碼的“優雅”;周密地考慮並設計代碼的層級結構,處理各種可能出現的異常,單一的功能和簡潔的邏輯便於代碼“性能最優”的調整,代碼的高效水到渠成。
“整潔代碼”包含諸多的涵義,編寫“整潔代碼”需要遵循大量的技巧和規則,這里只從提高可讀性的角度,從命名方法、函數設計、格式和注釋四個方面,對提高代碼可讀性的方法做簡單的探討。
1. 命名方法
1.1. 一個例子
為了說明命名方法對代碼可讀性的影響,先看下面一段代碼:
public List<int[]> getThem() { List<int[]> list1 = new ArrayList<int[]>(); for (int[] x : theList) if(x[0] == 4) list1.add(x); return list1; }
從字面上看,函數名稱叫getThem,最后返回了list1,說明這段代碼是為了獲取某樣東西;程序遍歷了theList,如果其中某項零下標值為4,則把它加到list1中。閱讀這段代碼,我們可以獲知程序的工作流程,但無法知道它究竟實現了什么功能。想要真正弄懂這段程序的意義,我們至少要知道list1和theList代表什么?theList零下標有什么意義?值4有什么意義?返回值是做什么的?而這些內容在代碼中都沒有體現。假設這段代碼是一個掃雷游戲程序中的一段,它用來獲取當前雷區中標記了旗子的單元格,這里theList為整個雷區,int型數組表示單元格,零下標項是一種狀態值,該狀態值為4時表示“標記了旗子”;把零下標值為4的項,也就是標記了旗子的單元格存儲到list1中,最后返回list1也就得到了雷區中標記了旗子的單元格。這樣才算真正了解了這段代碼的意義。
把上面的代碼作如下改寫:
public List<int[]> getFlaggedCells() { List<int[]> flaggedCells = new ArrayList<int[]>(); for(int[] cell : gameBoard) if(cell[STATUS_VALUE] == FLAGGED]) flaggedCells.add(cell); return flaggedCells; }
函數名getThem改名為getFlaggedCells,函數的目的就可以從函數名看出來,就是獲取標記的單元格;原程序里面的集合指示隨意取了一個名字叫list1,如果不往下讀就無法知道里面到底要存什么東西,把它改寫為flaggeCells,顧名思義就可以知道這個數組里存儲的是被標記的單元格;原程序循環里變量的名稱也是隨意取的,可以看出來是要遍歷整個theList,無法得知theList是什么,改寫后就可以知道這里是遍歷整個板面的單元格;從原程序的if語句里只可以看出這里要判斷theList中整形數組的首元素是否為4,完全不知道為什么要這么做,而從改寫后的代碼中就可以獲知這里是對單元格的狀態值進行判斷,判斷單元格的狀態是否是“已標記”,如果是則把這個單元格添加僅flagedCells中,進行記錄;源程序返回值list1依舊不知所謂,改寫flaggedCells表達返回值的意義,同時與函數名getFlaggedCells照應,任務完成。
這里只修改了變量的名字,用常量代替了數值,把抽象的結構封裝為類,代碼的簡潔性仍保留了下來,運算符、常量和嵌套的數量保持不變,但代碼變得明確的多。更進一步,不用int數組表示單元格,而是另寫一個類,包含一個函數isFlagged來掩蓋4這個魔術數,代碼就更加接近於自然語言。最終得到的代碼如下:
public List<Cell> getFlaggedCells() { List<Cell> flaggedCells = new ArrayList<Cell>(); for(Cell cell : gameBoard) if (cell.isFlagged()) flaggedCells.add(cell); return flaggedCells; }
上面的例子說明命名的重要性,對於函數、變量、常量等內容,好的命名可以顯提高代碼的可讀性。
1.2. 常見命名規則
在介紹命名需要遵循的原則前,下面首先列出一些常見的命名規則。
1.2.1. 帕斯卡命名法(PascalCase)
帕斯卡命名法混合使用大小寫字母來構成變量和函數的名字,組成命名的每個邏輯斷點都有一個大寫字母來標記。一般用於命名較大的概念,如類名等。例:Cell,UsrName。
1.2.2. 駝峰命名法(camelCase)
駝峰命名法,是指混合使用大小寫字母來構成變量和函數的名字,第一個單詞首字母小寫,其余首字母大寫,這樣的命名看上去像駝峰一樣,中間高兩邊低。一般用於命名函數/方法,變量等。例:cell,gameBoard,getFlaggedCells。
1.2.3. 下滑線法
下滑線法顧名思義,命名中的每一個邏輯斷點都用下划線來標記。一般用於命名常量、枚舉類型,C語言和Linux內核編碼風格中很多采用下划線法來命名。例:STATUS_VALUE。
值得注意的是,常量的命名常用大寫字母,以示與各種變量的區別。
1.2.4. 匈牙利命名法(Hungarian)
匈牙利命名法通過在變量名前面加上相應的小寫字母的符號標識作為前綴,標識出變量的作用域,類型等。Windows 編程中用到的變量(還包括宏)的命名遵循匈牙利命名法,早期編譯器不做類型檢查,程序員需要通過命名來幫助自己記住類型。現代編程語言具有更豐富的類型系統,特別是像C這種強類型的語言,編譯器會記住並檢查類型,使用匈牙利命名法增加了修改名稱或類型的難度,就顯得多余了。現在使用匈牙利命名法的一個好處是通過IDE的自動完成功能,進行快速查找。如在進行用戶圖形界面設計時,使用匈牙利命名法可以方便地對不同控件進行區分和查找。
1.3. 命名的原則
1.3.1. 名副其實
關於命名,首先應遵循的原則是名副其實。變量、函數或類的名稱應該能夠解釋程序的大部分功能,應該能夠告訴讀者,它為什么存在,它做什么事,應該怎么用。如果名稱需要注釋來補充,那就不算名副其實。如前面例子中的命名:
public List<int[]> getThem()
就無法解釋這個方法的功能是什么,而
public List<Cell> getFlaggedCells()
就是名副其實的命名。
1.3.2. 避免誤導
不使用有歧義的詞,不使用區別過小的名稱。如這樣的兩個變量:
int XYZControllerForEfficientHandlingOfString = 0; int XYZControllerForEfficientStorageOfString = 0;
它們差異很小,很容易形成誤導,導致混淆或錯用。
1.3.3. 做有意義的區分
編譯器要求同一作用范圍內兩樣不同的東西不能重名,出現重名的問題時,一般只是隨手改掉其中一個的名稱,添加一些廢話或者數字,雖然足以讓編譯器滿意,但卻不易於讀者閱讀。如果名稱必須相異,則其意義也應該不同。
如下面一段代碼:
public static void copyChars(char a1[], char a2[]) { for(int i = 0; i < a1.length; i++) { a2[i] = a1[i]; } }
這是一個用來復制char型數字的函數,用數字后綴來區分兩個數組,函數名沒有提供理解函數所需的全部信息,沒有提供導向作者意圖的線索,我們必須閱讀函數的具體代碼才能知道這個函數是把a1復制給a2。如果更改一下里面的命名,變成:
public static void copyChars(char source[], char destination[]) { for(int i = 0; i < source.length; i++) { destination[i] = source[i]; } }
用destination代替a1,用source代替a2,僅從函數名就可以了解函數的運行機制。
1.3.4. 使用易讀的名稱
人類通過語言進行交流,如果一個命名無法讓人在看到的時候自然地讀出來,就會妨礙交流。如這樣一個命名genymdhms,代表生成時的時間,年月日時分秒,十分簡練,但幾乎完全無法讀出來;使用恰當的英文單詞比生硬的自造詞更容易讓人理解,如generationTimestamp。 可以使用適當的縮寫來縮短名稱,但不能過於簡練以至於讓人費解。
2. 函數的設計
下面通過一個例子說明函數設計所要遵循的規則。
設想這樣一個場景:PC與外部數據終端通過一套特定的指令集進行交互,PC發往外部數據終端的指令格式為“AT+<指令名>=<參數>, <參數>, ..., <參數><CR><LF>”,一條指令中可以包含若干個參數,用逗號隔開,末尾的<CR><LF>為格式控制字符,如指令“AT+CTSP=1,3,130<CR><LF>”,其中指令名為“CTSP”,參數有三個,分別為“1”,“3”和“130”;外部數據終端發往PC的指令格式為“+<指令名>=<參數>, <參數>, ..., <參數><CR><LF>”,如指令“+CMGS: 184,2<CR><LF>”,其中指令名為“CMGS”,參數有兩個,分別為“184”和“2”。
現有一個類來處理PC與外部數據終端交互的指令,要求設計一個方法(函數),來獲取指令中指定的的參數(如獲取指令“AT+CTSP=1,3,130<CR><LF>”中第2個參數,應為“3”)。
對於獲取指令中指定的參數的方法,初步的設計是需要三個參數:首先需要一個String型的參數command來傳入指令的內容,還需要一個枚舉型參數commandType來指明指令的類型(PC發送給外部數據終端,還是外部數據終端發送給PC)以判斷第一個參數的起始位置(第一個參數前為“=”還是“:”),最后需要一個整型參數parameterNumber指明所要獲取的是指令中的哪一個參數。這樣,這個方法就可以定義為:
public String getParameter(String command, CommandType commandType, int parameterNumber);
這個設計看上去理所當然,三個參數直接包含了解決問題所需的全部信息,但仍存在很大的改進空間。
2.1. 盡可能少的參數
最理想的參數數量是零個,其次是單參數函數,再次是雙參數函數,應盡量避免使用三參數函數。參數引入了額外的概念,在理解函數名的同時,還需要理解參數的內容和作用,參數越多,代碼就越不容易閱讀。尤其是對帶有輸出參數的函數更難以閱讀,我們一般慣於認為函數通過參數傳入需要的信息,並通過返回值來輸出處理后的信息。如果一個函數不得不需要兩三個或更多的參數,那就說明其中的一些參數應該封裝為類了。
現在重新審視剛才的getParameter方法:
public String getParameter(String command, CommandType commandType, int parameterNumber);
其中的參數commandType用來指示指令的類型,據此獲知第一個參數前為“=”還是“: ”,以獲取第一個參數的起始位置。既然已經有了參數command來傳入指令內容,就可以在指令中直接查找“=”和“: ”:如果指令中有“=”,則把“=”之后的位置作為第一個參數的起始位置;如果指令中有“: ”,則把“: ”之后的位置作為第一個參數的起始位置(這里假設已知在指令中“=”和“: ”只會出現一次,且不會同時出現)。參數commandType的信息已經包含在了參數command里,不需要額外通過參數傳入,可以簡化掉。
這樣函數就剩下了兩個參數,但還是不夠好。觀察剩下的兩個參數command和parameterNumber,對於指令內容command,可以預見在查找參數結束位置以及修改指令參數時都會用到,而且指令內容顯然也是指令本身的屬性,應該定義為類的屬性,這樣就不再需要通過參數傳遞。 最終設計的函數的參數就只有參數序號parameterNumber一個,即:
public String getParameter(int parameterNumber);
parameterNumber是獲取指令中指定參數所必須的信息,無法再進行精簡。這樣就把一個三參數的函數縮短到了單參數的函數。
2.2. 短小
這樣就可以把獲取指令參數的方法寫出來:
public String getParameter(int parameterNumber) throws CommandException { String parameter = new String("None"); int startOfParameter = -1, endOfParameter = -1; if(parameterNumber == 1) { startOfParameter = command.indexOf(':'); if(startOfParameter == -1) { startOfParameter = command.indexOf('='); startOfParameter = startOfParameter + 1; } else { startOfParameter = startOfParameter + 2; } } else { for(int i = 0; i < parameterNumber - 1; i++) { startOfParameter = command.indexOf(',', startOfParameter); } } endOfParameter = command.indexOf(',', startOfParameter); if(endOfParameter == -1) { endOfParameter = command.indexOf('\r', startOfParameter); } if((startOfParameter != -1) && (endOfParameter != -1)) { parameter = command.substring(startOfParameter, endOfParameter); } else { throw new CommandException("Cannot find the specific parameter."); } return parameter; }
這個方法中,先獲取指定參數開始和結束的位置,再根據參數在指令字符串中的起始位置來獲取參數;獲取第一個參數時,還需要考慮參數前是“=”還是“:”的情況,如果指令中有“=”,則第一個參數緊跟其后,如果指令中有“:”,則第一個參數在“:”后第二個位置(“:”后是空格,然后才是參數)。
這樣寫出來的函數很長,過程復雜,如果事先對所處理的指令沒有足夠的了解,是無法讀懂這段程序的。編寫函數的一條重要規則是短小。要做到短小,首先要注意的是,函數應該只做一件事。
2.2.1. 只做一件事
函數應該只做一件事。剛才的那段長代碼顯然做了好幾件事,它先對指令類型進行判斷,然后查找參數的起止位置。而實際上這個函數真正應該做的事,只是獲取指令這一段。其他內容都不是這個函數應該做的事,都應該拆分出去成為單獨的函數。這里拆分並不是隨意地把代碼剪切出去寫成函數,要按函數所實現功能的抽象層級來划分函數的范圍。
2.2.2. 每個函數一個抽象層級
編寫函數是為了把大一些的概念拆分成各個抽象層上的一系列步驟,按照划分好抽象層級編寫函數,可以讓代碼具有自頂向下的閱讀順序,讓每個函數后面都跟着下一抽象層級的函數,在查看函數列表時,就能遵循抽象層級向下閱讀。
getParameter這個方法的目的是獲取指令中的指定參數,實現這個目標的過程,可以分成三級:
- 為了設置指令中的參數,首先需要找到所設置參數在指令中的開始和結束的位置,再把新的參數加進去。
- 為了查找指令開始的位置,需要先判斷當前的指令是接收的指令還是發送的指令。
- 為了判斷當前指令是否是接收的指令······
- ······
- 為了查找指令結束的位置······
- 為了查找指令開始的位置,需要先判斷當前的指令是接收的指令還是發送的指令。
- ······
重構后的代碼結構就應該設這樣:
public String getParameter(int parameterNumber) { ······ int startOfParameter = getStartIndexOfParameter(parameterNumber); int endOfParameter = getEndIndexOfParameter(parameterNumber); ······ } private int getStartIndexOfParameter(int parameterNumber) { ······ } private int getEndIndexOfParameter(int parameterNumber) { ······ }
頂層是我們的目標,也就是獲取指令中參數,其中需要獲知參數起始位置,放在下一層實現;獲取參數起始位置又要獲知指令類型,再放在下一層實現,讓代碼擁有自頂向下的閱讀順序。
2.2.3. 重構后的getParameter
按照上面的思路重構后的getParameter如下:
public String getParameter(int parameterNumber) throws CommandException { String parameter = new String("None"); int startOfParameter = getStartIndexOfParameter(parameterNumber); int endOfParameter = getEndIndexOfParameter(parameterNumber); if((startOfParameter != -1) && (endOfParameter != -1)) { parameter = command.substring(startOfParameter, endOfParameter); } else { throw new CommandException("Cannot find the specific parameter."); } return parameter; }
2.3. 如何寫出簡潔的函數
寫代碼和寫文章很像,首先需要制定一個大綱,列出要寫的主要內容,然后在各個部分和段落根據大綱中制定的內容,想到什么就寫什么,最后在打磨它。初稿雖然能夠滿足大綱的要求,但一般都粗陋無序,冗長復雜,具有大量縮進、嵌套循環,過長的參數列表,名稱也是隨意取的。這就需要在不影響功能的前提下進行修改,分解函數、修改名稱、消除重復,縮短和重新安置方法,但無論怎么修改,都必須要保證各部分仍然滿足大綱的要求。最后將分解和修改后的函數重新組合,構成完整的、滿足既定要求的程序。從最開始就嚴格按照各個規則和要求直接簡潔的函數十分困難,需要自頂向下地逐步求精。
3. 格式
理想的代碼格式應該像報紙一樣,在頂部你期望有個頭條,告訴你事件的主題,你可以據此判斷是否要讀下去;第一段是導語,是整個故事的大綱,給出粗線條的概述,但省略了許多故事細節;往下讀,獲得的細節逐漸增加,直到你了解了所有的細節。源代碼也應該像報紙一樣,名稱應該簡單而且一目了然,足夠告訴讀者他期望獲取的信息是否位於這個模塊當中。源文件頂部應該給出高層次概念和算法,細節則在下面逐漸展開,直到最底層的函數和細節。報紙由許多篇文章組成,多數短小精悍,有些稍微長點兒,很少會占滿整整一頁。如果一份報紙只刊登一篇長篇故事,其中充斥毫無組織的事實、日期、名字等等或籠統或詳細的概念,就沒人願意去讀它。
幾乎所有代碼都是從上往下、從左往右讀的,首先從形式上,每行應展現一個表達式或語句,每組代碼展示一條完整的思路,不同的思路用空行區隔開,而相互聯系緊密的代碼則要靠近。
3.1. 垂直格式
要在垂直方向上使用空行分隔代碼,以區分不同的思路或實現不同功能的代碼段。如下面一段代碼對界面進行初始化,分別初始化了一個Pannel和兩個按鈕,用空行分開成三段。
private void initPortConfig() { panelSerialPortConfig = new JPanel(); panelSerialPortConfig.setBounds(10, 10, 234, 149); frame.getContentPane().add(panelSerialPortConfig); panelSerialPortConfig.setLayout(null); btnOpenPort = new JButton("\u6253\u5F00"); btnOpenPort.setBounds(68, 19, 74, 23); btnOpenPort.addActionListener(new ButtonListenerForPortManagement()); panelSerialPortConfig.add(btnOpenPort); btnClosePort = new JButton("\u5173\u95ED"); btnClosePort.setBounds(141, 19, 74, 23); btnClosePort.setEnabled(false); btnClosePort.addActionListener(new ButtonListenerForPortManagement()); panelSerialPortConfig.add(btnClosePort); }
相關的內容在垂直方向上應該相互臨近:變量聲明應盡可能靠近其使用位置,有調用關系的函數放在一起,概念相關性越強,彼此間距離就該越短。如前面的
public String getParameter(int parameterNumber) { ······ int startOfParameter = getStartIndexOfParameter(parameterNumber); int endOfParameter = getEndIndexOfParameter(parameterNumber); ······ } private int getStartIndexOfParameter(int parameterNumber) { ······ } private int getEndIndexOfParameter(int parameterNumber) { ······ }
被調用的函數緊挨在調用它的函數之后,遵循這樣的規律,閱讀的時候就總可以期望在下面了解到更詳細的內容。
3.2. 水平格式
水平方向上,代碼行的寬度要以不需要拖動滾動條為標准。水平方向的分隔與靠近通過空格實現。如下面求二次方程根的函數:
public class Quadratic { public static double root1(double a, double b, double c) { double determinant = determinant(a, b, c); return (-b + Math.sqrt(determinant)) / (2*a); } public static double root2(double a, double b, double c) { double determinant = determinant(a, b, c); return (-b - Math.sqrt(determinant)) / (2*a); } public static double determinant(double a, double b, double c) { return b*b - 4*a*c; } }
優先級高的計算彼此靠近,不同優先級的計算間用空格分隔。 源代碼文件具有一種繼承結構,其中的信息涉及整個文件、文件中每個類、類中的方法、方法中的代碼塊,以至於代碼塊中的代碼塊,使用縮進可以表現出代碼的結構。實現相對於聲明縮進一個層級。
4. 注釋
寫注釋的常見動機之一就是代碼本身寫的很糟糕,帶有少量注釋的整潔而有表達力的代碼,要比帶有大量注釋的零碎而復雜的代碼像樣的多,與其花時間解釋糟糕的代碼,不如花時間清潔那堆糟糕的代碼。當然,也有一些情況必須使用注釋,如
- 法律信息
- 提供信息的注釋
- 對意圖的解釋
- 警示
要盡可能使用前面提到的規則和技巧,使代碼自身具有良好的可讀性,注釋應當用來闡述編程的意圖和思路,而不是用來解釋這段代碼為何如此糟糕。
注:本文根據我在例會上學術交流的內容整理,對涉及到的一些專業應用(如第2節中PC與數據終端通過AT指令進行交互)做了簡化。主要參考了Robert C. Martin所著的《Clean Code》(中譯本《代碼整潔之道》,韓磊譯),部分例子也引自其中。