代碼整潔之道(二)-Clean Code

1 對象和數(shù)據(jù)結(jié)構(gòu)

對象把數(shù)據(jù)隱藏于抽象之后,暴露操作數(shù)據(jù)的函數(shù);
數(shù)據(jù)結(jié)構(gòu)暴露其數(shù)據(jù),沒有提供有意義的函數(shù)。

比如有一個幾何類Geometry,過程式代碼如下所示:

public class Square {
  public Point topLeft;
  public double side;
}

public class Rectangle {
  public Point topLeft;
  public double height;
  public double width;
}

public class Circle {
  public Point center;
  public double radius;
}

public class Geometry {
  public final double PI = 3.1415926;

  public double area(Object shape) throws NoSuchShapeException {
    if (shape instanceof Square) {
      Square s = (Square) shape;
      return s.side * s.side;
    } else if (shape instanceof Rectangle) {
      Rectangle r = (Rectangle) shape;
      return (r.height * r.width) / 2;
    } else if (shape instanceof Circle) {
      Circle c = (Circle) shape;
      return PI * c.radius * c.radius;
    }
    throw new NoSuchShapeException();
  }
}

想想看,如果給幾何類Geometry類添加一個求周長的方法primeter(),那么Square、Rectangle、Circle不會因此受影響。但是如果要添加一個菱形,那么就得修改Geometry里面所有的函數(shù)來處理。

現(xiàn)在來看看面相對象方案,注意,這里的area()方法是多態(tài)的,不需要有Geometry類。所以如果要添加一個新形狀,現(xiàn)有的函數(shù)中沒有一個會受到影響;而當添加添加新函數(shù)時,所有的類都得修改。

public interface Shape {
  double area();
}

public class Square implements Shape {
  private Point topLeft;
  private double side;
  
  public double area() {
    return side * side;
  }
}

public class Rectangle implements Shape {
  private Point topLeft;
  private double height;
  private double width;
  
  public double area() {
    return (height * width) / 2;
  }
}

public class Circle implements Shape {
  private Point center;
  private double radius;
  public final double PI = 3.1415926;

  public double area() {
    return PI * radius * radius;
  }
}

我們再次看到這兩種定義的本質(zhì),他們是截然對立的:

  • 過程式代碼便于,在不改動既有數(shù)據(jù)結(jié)構(gòu)的前提下,添加新函數(shù)。
  • 面相對象代碼便于,在不改動既有函數(shù)的前提下,添加新類。

在任何一個復(fù)雜系統(tǒng)中,都會有需要添加新數(shù)據(jù)類型而不是新函數(shù)的時候,這時,對象就比較合適。另一方面,也會有想要添加新函數(shù)而不是數(shù)據(jù)類型的時候。在這種情況下,過程式代碼和數(shù)據(jù)結(jié)構(gòu)就更合適。

老練的程序員知道,一切都是對象的說法只是一個傳說,有時候你真的想要在簡單數(shù)據(jù)結(jié)構(gòu)上做一些過程式的操作。

2 錯誤處理

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

2.1 使用異常而非返回碼

在實際工作中,經(jīng)??吹椒椒ǚ祷匾粋€錯誤標識,然后讓上游來根據(jù)錯誤碼,來處理相應(yīng)的邏輯。類似下面這段代碼:

public class DeviceController {
...
  public void sendShutDown() {
    DeviceHandle handle = getHandle(DEV1);
    if (handle != DeviceHandle.INVALID) {
      DeviceRecord record = retrieveDeviceRecord(handle);
      if (record.getStatus != DEVICE_SUSPENDED) {
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
      } else {
        logger.log("Device suspended. Unable to shut down");
      }
    } else {
      logger.log("Invalid handle for:" + DEV1.toString());      
    }
  }
...
}

這段代碼的問題在于,他們搞亂了調(diào)用者代碼,調(diào)用者必須在調(diào)用之后,即刻檢查返回碼,不幸的是,這個步驟很容易被遺忘。所以,遇到錯誤時,最好拋出一個異常,這樣調(diào)用代碼會很整潔,其邏輯不會被錯誤處理搞亂。

對比一下用拋出異常的形式來處理的代碼:

