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 最佳實踐
- 盡量不要捕獲類似 Exception 這樣的通用異常,而是應(yīng)該捕獲特定異常,在這里是 Thread.sleep() 拋出的 InterruptedException。
try {
// 業(yè)務(wù)代碼
// …
Thread.sleep(1000L);
} catch (Exception e) {
// Ignore it
}
- 不要生吞異常。這是異常處理中要特別注意的事情,因為很可能會導(dǎo)致非常難以診斷的詭異情況。
- 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ī)則只是冰山之巔,還有下面三條定律:
- 在編寫不能通過的單元測試前,不可編寫生產(chǎn)代碼。
- 只可編寫,剛好無法通過的單元測試,不能編譯也算不通過。
- 只可編寫,剛好足以通過當前失敗測試的生產(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),讓編寫測試代碼變得容易,而不是提前編寫測試代碼。