代碼整潔之道-理論

文章目錄

代碼整潔之道-理論

前言

學習中、工作中遇到很多亂七八糟的糟糕代碼,自己入門時也寫過不少糟糕代碼。在一個夜深人靜的晚上,思考人生,覺得要成爲一名更好的程序員,那寫代碼的基本功就要紮實。

於是結合自己曾今看過的關於代碼設計的書,進行了總結,寫下這篇博客,作爲日後編碼的參考文檔。本文以《代碼整潔之道》、《重構:改善既有代碼的設計》、《設計模式之禪》、《Head First設計模式》和《阿里巴巴Java開發手冊》爲原始資料,總結了其中的核心內容,並且在部分內容中加入了自己的見解。

開篇之前,來幾句雞湯文補補身子。

編程不僅僅是寫代碼,而是一門語言設計的藝術。

大神級程序員是把系統當做故事來講,動聽優雅,而不是當成程序來寫。

溫馨提醒:以下關於代碼整潔的理論和建議對英語水平有一定要求的,尤其是命名和註釋的章節。所以這些理論和建議不能全部套用,要根據團隊實際情況來斟酌運用,適合自身團隊的纔是最好的。

一、優雅代碼的層次

優雅代碼具有這幾個特點:可讀性、可複用、健壯性(魯棒性)、高擴展、高性能。

1、第一層次:命名要好

優雅代碼最基本的是要有良好的命名。良好的命名纔有可讀性。

這個可以參考《代碼整潔之道》的第2章:有意義的命名《阿里巴巴Java開發手冊》

這個是最基本的能力,團隊中的程序員必須要學會的基礎能力。

2、第二層次:代碼結構要清晰

清晰的代碼結構纔有可讀性、可複用。同時代碼要健壯(魯棒性),如需要判空、校驗非法值等。

這個可以參考《代碼整潔之道》的第3、4、5、7、8章:函數、註釋、格式、錯誤處理、邊界《阿里巴巴Java開發手冊》《重構:改善既有代碼的設計》

這個也是最基本的能力,團隊中的程序員必須要學會的基礎能力。

3、第三層次:熟悉6大設計原則

前面兩個層次只是關注如何編寫好的代碼行和代碼塊,如函數的恰當構造,函數之間如何互相關聯等。

第三層次將關注代碼組織的更高層面,即類。符合6大設計原則的類,可讀性、可複用、魯棒性、擴展性和性能會更好。

這個可以參考《設計模式之禪》的第1、2、3、4、5、6章《代碼整潔之道》的第10章

這個是較高的能力要求,個人覺得,在企業級開發中,1-3年的程序員應該要達到這一層次的能力。

4、第四層次:熟悉23種設計模式

第四層要求程序員能站在整個系統的角度去合理運用這23種設計模式,對代碼進行分包、分類、接口和類設計等架構工作,使得代碼高擴展。

這個可以參考《設計模式之禪》的第7-38章《Head First設計模式》

這個是更高的能力要求,個人覺得,在企業級開發中,3-5年的高級程序員應該要達到這一層次的能力。

5、第五層次:併發編程

代碼要高性能。

這個可參考併發編程的相關書籍,如《代碼整潔之道》中的附錄A:併發編程(p297)《深入理解Java虛擬機》的第五部分:高效併發(p359)《Java併發編程的藝術》《Java併發編程:核心方法與框架》《Java程序性能優化》

這個能力要求就更高了。本人能力暫時不足,不好定義。先暫時定義成最高層次吧。

二、什麼是糟糕的代碼

本人將代碼分爲兩類:業務代碼、框架代碼。對於業務代碼,以上的特性(可讀性、可複用、健壯性(魯棒性)、高擴展、高性能)都要兼顧,但有時候可以犧牲一部分性能來提高代碼的可讀性、健壯性(魯棒性)和高擴展;對於框架代碼,可以犧牲一部分可讀性來實現更高的性能。

框架代碼一般都是大牛寫的,基本上不存在糟糕代碼。但是業務代碼則存在很多糟糕的代碼,所以本文中講的糟糕代碼指業務代碼。

不符合上面5個特性的代碼基本上都是糟糕的代碼。具體表現如下:

(一)命名糟糕

1、採用描述性名稱。

2、名稱沒有與抽象層級相符。

3、沒有使用標準命名法。如駝峯。

4、使用歧義的名稱。

5、沒有爲較大作用範圍選用較長名稱。

6、使用編碼。

7、名稱沒有說明副作用。即多做事情了,但名稱看不出來。

(二)函數(方法糟糕)

長函數、有大量字符串、怪異不常見的數據類型和API、有太多不同層級的抽象、奇怪的字符串和函數調用、多重嵌套、用標誌來控制的if語句…《代碼整潔之道》p30頁有個例子可以感受一下糟糕代碼。其實,在工作中維護舊系統代碼時、隔一段時間再看自己以前寫的代碼時,也會有相同的感受。

1、過多的參數。

2、輸出參數。

3、標誌參數。

4、死函數。即永不調用的方法。

(三)註釋糟糕

1、不恰當的信息。

2、廢棄的註釋。

3、冗餘註釋。

4、糟糕瞎扯的註釋。

5、註釋掉的代碼。

(四)測試糟糕

1、測試不足。可以使用覆蓋率工具,多數IDE提供了功能。

2、略過小測試。這個不能略過。

3、沒有測試邊界條件

4、只全面測試相近的缺陷

5、測試很慢

(五)一般性問題

1、一個源文件中存在多種語言,如Java、HTML、XML等

2、明顯的行爲未被實現。

3、不正確的邊界行爲。

4、忽視安全。

5、重複。

6、在錯誤的抽象層級上的代碼。如基類和派生類、controller層和service層

7、基類依賴於派生類。

8、信息過多。提供的接口中,需要調用方調用的函數越少越好。

9、死代碼。如在if、catch、工具類中、switch/case中。

10、垂直分隔。變量和函數應該在被使用的地方定義,不應該在被使用之處幾百行以外聲明。這種情況在一些巨大的方法中可以見到。

11、前後不一致。命名要前後形式一致。如controller層、service層、dao層同一個功能的方法名要有一致性。

12、不使用的變量、函數、沒有信息量的註釋等。這些都可以直接刪除。一般在IDE自動生成的代碼中會看到。

