1 对象和数据结构
对象把数据隐藏于抽象之后,暴露操作数据的函数;
而数据结构暴露其数据,没有提供有意义的函数。
比如有一个几何类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里面所有的函数来处理。
现在来看看面相对象方案,注意,这里的area()方法是多态的,不需要有Geometry类。所以如果要添加一个新形状,现有的函数中没有一个会受到影响;而当添加添加新函数时,所有的类都得修改。
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;
}
}
我们再次看到这两种定义的本质,他们是截然对立的:
- 过程式代码便于,在不改动既有数据结构的前提下,添加新函数。
- 面相对象代码便于,在不改动既有函数的前提下,添加新类。
在任何一个复杂系统中,都会有需要添加新数据类型而不是新函数的时候,这时,对象就比较合适。另一方面,也会有想要添加新函数而不是数据类型的时候。在这种情况下,过程式代码和数据结构就更合适。
老练的程序员知道,一切都是对象的说法只是一个传说,有时候你真的想要在简单数据结构上做一些过程式的操作。
2 错误处理
错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。
2.1 使用异常而非返回码
在实际工作中,经常看到方法返回一个错误标识,然后让上游来根据错误码,来处理相应的逻辑。类似下面这段代码:
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());
}
}
...
}
这段代码的问题在于,他们搞乱了调用者代码,调用者必须在调用之后,即刻检查返回码,不幸的是,这个步骤很容易被遗忘。所以,遇到错误时,最好抛出一个异常,这样调用代码会很整洁,其逻辑不会被错误处理搞乱。
对比一下用抛出异常的形式来处理的代码:
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());
...
}
...
}
《代码整洁之道》中关于null的处理,我个人的观点与书中稍微有些出入,下面是我认为更合理的处理:
- 方法的返回值是一个对象,我个人认为返回null,然后让上游进行非空判断更合理一点;如果返回一个空对象,然后在200行以外,拿空对象的某个属性时,出现空指针,还不如早点对对象进行非空判断,然后直接return掉。
- 如果方法的返回值是一个list或者map,那么返回Collections.emptyList()或者Collections.emptyMap()要比返回null合理。
- 对于方法入参为空的处理,我认为在方法一开始,就进行各种非空判断及入参校验,进而抛出异常或者return,更合理一点。
2.2 最佳实践
- 尽量不要捕获类似 Exception 这样的通用异常,而是应该捕获特定异常,在这里是 Thread.sleep() 抛出的 InterruptedException。
try {
// 业务代码
// …
Thread.sleep(1000L);
} catch (Exception e) {
// Ignore it
}
- 不要生吞异常。这是异常处理中要特别注意的事情,因为很可能会导致非常难以诊断的诡异情况。
- Java异常处理机制对性能的影响。
- try-catch 代码段会产生额外的性能开销,或者换个角度说,它往往会影响 JVM 对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的 try 包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。
- Java 每实例化一个 Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。
3 单元测试
其实我在很多资料中都看到了有关单元测试的章节,我个人也非常认可单元测试的重要性。但是在实际工作中,写单元测试的人已经少之又少了,更何况能写出好的单元测试的人,甚至我之前的Leader不让我提单元测试代码,导致我在代码合并到master之前,都必须要把测试代码删掉才行。
这里只是记录一下《代码整洁之道》中,关于单元测试的内容,后续还是得沉淀一篇专门整理单元测试的笔记。
敏捷和TDD(测试驱动开发)运动鼓舞了许多程序员编写自动化单元测试,每天还有更多人加入这个行列。但是,在争先恐后将测试加入规程中时,许多程序员遗漏了一些,关于编写好的测试的要点。
3.1 TDD三定律
TDD要求我们在编写生产代码前,先编写单元测试,但这条规则只是冰山之巅,还有下面三条定律:
- 在编写不能通过的单元测试前,不可编写生产代码。
- 只可编写,刚好无法通过的单元测试,不能编译也算不通过。
- 只可编写,刚好足以通过当前失败测试的生产代码。
这样写程序,我们每天就会编写数十个测试,每个月编写数百个测试,测试将覆盖所有生产代码。测试代码量足以匹敌生产代码量,导致令人生畏的管理问题。
个人理解哈,在实际工作中,对于TDD,不能不用,也不能全用。可以使用上面三个定律来指导我们设计单元测试用例。我们设计的单元测试用例,不用覆盖所有代码,但是要确保能覆盖所有的业务场景。
3.2 保持测试整洁
或许会有不少人认为,测试代码的维护不应遵循生产代码的质量标准,彼此默许在单元测试中破坏规矩。“速而不周”成了团队格言,即变量命名不用很好,测试函数不必短小和具有描述性,测试代码不必做良好设计和仔细划分,只要测试代码还能工作,只要还覆盖着生产代码,就足够好。
这个团队没有意识到的是,脏测试等同于没测试。问题在于,测试必须随生产代码的演进而修改。测试越脏,就越难修改。测试代码越纠结,你就越有可能花更多时间塞进新测试,而不是编写新的生产代码。修改生产代码后,旧测试就会开始失败,而测试代码中乱七八糟的东西将阻碍代码再次通过。于是测试变得就像是不断翻番的债务。
随着版本迭代,团队维护测试代码的代价也在上升,最终,这样的代价变成了开发者最大的抱怨对象。如果他们保持测试整洁,测试就不会令他们失望。测试代码和生产代码一样重要。测试代码可不是二等公民,它需要被思考、被设计、被照料,它该像生产代码一样保持整洁。
有了单元测试,你就不用担心对代码的修改!没有测试,每次修改都有可能会带来缺陷,无论架构多有扩展性,无论模块划分得有多好,如果没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。
有了单元测试,愁云一扫而空,测试覆盖率越高,你就越不用担心,哪怕是对于那种架构并不优秀、设计晦涩的代码,你也能近乎没有后患地做修改。实际上,你甚至能毫无顾虑地改进架构和设计。
所以,覆盖了生产代码的自动化单元测试,能尽可能的保持设计和架构的整洁。测试带来了一切好处,因为测试使改动变得可能。
个人理解哈,在设计单元测试的时候,可以结合测试给的测试用例,并且测试代码相对于生产代码来说,简单很多,所以保持测试代码整洁,所需要付出的成本并不会很高,但是收益却很大。比如我们可以在测试代码中,很容易的抽出来一些复用性高的类和方法(比如请求头信息、sku相关信息等)。
3.3 整洁的测试
整洁的测试有哪些要素呢?有三个要素:可读性、可读性和可读性。在单元测试中,可读性甚至比在生产代码中还重要。测试如何才能做到可读?和生产代码中一样:明确、简洁并有足够的表达力。在测试中,你要以尽可能少的文字表达大量内容。
下面来看一段测试代码
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的那些调用,他们将字符串转换为供爬虫使用的PagePath实体。转换与测试毫无关系,突然混淆了代码的意图。现在再来看下重构之后的测试代码
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>");
}
这些测试显然呈现了构造-操作-检验(Build-Operate-Check)模式。每个测试都清晰地分为3个环节。第一个环节构造测试数据,第二个环节操作测试数据,第三个环节校验操作是否得到期望的结果。大部分恼人的细节流失了,测试直达目的,只用到那些真正需要的数据类型和函数。读测试的人应该能够很快搞清楚状况,而不至于被细节误导或吓到。
3.4 每个测试一个断言
有一个流派认为,JUnit中每个测试函数都应该有且只有一个断言语句。这条规则看似过于苛刻,但是却可以方便快速的理解测试函数的意图。对于上面举的例子,可以重构为:
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>");
}
注意,这里修改了那些函数的名称,以符合given-when-then约定,让测试更易阅读。不幸的是,如此分解测试,导致了许多重复的代码。可以利用模板模式,将given-when不分放到基类中,将then部分放到子类中。也可以创建一个完整的测试类,把given和when部分放到@Before函数中,把then部分放到@Test函数中。
最好的说法是,每个测试中的断言数量应该最小化。
3.5 FIRST原则
整洁的测试还遵循以下5条规则:
- 快速(Fast)。测试应该够快,能够快速运行。如果测试运行缓慢,你就不会想要频繁地运行它,如果你不频繁运行测试,就不能尽早发现问题。
- 独立(Independent)。测试应该互相独立,某个测试不应该成为下一个测试的设定条件,应该可以独立运行每个测试,以及以任何顺序运行测试。
- 可重复(Repeatable)。测试应当可以在任何环境中重复通过。你应该能够在生产环境、测试环境中运行测试,甚至在无网络的列车上运行测试。如果测试不能在任意环境中重复,你就总会有个解释其失败的接口。当环境条件不具备时,你也无法运行测试。
- 自验证(Self-Validating)。测试应该有布尔值输出,无论是通过或失败,你都不应该通过查看日志文件来确认测试是否通过。如果测试不能满足自验证,对失败的判断就会变得主观,而运行测试也需要更长的操作时间。
- 及时(Timely)。测试应及时编写,单元测试应该在生产代码之前编写。如果在编写生产代码之后再写测试,你会发现生产代码难以测试。
上面五条原则引用自书中原文。
个人理解哈,“Timely”这条原则有点教条,不可全部采用。我们可以在写完生产代码之后,再编写测试,如果发现很难为一段生产代码编写测试,那说明生产代码有问题,应该通过重构,让编写测试代码变得容易,而不是提前编写测试代码。