public class DeviceController {
  ...
  public void sendShutDown() {
    try {
      tryToShutDown();
    } catch (DeviceShutDownError e) {
      logger.log(e);
    }
  }

  private void tryToShutDown() throws DeviceShutDownError {
    DeviceHandle handle = getHandle(DEV1);
    DeviceRecord record = retrieveDeviceRecord(handle);

    pauseDevice(handle);
    clearDeviceWorkQueue(handle);
    closeDevice(handle);
  }

  private DeviceHandle getHandle(DeviceID id) {
    ...
    throw new DeviceShutDownError("Invalid handle for: " + id.toString());
    ...
  }
  ...
}

《代碼整潔之道》中關(guān)于null的處理,我個人的觀點與書中稍微有些出入,下面是我認為更合理的處理:

  • 方法的返回值是一個對象,我個人認為返回null,然后讓上游進行非空判斷更合理一點;如果返回一個空對象,然后在200行以外,拿空對象的某個屬性時,出現(xiàn)空指針,還不如早點對對象進行非空判斷,然后直接return掉。
  • 如果方法的返回值是一個list或者map,那么返回Collections.emptyList()或者Collections.emptyMap()要比返回null合理。
  • 對于方法入?yún)榭盏奶幚?,我認為在方法一開始,就進行各種非空判斷及入?yún)⑿r?,進而拋出異?;蛘遰eturn,更合理一點。

2.2 最佳實踐

  1. 盡量不要捕獲類似 Exception 這樣的通用異常,而是應(yīng)該捕獲特定異常,在這里是 Thread.sleep() 拋出的 InterruptedException。
try {
  // 業(yè)務(wù)代碼
  // …
  Thread.sleep(1000L);
} catch (Exception e) {
  // Ignore it
}
  1. 不要生吞異常。這是異常處理中要特別注意的事情,因為很可能會導(dǎo)致非常難以診斷的詭異情況。
  2. Java異常處理機制對性能的影響。
  • try-catch 代碼段會產(chǎn)生額外的性能開銷,或者換個角度說,它往往會影響 JVM 對代碼進行優(yōu)化,所以建議僅捕獲有必要的代碼段,盡量不要一個大的 try 包住整段的代碼;與此同時,利用異??刂拼a流程,也不是一個好主意,遠比我們通常意義上的條件語句(if/else、switch)要低效。
  • Java 每實例化一個 Exception,都會對當時的棧進行快照,這是一個相對比較重的操作。如果發(fā)生的非常頻繁,這個開銷可就不能被忽略了。

3 單元測試

其實我在很多資料中都看到了有關(guān)單元測試的章節(jié),我個人也非常認可單元測試的重要性。但是在實際工作中,寫單元測試的人已經(jīng)少之又少了,更何況能寫出好的單元測試的人,甚至我之前的Leader不讓我提單元測試代碼,導(dǎo)致我在代碼合并到master之前,都必須要把測試代碼刪掉才行。
這里只是記錄一下《代碼整潔之道》中,關(guān)于單元測試的內(nèi)容,后續(xù)還是得沉淀一篇專門整理單元測試的筆記。

敏捷和TDD(測試驅(qū)動開發(fā))運動鼓舞了許多程序員編寫自動化單元測試,每天還有更多人加入這個行列。但是,在爭先恐后將測試加入規(guī)程中時,許多程序員遺漏了一些,關(guān)于編寫好的測試的要點。

3.1 TDD三定律

TDD要求我們在編寫生產(chǎn)代碼前,先編寫單元測試,但這條規(guī)則只是冰山之巔,還有下面三條定律:

  1. 在編寫不能通過的單元測試前,不可編寫生產(chǎn)代碼。
  2. 只可編寫,剛好無法通過的單元測試,不能編譯也算不通過。
  3. 只可編寫,剛好足以通過當前失敗測試的生產(chǎn)代碼。

這樣寫程序,我們每天就會編寫數(shù)十個測試,每個月編寫數(shù)百個測試,測試將覆蓋所有生產(chǎn)代碼。測試代碼量足以匹敵生產(chǎn)代碼量,導(dǎo)致令人生畏的管理問題。