13、耦合。如爲了方便,隨意將變量、常量和函數放在一個不合適的臨時地方。

14、特性依戀。類的方法只應操作其所屬的變量和函數,不應該操作其它類的變量和函數。但是對於Controller層直接調用Service層方法,這種就不算壞味道。像下面這種,從其他類中獲取其他類的變量來進行計算的,這種就是特性依戀了。

// 特性依戀
public class HourlyPayCalculator{
    
    public Money calculateWeeklyPay(HourlyEmployee e){
        int tenthRate = e.getTenthRate().getPennies();
        int tenthsWorked = e.getTenthsWorked();
        ......
        ......
        return new Money();
    }
    
}

// 非特性依戀
public class UserController {
    
    @Autowired
    priavate UserService userService;
    
    public boolean login(String username, String password){
        userService.login(username, password);
    }
}

15、選擇參數。傳入參數使用:boolean、枚舉元素、整數或者任何一種用於選擇函數行爲的參數。

16、晦澀不明的意圖。如使用聯排表達式、匈牙利語標記法和魔法數等。

17、位置錯誤的權責。代碼放錯位置,如放到了不同模塊、無關的類中。

18、不恰當的靜態方法。有些類是會用到多態的,就不要用靜態方法。

19、使用解釋性變量。在計算過程中,如果直接計算 會很難讀懂計算過程。此時,加上一些解釋性變量,把計算過程打散成一些了良好命名的中間值,這樣計算過程會易讀很多。

Matcher match = headerPattern.matcher(line);
if(match.find()){
    String key = match.group(1);
    String value = match.group(2);
    headers.put(key.toLowerCase(), value);
}

20、函數名稱應該表達其行爲。

21、理解算法。很多糟糕代碼是因爲人們沒有花時間去理解算法,而是不停地加if語句和標誌。

22、把邏輯依賴改爲物理依賴。

23、用多態替代if/else和switch/case

24、遵循標準約定。團隊成員要遵循團隊共同制定的規範。

25、用命名常量替代魔法數。

26、準確。如不能用浮點數表示貨幣,數據庫的查詢不一定返回唯一一條記錄、有併發更新時要適當加鎖、是否判空、異常是否處理等等。

27、結構比約定好。如使用IDE的強制性結構提示、使用基類使得具體類必須實現所有方法。

28、封裝條件。

// 糟糕代碼
if (timer.hasExpired() && !timer.isRecurrent()){}

// 優雅代碼
if (shouldBeDeleted(timer)){}

29、避免否定性條件。

// 糟糕代碼
if (!buffer.shouldNotCompact()){}

// 優雅代碼
if (buffer.shouldCompact()){}

30、函數只做一件事。

31、掩蓋時序耦合。這個見仁見智了。見《代碼整潔之道》p284-285。

32、別隨意。

33、封裝邊界條件。

34、函數中的語句應該只在一個抽象層級上。

35、在較高層級放置可配置數據。

public class Arguments {
    public static final String DEFAULT_PATH = "" ;
    public static final String DEFAULT_ROOT = "FitNesseRoot";
    public static final String DEFAULT_PORT = 80 ;
    public static final String DEFAULT_VERSION_DAYS = 14 ;
    
    public void parseCommandLine(String[] args){
        // user 80 by default  這裏就不需要寫了。因爲已經在上面寫好了配置數據。
        if(arguments.port == 0) {}
    }
    
}

public class Main {
    public static void main(String[] args){
        Arguments arguments = parseCommandLine(args);
        ......
    }
}

36、避免傳遞瀏覽。不要出現a.getB().getC()。

(六)Java

1、過長的導入清單。可使用通配符來避免。如import package.*;

2、繼承常量。不能爲了使用常量而繼承有常量的類,正確的做法是導入該常量類。

3、常量 VS 枚舉,建議使用枚舉enum。

(七)環境

1、構建項目要很多步驟。

如項目檢出,導入IDE後,還需要四處找額外的jar、xml文件和其它系統需要的雜物。

正常情況,只需要三步:源代碼控制系統檢出項目、導入項目到IDE、直接構建項目。

2、進行測試要很多步驟。

最好的是一個指令就可以執行所有測試。

使用Maven工具,可以快速構建、管理項目。

三、編碼時

(一)命名

1、範圍

變量、函數、參數、類、包、目錄、jar文件、war文件、war文件等。

2、要有含義

(1)對於所有的命名,都要有具體含義,也就是說看到命名就知道是幹什麼的。這個對英語能力有一定的要求。英語不行的可以藉助翻譯工具。

(2)不能使用a、b、c、i、j、x、y等進行命名。

(3)不要使用魔法數,如直接使用1、2、3、4。

3、不能有誤導

(1)不能使用一些專有名稱。

(2)別用userList,即使真的是List類型,建議也別再名稱中寫出容器類型名稱。如直接用users更好。

(3)注意不要使用不同之處較小的名稱。起碼要有兩個單詞不同。

(4)不能使用小寫字母“l”和大寫字母“O”,因爲這個兩個看起來很像數字”1“和”0“。

4、要有區分

(1)不能以數字系列命名來區分,如a1、a2、…aN,

// 糟糕命名
public static void copyChars(char[] a1, char[] a2){}
// 優雅命名
public static void copyChars(char[] source, char[] destination){}

(2)不要用廢話命名。

廢話一:使用意思沒區別的命名。如下面這3個類的名稱雖然不同,但是意思卻沒有區別。

Product.java
ProductInfo.java
ProductData.java

廢話二:命名帶上類型。

String name
String nameString ;

Customer.java
CustomerObject.java

數據表名:
user
user_table

5、不要用縮寫,要能讀

// 糟糕命名
class DtaRcrd102{
    private Date genymdhms;
    private Date modymdhms;
    private final String pszqint = "102" ;     
}

// 優雅命名
class Customer{
    private Date generationTimestamp;
    private Date modificationTimestamp;
    private final String recordId = "102" ;     
}

6、要可搜索

不要使用單字母名稱和數字常量,因爲很難搜索。如數字1、2、3和字母i、j、e等,很難找出來。

所以,長名稱比短名稱好。

// 糟糕命名
int[] a = ... ;
int s = 0 ;
for (int j = 0; j<10; j++){
    s += (a[j] * 4)/5 ;
}

