代码整洁之道(二)-Clean Code

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 最佳实践

  1. 尽量不要捕获类似 Exception 这样的通用异常,而是应该捕获特定异常,在这里是 Thread.sleep() 抛出的 InterruptedException。
try {
  // 业务代码
  // …
  Thread.sleep(1000L);
} catch (Exception e) {
  // Ignore it
}
  1. 不要生吞异常。这是异常处理中要特别注意的事情,因为很可能会导致非常难以诊断的诡异情况。
  2. Java异常处理机制对性能的影响。
  • try-catch 代码段会产生额外的性能开销,或者换个角度说,它往往会影响 JVM 对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的 try 包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。
  • Java 每实例化一个 Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。

3 单元测试

其实我在很多资料中都看到了有关单元测试的章节,我个人也非常认可单元测试的重要性。但是在实际工作中,写单元测试的人已经少之又少了,更何况能写出好的单元测试的人,甚至我之前的Leader不让我提单元测试代码,导致我在代码合并到master之前,都必须要把测试代码删掉才行。
这里只是记录一下《代码整洁之道》中,关于单元测试的内容,后续还是得沉淀一篇专门整理单元测试的笔记。

敏捷和TDD(测试驱动开发)运动鼓舞了许多程序员编写自动化单元测试,每天还有更多人加入这个行列。但是,在争先恐后将测试加入规程中时,许多程序员遗漏了一些,关于编写好的测试的要点。

3.1 TDD三定律

TDD要求我们在编写生产代码前,先编写单元测试,但这条规则只是冰山之巅,还有下面三条定律:

  1. 在编写不能通过的单元测试前,不可编写生产代码。
  2. 只可编写,刚好无法通过的单元测试,不能编译也算不通过。
  3. 只可编写,刚好足以通过当前失败测试的生产代码。

这样写程序,我们每天就会编写数十个测试,每个月编写数百个测试,测试将覆盖所有生产代码。测试代码量足以匹敌生产代码量,导致令人生畏的管理问题。

个人理解哈,在实际工作中,对于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”这条原则有点教条,不可全部采用。我们可以在写完生产代码之后,再编写测试,如果发现很难为一段生产代码编写测试,那说明生产代码有问题,应该通过重构,让编写测试代码变得容易,而不是提前编写测试代码。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容