個人理解哈,在實際工作中,對于TDD,不能不用,也不能全用??梢允褂蒙厦嫒齻€定律來指導(dǎo)我們設(shè)計單元測試用例。我們設(shè)計的單元測試用例,不用覆蓋所有代碼,但是要確保能覆蓋所有的業(yè)務(wù)場景。

3.2 保持測試整潔

或許會有不少人認為,測試代碼的維護不應(yīng)遵循生產(chǎn)代碼的質(zhì)量標準,彼此默許在單元測試中破壞規(guī)矩?!八俣恢堋背闪藞F隊格言,即變量命名不用很好,測試函數(shù)不必短小和具有描述性,測試代碼不必做良好設(shè)計和仔細劃分,只要測試代碼還能工作,只要還覆蓋著生產(chǎn)代碼,就足夠好。

這個團隊沒有意識到的是,臟測試等同于沒測試。問題在于,測試必須隨生產(chǎn)代碼的演進而修改。測試越臟,就越難修改。測試代碼越糾結(jié),你就越有可能花更多時間塞進新測試,而不是編寫新的生產(chǎn)代碼。修改生產(chǎn)代碼后,舊測試就會開始失敗,而測試代碼中亂七八糟的東西將阻礙代碼再次通過。于是測試變得就像是不斷翻番的債務(wù)。

隨著版本迭代,團隊維護測試代碼的代價也在上升,最終,這樣的代價變成了開發(fā)者最大的抱怨對象。如果他們保持測試整潔,測試就不會令他們失望。測試代碼和生產(chǎn)代碼一樣重要。測試代碼可不是二等公民,它需要被思考、被設(shè)計、被照料,它該像生產(chǎn)代碼一樣保持整潔。

有了單元測試,你就不用擔心對代碼的修改!沒有測試,每次修改都有可能會帶來缺陷,無論架構(gòu)多有擴展性,無論模塊劃分得有多好,如果沒有了測試,你就很難做改動,因為你擔憂改動會引入不可預(yù)知的缺陷。

有了單元測試,愁云一掃而空,測試覆蓋率越高,你就越不用擔心,哪怕是對于那種架構(gòu)并不優(yōu)秀、設(shè)計晦澀的代碼,你也能近乎沒有后患地做修改。實際上,你甚至能毫無顧慮地改進架構(gòu)和設(shè)計。

所以,覆蓋了生產(chǎn)代碼的自動化單元測試,能盡可能的保持設(shè)計和架構(gòu)的整潔。測試帶來了一切好處,因為測試使改動變得可能。

個人理解哈,在設(shè)計單元測試的時候,可以結(jié)合測試給的測試用例,并且測試代碼相對于生產(chǎn)代碼來說,簡單很多,所以保持測試代碼整潔,所需要付出的成本并不會很高,但是收益卻很大。比如我們可以在測試代碼中,很容易的抽出來一些復(fù)用性高的類和方法(比如請求頭信息、sku相關(guān)信息等)。

3.3 整潔的測試

整潔的測試有哪些要素呢?有三個要素:可讀性、可讀性和可讀性。在單元測試中,可讀性甚至比在生產(chǎn)代碼中還重要。測試如何才能做到可讀?和生產(chǎn)代碼中一樣:明確、簡潔并有足夠的表達力。在測試中,你要以盡可能少的文字表達大量內(nèi)容。

下面來看一段測試代碼

public void testGetPageAsXml() throws Exception {
  crawler.addPage(root, PathParser.parse("PageOne"));
  crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
  crawler.addPage(root, PathParser.parse("PageTwo"));

  request.setResource("root");
  request.addInput("type", "pages");
  Responder responder = new SerializedPageResponder();
  SimpleResponse response = responder.makeResponse(new FitNessContext(root, request));
  String xml = response.getContent();

  assertEquals("text/xml", response.getContentType());
  assertSubString("<name>PageOne</name>", xml);
  assertSubString("<name>PageTwo</name>", xml);
  assertSubString("<name>ChildOne</name>", xml);
}

請看對PathParser的那些調(diào)用,他們將字符串轉(zhuǎn)換為供爬蟲使用的PagePath實體。轉(zhuǎn)換與測試毫無關(guān)系,突然混淆了代碼的意圖。現(xiàn)在再來看下重構(gòu)之后的測試代碼