// 優雅命名
int[] taskEstimate = ... ;
int realDayPerIdealDay = 4 ;
int WORK_DAYS_PER_WEEK = 5 ;
int sum = 0 ;
for (int j = 0;j<NUMBER_OF_TASKS; j++){
    int realTaskDays = taskEstimate[j] * realDayPerIdealDay ;
    int realTaskWeeks = realTaskDays / WORK_DAYS_PER_WEEK;
    sum += realTaskWeeks ;
}

在該例子中,搜索“sum”比搜索“s”容易多,搜索“WORK_DAYS_PER_WEEK”比搜索“5”容易多。

當然,名稱長短要與其作用域大小相對應。對於局部變量,名稱短一點可以,但也要可讀;對於可能被多處代碼使用的、經常需要搜索的變量或常量,需要使用適當長的、便於搜索的名稱。單字母名稱只能用於短方法中局部變量。

7、不能使用編碼

(1)不使用匈牙利語標記法。Java是強類型的語言,IDE工具在編譯開始前就能偵測到數據類型錯誤,所以這種方式在Java開發中基本沒人使用。這裏就不贅述。

(2)不要再使用前綴(如m_)或者後綴。而是應當把類和函數寫得足夠小。因爲現在很多IDE能夠用顏色區分成員變量。

// 糟糕命名
public class Part{
    private String m_dsc ;
    public void setName(String name){
        m_desc  = name ;
    }
}

// 優雅命名
public class Part{
    private String description ;
    public void setName(String description){
        this.description  = description ;
    }
}

(3)接口和實現類。接口不要再使用前導字母“I”。

// 糟糕命名
IUserService.java
UserService.java

// 優雅命名
UserService.java
UserServiceImpl.java

8、類名和對象名:名詞或名詞短語

// 糟糕命名
Manage.java
Process.java
Data.java
Info.java

// 優雅命名
Customer.java
User.java
Account.java
WikiPage.java
AdressParser.java

類名不能是動詞

9、方法名:動詞或動詞短語

(1)方法名要使用動詞或動詞短語

// 使用動詞或動詞短語
postPayment();
deletePage();
savaPage();

(2)屬性訪問器、修改器和斷言(isXXX)

// 屬性訪問器、修改器和斷言應根據其值命名,並按照JavaBean標準加上set、get、is前綴。如Employee.java中有一個屬性name,則屬性訪問器和修改器爲setName(String name)和getName()。
String name  = employee.getName();
customer.setName("dave");
if(paycheck.isPosted()){......}

(3)重載構造方法

// 重載構造方法時,使用描述了參數的靜態工廠方法名。如
Complex fulcrumPoint = Complex.FromRealNumber(23.0);
// 通常好於
Complex fulcrumPoint = new Complex(23.0);
// 同時可以考慮將構造方法設置爲private,強制使用這種靜態工廠方法名。

10、命名不能用笑話、俗語等

// 糟糕命名
// 劈砍
whack();
// 去死吧
eatMyShorts();

// 優雅命名
kill();
abort();

11、不能將同一單詞用於不同目的

在多個類中都有add方法,作用都是通過增加或連接兩個現存值來獲得新值,相當於“+”。

如果要寫一個新類,該類中有個方法,作用是將一個參數插入到一個集合中。這個時候,是不能再把方法定義爲add,因爲語義是不同的,應該使用insert或者append等詞命名。

12、使用解決方案領域的名稱

儘量使用計算機科學的術語、算法名、模式名和數學術語等,取一個技術性的名稱。

// 設計模式:訪問者模式
AccountVisitor.java

// 框架:任務隊列
JobQueue.java

13、使用所涉及問題領域的名稱

如果無法做到12中的用程序員熟悉的術語來命名,可以考慮採用從所涉及問題領域而來的名稱。

這裏不是很理解,本人的想法是,從技術解決方案領域無法找到合適命名,則從業務問題領域入手。

不過,一般不會出先這種情況。

14、添加有意義的語境

對於一些變量,如果單獨寫在一大段代碼中,沒有一個簡單明瞭的語境,是很難讀懂這些變量的。這個時候,就需要用類、函數或者名稱空間來處理這些變量。

(1)使用名稱空間(即前綴)。但不是最優方案,不提倡。更好的方案是創建一個類。

// 糟糕命名
void excute(){
    ......
    ......
    String firstName ;
    String lastName ;
    String street ;
    String houseNumber ;
    String city ;
    String state ;
    String zipcode ;
    ......
    ......
}

// 稍微好一點點的命名
void excute(){
    ......
    ......
    String addrFirstName ;
    String addrLastName ;
    String addrStreet ;
    String addrHouseNumber ;
    String addrCity ;
    String addrState ;
    String addrZipcode ;
    ......
    ......
}


// 優雅命名
void excute(){
    ......
    ......
    Address address = new Address();
    ......
    ......
}

class Address {
    private String firstName ;
    private String lastName ;
    private String street ;
    private String houseNumber ;
    private String city ;
    private String state ;
    private String zipcode ;
    // setter和getter方法
} 

(2)案例

// 糟糕命名
public class Main {
    public static void main(String[] args){
        Main name = new Main();
        name.printGuessStatistics('d',2);
    }
    private void printGuessStatistics(char candidate,int count){
        String number ;
        String verb ;
        String pluralModifier ;

        if (count == 0){
            number = "no" ;
            verb = "are" ;
            pluralModifier = "s";
        }else if (count == 1){
            number = "1" ;
            verb = "is" ;
            pluralModifier = "";
        }else {
            number = Integer.toString(count) ;
            verb = "are" ;
            pluralModifier = "s";
        }
        
        String guessMessage = String.format(
                "There %s %s %s%s",verb,number,candidate,pluralModifier
        );
        System.out.println(guessMessage);
    }
}


// 優雅命名
public class Main {
    public static void main(String[] args){
        Main name = new Main();
        name.printGuessStatistics('d',2);
    }

    private void printGuessStatistics(char candidate,int count){
        GuessStatisticsMessage guessStatisticsMessage = new GuessStatisticsMessage();
        String guessMessage =  guessStatisticsMessage.make(candidate,count);

        System.out.println(guessMessage);
    }
}

