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

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

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

比如有一個(gè)幾何類Geometry,過(guò)程式代碼如下所示:

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類添加一個(gè)求周長(zhǎng)的方法primeter(),那么Square、Rectangle、Circle不會(huì)因此受影響。但是如果要添加一個(gè)菱形,那么就得修改Geometry里面所有的函數(shù)來(lái)處理。

現(xiàn)在來(lái)看看面相對(duì)象方案,注意,這里的area()方法是多態(tài)的,不需要有Geometry類。所以如果要添加一個(gè)新形狀,現(xiàn)有的函數(shù)中沒(méi)有一個(gè)會(huì)受到影響;而當(dāng)添加添加新函數(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;
  }
}

我們?cè)俅慰吹竭@兩種定義的本質(zhì),他們是截然對(duì)立的:

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

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

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

2 錯(cuò)誤處理

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

2.1 使用異常而非返回碼

在實(shí)際工作中,經(jīng)??吹椒椒ǚ祷匾粋€(gè)錯(cuò)誤標(biāo)識(shí),然后讓上游來(lái)根據(jù)錯(cuò)誤碼,來(lái)處理相應(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());      
    }
  }
...
}

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

對(duì)比一下用拋出異常的形式來(lái)處理的代碼:

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的處理,我個(gè)人的觀點(diǎn)與書(shū)中稍微有些出入,下面是我認(rèn)為更合理的處理:

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

2.2 最佳實(shí)踐

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

3 單元測(cè)試

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

敏捷和TDD(測(cè)試驅(qū)動(dòng)開(kāi)發(fā))運(yùn)動(dòng)鼓舞了許多程序員編寫(xiě)自動(dòng)化單元測(cè)試,每天還有更多人加入這個(gè)行列。但是,在爭(zhēng)先恐后將測(cè)試加入規(guī)程中時(shí),許多程序員遺漏了一些,關(guān)于編寫(xiě)好的測(cè)試的要點(diǎn)。

3.1 TDD三定律

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

  1. 在編寫(xiě)不能通過(guò)的單元測(cè)試前,不可編寫(xiě)生產(chǎn)代碼。
  2. 只可編寫(xiě),剛好無(wú)法通過(guò)的單元測(cè)試,不能編譯也算不通過(guò)。
  3. 只可編寫(xiě),剛好足以通過(guò)當(dāng)前失敗測(cè)試的生產(chǎn)代碼。

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

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

3.2 保持測(cè)試整潔

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

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

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

有了單元測(cè)試,你就不用擔(dān)心對(duì)代碼的修改!沒(méi)有測(cè)試,每次修改都有可能會(huì)帶來(lái)缺陷,無(wú)論架構(gòu)多有擴(kuò)展性,無(wú)論模塊劃分得有多好,如果沒(méi)有了測(cè)試,你就很難做改動(dòng),因?yàn)槟銚?dān)憂改動(dòng)會(huì)引入不可預(yù)知的缺陷。

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

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

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

3.3 整潔的測(cè)試

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

下面來(lái)看一段測(cè)試代碼

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);
}

請(qǐng)看對(duì)PathParser的那些調(diào)用,他們將字符串轉(zhuǎn)換為供爬蟲(chóng)使用的PagePath實(shí)體。轉(zhuǎn)換與測(cè)試毫無(wú)關(guān)系,突然混淆了代碼的意圖?,F(xiàn)在再來(lái)看下重構(gòu)之后的測(cè)試代碼

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>");
}

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

3.4 每個(gè)測(cè)試一個(gè)斷言

有一個(gè)流派認(rèn)為,JUnit中每個(gè)測(cè)試函數(shù)都應(yīng)該有且只有一個(gè)斷言語(yǔ)句。這條規(guī)則看似過(guò)于苛刻,但是卻可以方便快速的理解測(cè)試函數(shù)的意圖。對(duì)于上面舉的例子,可以重構(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約定,讓測(cè)試更易閱讀。不幸的是,如此分解測(cè)試,導(dǎo)致了許多重復(fù)的代碼??梢岳媚0迥J?,將given-when不分放到基類中,將then部分放到子類中。也可以創(chuàng)建一個(gè)完整的測(cè)試類,把given和when部分放到@Before函數(shù)中,把then部分放到@Test函數(shù)中。

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

3.5 FIRST原則

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

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

上面五條原則引用自書(shū)中原文。

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

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

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

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