public void testGetPageAsXml() throws Exception {
  makePage("PageOne", "PageOne.ChildOne", "PageTwo");

  submitRequest("root", "type:pages");

  assertResponseIsXml();
  assertResponseContains("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
}

這些測試顯然呈現(xiàn)了構(gòu)造-操作-檢驗(Build-Operate-Check)模式。每個測試都清晰地分為3個環(huán)節(jié)。第一個環(huán)節(jié)構(gòu)造測試數(shù)據(jù),第二個環(huán)節(jié)操作測試數(shù)據(jù),第三個環(huán)節(jié)校驗操作是否得到期望的結(jié)果。大部分惱人的細節(jié)流失了,測試直達目的,只用到那些真正需要的數(shù)據(jù)類型和函數(shù)。讀測試的人應(yīng)該能夠很快搞清楚狀況,而不至于被細節(jié)誤導(dǎo)或嚇到。

3.4 每個測試一個斷言

有一個流派認為,JUnit中每個測試函數(shù)都應(yīng)該有且只有一個斷言語句。這條規(guī)則看似過于苛刻,但是卻可以方便快速的理解測試函數(shù)的意圖。對于上面舉的例子,可以重構(gòu)為:

public void testGetPageAsXml() throws Exception {
  givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

  whenRequestIsIssued("root", "type:pages");

  thenResponseShouldBeXml();
}

public void testGetPageAsXml() throws Exception {
  givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

  whenRequestIsIssued("root", "type:pages");

  thenResponseShouldContain("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
}

注意,這里修改了那些函數(shù)的名稱,以符合given-when-then約定,讓測試更易閱讀。不幸的是,如此分解測試,導(dǎo)致了許多重復(fù)的代碼??梢岳媚0迥J剑瑢iven-when不分放到基類中,將then部分放到子類中。也可以創(chuàng)建一個完整的測試類,把given和when部分放到@Before函數(shù)中,把then部分放到@Test函數(shù)中。

最好的說法是,每個測試中的斷言數(shù)量應(yīng)該最小化。

3.5 FIRST原則

整潔的測試還遵循以下5條規(guī)則:

  • 快速(Fast)。測試應(yīng)該夠快,能夠快速運行。如果測試運行緩慢,你就不會想要頻繁地運行它,如果你不頻繁運行測試,就不能盡早發(fā)現(xiàn)問題。
  • 獨立(Independent)。測試應(yīng)該互相獨立,某個測試不應(yīng)該成為下一個測試的設(shè)定條件,應(yīng)該可以獨立運行每個測試,以及以任何順序運行測試。
  • 可重復(fù)(Repeatable)。測試應(yīng)當可以在任何環(huán)境中重復(fù)通過。你應(yīng)該能夠在生產(chǎn)環(huán)境、測試環(huán)境中運行測試,甚至在無網(wǎng)絡(luò)的列車上運行測試。如果測試不能在任意環(huán)境中重復(fù),你就總會有個解釋其失敗的接口。當環(huán)境條件不具備時,你也無法運行測試。
  • 自驗證(Self-Validating)。測試應(yīng)該有布爾值輸出,無論是通過或失敗,你都不應(yīng)該通過查看日志文件來確認測試是否通過。如果測試不能滿足自驗證,對失敗的判斷就會變得主觀,而運行測試也需要更長的操作時間。
  • 及時(Timely)。測試應(yīng)及時編寫,單元測試應(yīng)該在生產(chǎn)代碼之前編寫。如果在編寫生產(chǎn)代碼之后再寫測試,你會發(fā)現(xiàn)生產(chǎn)代碼難以測試。

上面五條原則引用自書中原文。

個人理解哈,“Timely”這條原則有點教條,不可全部采用。我們可以在寫完生產(chǎn)代碼之后,再編寫測試,如果發(fā)現(xiàn)很難為一段生產(chǎn)代碼編寫測試,那說明生產(chǎn)代碼有問題,應(yīng)該通過重構(gòu),讓編寫測試代碼變得容易,而不是提前編寫測試代碼。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容