public class GuessStatisticsMessage {

    private String number ;
    private String verb ;
    private String pluralModifier ;

    public String make(char candidate,int count){
        createPluralDepentMessageParts(count);
        return String.format(
                "There %s %s %s%s",verb,number,candidate,pluralModifier
        );

    }

    private void createPluralDepentMessageParts(int count){
        if (count == 0){
            thereAreNoLetters();
        }else if (count == 1){
            thereIsOneLetters();
        }else {
            thereAreManyLetters(count);
        }
    }

    private void thereAreManyLetters(int count){
        number = Integer.toString(count) ;
        verb = "are" ;
        pluralModifier = "s";
    }

    private void thereIsOneLetters(){
        number = "1" ;
        verb = "is" ;
        pluralModifier = "";
    }

    private void thereAreNoLetters(){
        number = "no" ;
        verb = "are" ;
        pluralModifier = "s";
    }
}

15、不添加沒有意義的語境

命名時不需要加一些無關緊要的前綴。如項目的名稱叫做(Online School),那麼不需要在每個類前加上“OS”前綴,如OSUSer.java 、OSAccount.java等

只要短名稱已經最夠說清楚,就不需要加長名稱。

(二)函數(方法)

1、短小

(1)函數的第一規則是要短小,第二條規則是要更短小。

(2)函數的函數不能超過20行。顯示器一屏要能夠看完一個函數。

(3)if語句、else if語句、else語句和while語句,其中的代碼只能有一行,這一行一般是函數調用語句。

2、只做一件事情

(1)判斷一個函數是夠做了多件事情:一是看是否存在不同的抽象層級;二是看能否再拆出一個函數。

(2)一個函數只能做一件事情。

3、每個函數一個抽象層級

MVC代表了不同的抽象層級。從頁面、控制層、服務層、數據層,每一層的方法只能處理該層抽象層級的事,不能處理其他層級的事。舉個例子,數據層不能出現調用服務層的代碼,在控制層也不能直接出現調用數據層的代碼。

4、switch語句

(1)一般不會用switch語句。

(2)實在無法避免了,使用多態來實現。這裏不贅述,詳見《代碼整潔之道》P35頁。

5、使用描述性的名稱

(1)函數越短小、功能越集中,就越便於取個好名稱。所以在發現很難給函數取名時,看看函數是否做了多件事情。

(2)可以使用長名稱。

(3)命名格式要保持一致,使用與模塊名一脈相承的相關動詞和動詞短語給函數命名。

6、參數

參數個數不能超過3個,超過的要進行封裝。最理想的是沒有參數,第二好是一個參數。

通過參數傳入數據,但不能通過參數傳出處理結果,而是要通過返回值輸出處理結果。

(1)一個參數

傳入單個參數有兩種極普遍的理由,一是問關於這個參數的問題,如boolean fileExists("MyFile") ,問這個路徑的文件是否存在;二是操作這個參數,將其轉爲其他東西, 如InputStream fileOpen("MyFile"),把String類型的文件名轉換爲InputStream類型的返回值。

還有一種不是很普遍的理由,那就是事件:有輸入參數,無輸出參數。如void passwordAttemptFailedNtimes(int attempts)。要小心使用這種形式。

以上這三種形式都是較好的。

儘量避免編寫不遵循這些形式的一元函數,如使用輸出參數而不是返回值。如

// 糟糕用法
void transform(StringBuffer out);
// 優雅用法
StringBuffer transform(StringBuffer in);

(2)標誌參數

不要使用標誌參數,這種參數醜陋不堪。如向函數傳入布爾值,簡直駭人聽聞。見《代碼整潔之道》p46頁的代碼清單3-7例子。

// 醜陋代碼
render(Boolean isSuite);

// 優雅代碼
renderForSuite();
renderForSingleTest();

(3)兩個參數

二元函數容易搞錯兩個參數的順序。如assertEquals(expected,actual),容易搞錯expected與actual位置

儘量利用一些機制將其換成一元函數:

writeField(outputStream,name);

方案一:可以將writeField方法寫成outputStream的方法,則直接用outputStream.writeField(name);
方案二:把outputStream寫成當前類的成員變量,從而無需再傳遞;
方案三:分離出FiledWriter的新類,在其構造器中採用outputStream,並且包含write方法。

(4)三個參數

三元函數更加容易搞錯三個參數的順序。如assertEquals(message, expected, actual),很容易將message誤以爲expected。

因此這裏需要注意。

(5)參數對象

如果函數看起來需要兩個、三個或三個以上參數,則說明其中一些參數需要封裝爲類。如:

Circle makeCircle(double x,double y,double radius);
Circle makeCircle(Piont center,double radius);

(6)參數列表

這裏參數列表指的是可變參數。如String.format方法。

public String format(String format,Object... args);

有可變參數的函數可能是一元、二元和三元的,不要超過這個數量。

void monad(Integer... args);
void dyad(String name, Integer... args);
void triad(String name, int out, Integer... args);

7、函數偷偷多做事

函數名錶明只做了一件事,但是又偷偷地做了其它事情,這些事情包括:一,對自己類中的變量做出未能預期的改動,二是把變量搞成向函數傳遞的參數或者系統全局變量。這兩種情況都會導致時序性耦合與順序依賴。

public class UserValidator {
    
    private Cryptographer cryptographer ;
    
    public boolean checkPassword(String username,String password){
        User user = UserGateway.findByName(username);
        if (user != User.NULL){
            String codedPhrase = user.getPhraseEncodedByPassword();
            String phrase = cryptographer.decrypt(codedPhrase,password);
            if ("Valid Password".equals(phrase)){
                Session.initialize();
                return true ;
            }
        }
        return false ;
    }
    
}

checkPassword()方法,顧名思義,就是用來檢查密碼的。這個名稱並沒有說明會初始化該次會話,但是在代碼中卻調用了Session.initialize();,也就是說,調用該方法來檢查密碼時,會刪除現有會話。這就造成了時序性耦合。

所以checkPassword()方法需要重新命名爲checkPasswordAndInitializeSession()方法。當然這樣會違背了只做一件事的原則。

因此,最終方案是要將Session.initialize();提取出來。

8、分割指令和詢問

函數要麼做什麼事,要麼回答什麼事,二者不可兼得。

函數要麼修改某對象的狀態,要麼返回該對象的有關信息,二者不能同時做。

public boolean set(String attibute, String value){}

public static void main(String[] args){
    if(set("username","unclebob"){
        return true ;
    }
    return false ;
}

在讀者看來,會存在疑問:這個方法時在問username屬性值是否之前已設置爲unclebob,還是在問username屬性值是否成功設置爲unclebob呢?這裏很難判斷其含義。

解決方案是將指令和詢問分隔開,

if(attributeExists("username")){
    setAttribute("username","unclebob");
}

這樣,看起來就很明顯知道:如果username存在,則將username屬性值設置爲unclebob。

9、使用異常替代返回錯誤碼

(1)抽離try/catch代碼塊

返回錯誤碼,是在要求調用者立刻處理錯誤。

if(deletePage(page) == E_OK){
    if(registry.deleteReference(page.name) == E_OK){
        if(configKeys.deleteKey(page.name.makeKey())==E_OK){
            logger.log("page deleted");
        }else{
            logger.log("configKey not deleted");
        }
    }else{
        logger.log("deleteReference from registry failed");
    }
}else{
    logger.log("delete failed");
    return E_ERROR;
}

如果使用異常代替返回錯誤碼,可簡化爲

try{
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey();
}catch(Exception e){
    logger.log(e.getMessage());
}

但是,try/catch語句很醜,搞亂了代碼結構,把錯誤處理與正常流程混在一起。所以,要把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());
}

delete函數只和錯誤處理有關

deletePageAndAllReferences函數只處理業務,與錯誤處理無關了。

(2)錯誤處理就是一件事。函數處理錯誤,就是做一件事。也就是說, 函數如果是處理異常的,關鍵字try就是這個函數的第一個單詞,而且在catch/finally代碼塊後面不應該有其它代碼了。

(3)Error.java依賴磁鐵

使用返回錯誤碼,一般是某個類或者枚舉。這個類就像一顆依賴磁鐵:其它許多類都導入和使用它。當Error枚舉修改時,所有其他類都要重新編譯和部署。

public enum Error{
    OK,
    INVALID,
    NO_SUCH,
    LOCKED,
    OUT_OF_RESOURCES,
    WAITING_FOR_EVENT;
}

使用異常替代返回錯誤碼,新異常可以從異常類派生出來,無需重新編譯和部署。

10、不要重複

重複是軟件中邪惡的根源。很過原則和實踐規則都是爲了控制與消除重複的。如數據庫範式是爲了消除數據重複,面向對象編程將代碼集中到基類,避免代碼重複。面向切面、面向組件編程等,也是消除重複的策略。

11、結構化編程

結構化編程規則:每個函數、函數中的每個代碼塊都應該有一個入口、一個出口,意味着,每個函數只有一個return語句,循環中不能有break和continue,不能有任何的goto語句。

小函數:一般不需要遵守,助益不大。因爲都是寫小函數,所以這個可以略過。

大函數:結構化編程有明顯好處。

12、如何寫出這樣的函數

(1)一開始時,可能會相對長和複雜。有太多縮進和嵌套循環。有過長的參數列表。名稱比較隨意,也會有部分重複代碼。

(2)寫單元測試代碼,覆蓋每行醜陋的代碼。

(3)分解函數、修改名稱、消除重複。縮短和重新安置方法,有時還會拆散類。同時要保持測試通過。

(4)遵循以上的規則,組裝好函數。

有個小技巧:如果發現某個方法不能進行簡單的單元測試,那麼這個方法肯定有問題。

(三)註釋

1、註釋不能美化糟糕的代碼

別給糟糕的代碼寫註釋,直接重寫。

2、好註釋

(1)法律信息

(2)提供信息的註釋。如解釋某個抽象方法的返回值。

(3)對意圖的解釋。如程序員解釋盡力做了優化後,仍然這樣寫的原因。

(4)闡釋。如把某些晦澀難懂的參數或返回值的意義翻譯爲某種可讀形式。

(5)警示。警告其他程序員出現某種後果的註釋。如警示一些單元測試不能跑的(Junit4測試框架可以使用@Ignore註解)、日期工具類中提醒SimpleDateFormat是線程不安全的,所以需要每次都實例化對象等。

(6)TODO註釋。不過需要定期清理。

(7)放大。如使用“非常重要”等字眼,提醒其他程序員注意該處代碼。

(8)公共API的Javadoc。這個不能缺了,但是一定要準確。

3、壞註釋

(1)喃喃自語。程序員的自我表達,只有作者自己看得懂。

(2)多餘的註釋。代碼已經能清晰說明了,但還是加上了無關緊要的註釋。一般出現在一些自動生成或者複製粘貼的模板代碼中。注意要刪除這類註釋。

(3)誤導性註釋。如那些不夠精確的註釋,會誤導讀者。

(4)循規式註釋。不一定每個函數每個變量都要有註釋。但是本人覺得業務代碼基本都要寫。

(5)日誌式註釋。每次修改代碼都加一條註釋,這種註釋是不需要的。

(6)廢話註釋。一般是IDE工具自動生成的註釋,沒啥用。

(7)可怕的廢話。一般是複製粘貼過來的廢話註釋,廢話的廢話。

(8)位置標記。這種沒有必要。

//////////////////////////////////////////////////////
......
......
/////////////////////////////////////////////////////

(9)括號後的註釋。

try{
    while(){
        
    } // while
} //try
catch{
    
} //catch

(10)歸屬和署名。源代碼控制系統(Git和SVN)纔是這些信息最好的歸屬地。

/* Added by Dave */
/* Modified by Dave */

(11)註釋掉的代碼。刪掉吧,有源代碼控制系統,怕啥。

(12)HTML註釋。不要寫。

(13)非本地信息。要寫當前位置的註釋,不要寫遠在他方的註釋。

(14)信息過多。不要寫一大堆註釋,要言簡意賅。

(15)不明顯的關係。註釋要和代碼有關聯。

(16)函數頭。這裏建議不寫註釋。但這個本人覺得在業務代碼中,還是建議每個函數頭要寫註釋。看項目質量吧。

(17)非公共代碼中的Javadoc。非公共代碼,就不要寫Javadoc註釋。

(四)格式

1、格式的目的

格式會影響可讀性。可讀性會影響可維護性和擴展性。

2、垂直格式

(1)垂直方向上,代碼最頂部應該是高層次概念和算法,細節往下逐次展開,直到最底層的函數和細節。也就是說,被調用的函數要放在調用函數的下面。就像看報紙一樣,從上到下閱讀,頂部是頭條,接着第一段是故事大綱,然後細節在後面逐漸展開。

(2)概念間的隔開。適當使用一些空白行隔開,如package、import、每一塊成員變量間、每個方法間、方法內每一塊代碼間(因爲都是小函數,所以方法內一般不需要空白行)。

(3)概念間的靠近。不要使用空白行、或者不恰當的註釋隔斷緊密相關的代碼。

// 糟糕代碼
public class ReporterConfig{
    /**
     * The class name of the reporter listener
     */
    private String m_className ;
    
    /**
     * The properties of the reporter listener
     */
    private List<Property> m_properties = new ArrayList<Property>(); 
    
    public void addProperty(Property property){
        m_properties.add(property);
    }
}

// 優雅代碼
public class ReporterConfig{

    private String classNameOfReporterListener ;
    private List<Property> propertiesOfReporterListener = new ArrayList<Property>(); 
    
    public void addProperty(Property property){
        propertiesOfReporterListener.add(property);
    }
}

(4)概念間的距離

第一,函數短小,局部變量應該在函數的頂部出現。

第二,成員變量應該在類的頂部出現。

第三,相關函數。若一個函數調用了同一個類的另一個,應該把這兩個放在一起,而且調用者應該儘可能放在被調用者上面。

第四,概念相關的代碼應該放在一起。如相關性可能來自於執行相似操作的一組函數。

public class Assert{
    public static void assertTrue();
    public static void assertFalse();
    public static void assertTrue(String message);
    public static void assertFalse(String message);
}

(5)概念間的順序

被調用的函數應該放在執行調用函數的下面。這樣,閱讀代碼時,看最前面的幾個函數就能大概知道該類主要是做什麼的了。

3、橫向格式

一行代碼應該有多寬?小屏幕一屏能展示,不用拖動滾動條到右邊。

(1)隔開。

第一,在賦值操作符兩邊加上空格。

int lineSize = line.length();
totalChars += lineSizw();

第二,不在函數名和左圓括號之間加空格。

第三,在函數括號中的參數之間加空格。

public static boolean checkUserNameAndPassword(String username, String password);

(2)對齊。

Java開發無需關注橫向方向上的對齊,而是要關注垂直的長度。如果發現需要對齊纔好看清楚,那就要思考該類是否需要拆分了。

public class FitNesseExpediter implements ResponseSender{
    
    private Socket socket;
    private InputStream input;
    private OutputStream output;
    private Request request;
    private Response response;
    private FitNesseContext context;
    protected long requestParsingTimeLimit;
    private long requestProcess;
    private long requestParsingDeadline;
    private boolean hasError;
    
    ......
}

(3)縮進。一般IDE工具可以自動縮進。

(4)空範圍。while或for語句的語句體爲空時,容易忽略在同一行的分號";"。

// 後面的分號容易被忽略
while (dis.read(buf, 0, readBufferSize) != -1);

// 好一點
while (dis.read(buf, 0, readBufferSize) != -1)
;

// 優雅代碼
while (dis.read(buf, 0, readBufferSize) != -1){
    
};

4、團隊規則

在一個團隊中工作,則需要定一個團隊規則,一旦定好,所有人包括後來接手的人都要接受。

(1)啓動項目時,團隊要先制定一套編碼規範,如什麼地方放置括號,縮進幾個字符,如何命名類、變量和方法等等。

(2)定好編碼規範後,將這些規則編寫今IDE的代碼格式功能。

(3)後面接手的人要一定要按照這種編碼規範來進行編碼。

不要用不同風格來編寫同一個項目的源代碼。

由此可見,編碼規範一旦定下,就要一直沿用。所以,在項目一開始時,就要非常注重編碼規範的制定。

(五)對象與數據結構

慎用鏈式調用。這類代碼被稱作火車失事,是一種骯髒的風格。

最爲精煉的數據結構,是數據傳送對象,即DTD(Data Transfer Objects),只有公共變量、沒有函數。

對象曝露行爲,隱藏數據。所以便於添加新對象類型而無需修改既有行爲,同時難以在既有對象中添加新行爲。

數據結構曝露數據,隱藏行爲。便於向既有數據結構添加新行爲,同時難以向就有函數添加新數據結構。

(六)錯誤處理

錯誤處理很重要,但是如果它搞亂了代碼邏輯,那就是錯誤的做法。

1、使用異常,而不是返回碼

遇到錯誤時,最好是拋出一個異常。見(二)函數(方法)的第9條。

2、先寫try-catch-finally語句

3、使用不可控異常

對於一般的應用開發,使用不可控異常。

如果使編寫一套關鍵代碼庫,則可以考慮使用可控異常。

異常分類 說明
可控異常(checked exception) 繼承自java.lang.Exception的異常,這種異常需要顯式的try/catch或throw出來,否則編譯不通過;
不可控異常(unchecked exception) 繼承自java.lang.RunTimeException的異常,這種異常不需要顯式的try/catch或throw出來編譯就能通過。也叫運行時異常

之所以使用不可控異常,是因爲不可控異常可以簡化代碼。然而,由於不需要額外處理就能編譯通過,所以最好在調用前檢查一下可能發生的錯誤,比如空指針、數組越界等。

4、catch時打印異常發生的環境

要將失敗的操作和失敗類型等打印記錄下來,便於追蹤排查問題。

使用日誌系統,傳遞足夠的信息給catch塊,並記錄下來。

這裏參考《阿里巴巴Java開發手冊》的日誌打印規範。

5、自定義異常類

爲某個功能定義一個異常類型,可以簡化代碼。

// 糟糕代碼。catch裏面代碼大量重複。
ACMEReport port  = newACMEReport();

try {
    port.open();
} catch (DeviceResponseException e) {
    reportPortError(e);
    logger.log("......",e);
} catch (ATM1212UnlockedException e) {
    reportPortError(e);
    logger.log("......",e);
} catch (GMXError e) {
    reportPortError(e);
    logger.log("......",e);
} finally {
    ......
}

// 優雅代碼。
LocalPort port = new LocalPort(12);
try {
    port.open();
} catch(PortDeviceFailure e) {
    reportPortError(e);
    logger.log(e.message(),e);
} finally {
    ......
}

// ACMEReport封裝進LocalPort
public class LocalPort {
    private ACMEReport innerPort;
    
    public LocalPort(int portNumber){
        innerPort = new ACMEReport(portNumber);
    }
    
    public void open(){
        try {
            innerPort.open();
        } catch (DeviceResponseException e) {
            throw new PortDeviceFailure(e);
        } catch (ATM1212UnlockedException e){
            throw new PortDeviceFailure(e);
        } catch (GMXError e){
            throw new PortDeviceFailure(e);
        }finally {
            ......
        }
    }

}

// 自定義異常類
class PortDeviceFailure extends RunTimeException {}

6、定義常規流程

特例模式。

該情況很少見,不展開說了。具體見《代碼整潔之道》p100-p101

7、不要返回null值

新寫的代碼,不要返回null值。

調用第三方API返回null值的方法,要在新方法中打包這個方法,在新方法中拋出異常或者返回特列對象。

8、不要傳遞null值

準確講,是禁止傳入null值。

(七)邊界

邊界代碼一般指函數的入參和返回值、第三方API的規範。

1、邊界不用Map

不要使用Map作爲傳入參數類型;不要使用Map作爲返回值類型

可以將Map進行包裝後再使用。

// 糟糕代碼:直接將Map作爲參數在系統中傳遞
Map sensors = new  HashMaps();
......
Sensor sensor = (Sensor)sensors.get(sensorId);

// 使用泛型,好一點
Map<Sensor> sensors = new  HashMaps<Sensor>();
......
Sensor sensor = sensors.get(sensorId);

// 將Map包裝起來,在系統中傳遞的是包裝類Sensors
public class Sensors {
    private Map sensors = new HashMap();
    
    public Sensor getById(String id){
        return (Sensor)sensors.get(id);
    }
    
    ......
}

Sensors sensors = getSensors();
......
Sensor sensor = sensors.getById(id);

2、識別應用和第三方API的邊界接口

如學習log4j框架時,要能夠學會將應用程序的其它部分與log4j的邊界接口隔離開。

3、對接的API還沒設計出來時

這種方式一般不建議的,除非是真的無法避免了。

簡單來說,就是兩個系統之間交互的API格式還沒定好,有一方b的進度是延後的,超前的團隊a也不可能等待,所以a會先單方面定義好一個API進行開發。等b進度趕上,和a共同制定了最終的API,此時a在編寫一個適配器類來對接兩個接口。這是一種適配器模式,是屬於事後的補救措施。可以參考《設計模式之禪》p215的第19章《適配器模式》

4、小結:整潔的邊界

(1)整潔的邊界要考慮到需要改動時,邊界不需要太大代價來修改,甚至重寫。

(2)邊界上的代碼需要清晰的分割和定義期望的測試。學會進行學習型測試來理解第三方代碼,找出邊界。

(3)避免我們的代碼過多瞭解第三方代碼中特定信息

(4)邊界傳值:兩種方法,包裝和使用適配器模式。

(八)單元測試

測試驅動開發。

1、TDD三定律

(1)在編寫不能通過的單元測試前,不可編寫生產代碼

(2)只可編寫剛好無法通過的單元測試,不能編譯也算不通過

(3)只可編寫剛好足以通過當前失敗測試的生產代碼。

2、保持測試的整潔

(1)髒測試等於沒有測試,甚至比沒有測試更壞。

(2)測試代碼和生產代碼一樣重要。

(3)單元測試可以讓代碼可擴展、可維護、可複用。

3、整潔的測試

可讀性。其實和生產代碼的編碼規範差不多。測試代碼可按照這三個環節來寫:

第一是構造測試數據;

第二是操作測試數據。這一部分往往就是生產代碼;

第三是檢驗操作是否得到期望結果。

(1)測試代碼要有一定的流程規範,如上面提到的三個環節。

(2)測試和生產可以有雙重標準。但是測試代碼一定整潔。測試代碼和和生產代碼的不同應該是在內存和CPU效率,而不是整潔方面,兩者都要整潔。

4、每個測試一個斷言

單個測試中的斷言數量應該最小化。

每個測試函數只測試一個概念。

5、F.I.R.S.T

整潔測試遵循以下5條規則:

Fast:快速。測試運行要快。如果發現測試很慢,就要懷疑是不是性能問題了。

Independent:獨立。各個測試之間要互相獨立。

Repeatable:可重複。測試要能夠在任何環境中重複通過。

Self-Validation:自足驗證。測試要有布爾值輸出,不能手工對比來確認測試是否通過,應使用assert方法。

Timely:及時。測試應及時編寫。單元測試代碼要在使其通過的生產代碼之前編寫。

(九)類設計

前面一直講的是如何編寫好的代碼行和代碼塊。如函數的恰當構造,函數之間如何互相關聯等。

現在將注意力放到代碼組織的更高層面,即類,來探討如何得到整潔代碼。

更加具體的可以參考《設計模式之禪》

1、類的組織

(1)順序:

第一,公共靜態常量

第二,私有靜態變量

第三,私有成員變量

第四,公共函數

第五,私有函數

以下例子僅爲了說明類的組織順序,其命名是不正確的。

public class Main {
    public static final String SALT = "salt" ;
    
    private static final String SALT = "salt" ;

    private String String username ;
    
    Main(){}
    
    public void f(){
        a();
        b();
    }
    
    private void a(){};
    private void b(){};
    
    public void g(){
        c();
    }
    
    private void c(){};
    
    ......
}

2、類要短小

類要短小,更加短小。

衡量函數是通過計算代碼行數衡量大小;衡量類,採用計算權責衡量。

類的名稱應當可以描述其權責。如果無法給一個類定一個精確的名稱,那這個類就太長了。類名越含糊,改類就擁有過多權責。大概25個字母能描述一個類,且不能出現“if”、“and”、“or”、“but”等。

(1)單一權責原則

單一權責原則(SRP)認爲,類或模塊只有一條修改的理由。

系統應該有許多選小的類組成,而不是由少量巨大的類組成。

(2)內聚

類應只有少量實體變量。

類中的每個方法都應該操作一個或多個實體變量。

通常而言,方法操作的變量越多,就越內聚到類上。

如果一個類中每個變量都被每個方法所使用,那該類具有最大的內聚性。

一般來說,一個類的要有較高的內聚性。

如果發現,一個類中有的方法沒有引用改類的任何變量,也沒有操作改類的任何變量,那麼這個方法可以剝離出來。

如果發現,一個類中有的實體變量沒有被任何方法使用,那這個變量就可以直接刪除了。

(3)內聚會得到許多短小的類

將大函數拆成小函數,往往也是將類拆分爲多個小類的時機。

3、爲了修改而組織

(1)開發-閉合原則:類應該對擴展開放,對修改關閉。

// 一個必須打開修改的類
public class Sql{
    public Sql(String table, Column[] columns);
    public String create();
    public String insert(Object[] fields);
    public selectAll();
    public findByKey(String keyColumn, String keyValue);
    public select(Column column, String pattern);
    public select(Criteria criteria);
    public preparedInsert();
    private columnList(Column[] columns);
    private valuesList(Object[] fields, final Column[] columns);
    private selectWithCriteria(String criteria);
    private placeholderList(Column[] columns);
    
    // 增加update語句,需要修改這個類
    public String update(Object[] fields);
}

當需要增加一種新語句時,需要修改Sql類。重構一下:

// 一組封閉的類
public abstract class Sql{
    public Sql(String table, Column[] columns);
    public abstract String generate();
}

public class CreateSql extends Sql{
    public CreateSql(String table, Column[] columns){}
    @Override
    public String generate(){}
}

public class SelectSql extends Sql{
    public SelectSql(String table, Column[] columns){}
    @Override
    public String generate(){}
}

public class InsertSql extends Sql{
    public InsertSql(String table, Column[] columns, Object[] fields){}
    @Override
    public String generate(){}
    
    private String valuesList(Object[] fields, final Column[] columns){}
}

public class SelectWithCriteriaSql extends Sql{
    public SelectWithCriteriaSql(String table, Column[] columns, Criteria criteria){}
    @Override
    public String generate(){}
}

public class SelectWithMatchSql extends Sql{
    public SelectWithMatchSql(String table, Column[] columns, Column column, String pattern){}
    @Override
    public String generate(){}
}

public class FindByKeySql extends Sql{
    public FindByKeySql(String table, Column[] columns, String keyColumn, String keyValue){}
    @Override
    public String generate(){}
}

public class PreparedInsertSql extends Sql{
    public PreparedInsertSql(String table, Column[] columns){}
    @Override
    public String generate(){}
    
    private placeholderList(Column[] columns);
}

public class Where{
    public Where(String criteria){}
    public String generate(){}
}

public class ColumnList(){
    public ColumnList(Column[] columns){}
    public String generate(){}
}

// 此時,增加update語句,不需要修改原來的任何類。只需要新建一個子類,繼承父類Sql
public class UpdateSql extends Sql{
    public UpdateSql(String table, Column[] columns, Object[] fields){}
    @Override
    public String generate(){}
}

(2)依賴倒置原則:類應當依賴於抽象,而不是依賴於具體細節(如實現類)。

需求會變,所以代碼也會變。

接口(抽象類)是概念,具體類是實現細節。

把變化的東西放到具體類,接口保持定義好後保持不變。

所以,需求變了,只需修改具體類,不用修改接口。

// 接口
public interface StockExchange {
    Money currentPrice(String symbol);
}

// 實現類
public class FixedStockExchangeSub implements StockExchange{
    
}

// 客戶端調用
public class Portfolio {
    private StockExchange exchange;
    // 依賴StockExchange接口,而不是具體類
    public Portfolio(StockExchange exchange){
        this.exchange = exchange;
    }
    ......
}

// 測試
public class portfolioTest {
    private FixedStockExchangeSub exchange ;
    private Portfolio portfolio;
    
    @Before
    protected void setUp() throws Exception(){
        exchange = new FixedStockExchangeSub();
        exchange.fix("MSFT",100);
        portfolio = new Portfolio(exchange);
    }
    
    @Test
    public void GivenFiveMSFTTotalShouldBe500() throws Exception{
        portfolio.add(5,"MSFT");
        Assert.assertEquals(500,portfolio.value());
    }
}

(十)系統

前面講的是類如何得到整潔代碼。

這裏討論更高的抽象層級,如何在系統層級保持整潔。

1、構造和使用分開:依賴注入

工廠模式

2、AOP

代理模式、Java AOP框架、AspectJ

3、模塊化

(十一)小結

4條簡單的規則,按優先級從高到低,排列如下:

第一,運行所有測試。緊耦合的代碼難以編寫測試。

第二,不可重複。可用模板方法模式消除明顯重複。

第三,表達了程序員的意圖。如好的命名、函數和類短小等

第四,儘可能減少類和方法的數量。

優秀的軟件設計:提升內聚性,降低耦合度,關注切面,模塊化,縮小函數和類,使用好名稱等。

四、重構時

這裏獨立成一篇博客來寫。在撰寫中。

代碼整潔之道-理論-重構

五、併發編程

這裏獨立成一篇博客來寫。待撰寫。

代碼整潔之道-理論-併發編程

六、總結

代碼質量、架構和項目管理決定軟件質量,代碼質量是重要因素。

想成爲更好的程序員,基礎是要能寫整潔優雅的代碼。

糟糕的代碼會毀掉一個項目,甚至毀掉一個公司。

整潔代碼要立刻動手寫,因爲稍後等於永不。

讓經過你手的代碼能夠更乾淨些。

七、參考

《代碼整潔之道》

《重構:改善既有代碼的設計》

《設計模式之禪》

《Head First設計模式》

《阿里巴巴Java開發手冊》

《Java併發編程的藝術》

《Java併發編程:核心方法與框架》

《Java程序性能優化》

《深入理解Java虛擬機》

如何寫出優雅的代碼

寫代碼時應該注意的問題

八、實戰

這裏獨立成一篇博客來寫。計劃撰寫。

代碼整潔之道-實驗-重構Args

代碼整潔之道-實驗-重構Junit

代碼整潔之道-實驗-重構SerialDate

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章