预防错误的设计
上周参加了一个 Michael Feathers 的 workshop,整个 workshop 内容非常多,其中大部分是围绕他那本著名的 《修改代码的艺术》所阐述,不过除此之外 Michael 还以 Beyond Error Handling, Using Design to Prevent Errors 为主题,讲了不少如何优雅地处理错误的做法和思路。其中的一个例子涉及并融合了面向对象和函数式的相关知识,引发我的一些思考,本文就此做一些总结。
例子的代码很典型也很简单,简化后,主要涉及三个类,分别是 Inventory, Item, 和 Sale,代码如下:
public class Inventory { private HashMap<String,Item> items = new HashMap<String,Item>(); public Inventory() { items.put("1", new Item("Preserved Duck Eggs", 150000)); items.put("2", new Item("Milk", 7000); items.put("3", new Item("Tomato", 5500)); } public Item itemForBarcode(String barcode) { return items.get(barcode); } }
public class Item { private String name; private int price; public Item(String name, int price) { this.name = name; this.price = price; } public String getName() { return name; } public int getPrice() { return price; } }
public class Sale { private SaleEventListener listener; private Inventory inventory = new Inventory(); private ArrayList<Item> items = new ArrayList<Item>(); public Sale(SaleEventListener listener) { this.listener = listener; } public void addBarcode(String barcode) { Item item = inventory.itemForBarcode(barcode); items.add(item); listener.itemAdded(item); } public void subtotal() { Money sum = new Money(); for(Item item : items) { sum = sum.add(item.getPrice(items)); } listener.subtotaled(sum); } }
想象我们去超市购物,然后到收银台结账,当我们把一车东西交给收银员的时候,她会对每件东西(Item)逐一扫描条形码(barcode),扫描条形码的时候系统会去库存(Inventory)中查询这件东西的名称和价格,扫描完成后,收银员会计算总价格(subtotal)。上述代码所表述的就是这个场景。
读者不妨花5分钟理解一下上述代码,接着考虑一个问题,如果扫描的条形码在库存中不存在怎么办?具体来说 inventory.itemForBarcode(barcode); 返回 null 怎么办?下面的所有讨论都围绕这个问题展开。
null 判断
如果我们随意把 null 传来传去,什么都不干,那必然早晚会出现 NullPointerException ,那么最简单的做法是加上 null 判断以保证错误的 null 不会再被传出去:
public void addBarcode(String barcode) { Item item = inventory.itemForBarcode(barcode); if (item == null) { return; } items.add(item); listener.itemAdded(item); }
这可能是我们能见到的最常见的做法,代码中到处充满了 null 判断,在某种程度上把代码主要想表达的主干逻辑给污染了,过多 null 判断影响了代码的可读性。如果所有使用变量的地方都要去检查 null,那代码必然会恶心的不行,实际中我们不会这么干,因为变量的值什么来的,很多时候也是由我们自身控制的。但如果我们在使用第三方的代码,那为了保护自己,检查 null 则是比较明智的选择了。因此, 在自己的代码库内部,尽量避免传 null 并去掉 null 判断;而在集成第三方代码的时候,则多防御性地使用 null 判断。
如果我们设计一个 API,那显式地告诉别人是否可以传入 null,是否会返回 null,则是很好的习惯,例如:
@Nullable public Item itemForBarcode(@Notnull String barcode)
上述代码表示传入的 barcode 不能为 null(如果是 null,不保证会发生什么后果),而返回的 Item 则可能是 null,这样实现的时候我就不需要去判断 barcode 是否为 null,另外用这个接口的人也明确知道自己要检查返回是否为 null。 @Nullable 和 @Notnull 都已经在 jsr-305 中明确定义。
异常
避免 null 判断的一种最直接做法就是抛出异常,例如:
public void itemForBarcode(String barcode) throws ItemNotFoundException { if (items.containsKey(barcode)) { return items.get(barcode); } else { throw new ItemNotFoundException(); } }
遇到 ItemNotFoundException 怎么办?继续往上抛?处理掉?记个日志然后吃掉?在这个上下文中,我无法给出明确的答案,具体选择哪种方式还得看具体场景,通常来说,**先想想当你遇个异常的时候,你的用户希望得到怎样的结果?然后基于这点来指导你的异常处理方式。**
异常在很多情况下是很适用的,例如磁盘坏了,那抛出 IOException 让系统 Fail Fast 往往是一种很好的处理方式,然后异常也有不少问题,首先 Java 中的 Checked Exception 很容易让 API 变得丑陋无比;其次异常的跳跃式的抛来抛去,也让有 goto 的感觉,很难 follow 。
有没有更好的办法保持 API 的简洁,同时也能避免 null 判断呢?
Null Object 模式
在面向对象设计中,我们常常可以使用 Null Object Pattern 来去掉 null 检查逻辑或者异常捕捉,该例中,我们可以创建一个 NotFoundItem ,它继承自 Item,不过 name 和 price 比较特殊:
public class NotFoundItem extends Item { public NotFoundItem() { super("Item not found", 0); } }
然后再 Invetory 中适当地返回 NotFoundItem:
public void itemForBarcode(String barcode) { if (items.containsKey(barcode)) { return items.get(barcode); } else { return new NotFoundItem(); } }
这样,所有使用 Inventory.itemForBarcode() 的地方都不需要特殊的错误处理了,例如在 Sale 类中, addBarcode() 和 subtotal() 都能正常工作。想象一下,如果有五个、十个、或者更多的类使用 Inventory.itemForBarcode(),这样可以简化多少代码!因此, 如果有可能,尽量在下层完成错误处理,因为越往上层,需要的错误处理代码往往越多。这实际上是契合 Robustness Principle 的,这条原则是这么说的:
Code that sends commands or data to other machines (or to other programs on the same machine) should conform completely to the specifications, but code that receives input should accept non-conformant input as long as the meaning is clear.
Inventory.itemForBarcode() 能够接受不合法的 barcode ,但是它返回的 Item 是符合接口定义的,没有特殊性。
到目前为止一切似乎看起来很美好,但实际上 Null Object 模式不是完美的,想象一下,如果我们要在 Sale 类中加入这样一个逻辑:如果购买物品的数量达到了10,则有5%的折扣。显然 NotFoundItem 会破坏这样的逻辑,扫描1个合法 barcode 加9个不合法的 barcode 也会造成 5% 折扣。
Option
我们花了大量的精力对付 null,事实上 null 这个概念的发明人 Tony Hoare 也说:
I call it my billion-dollar mistake.
是否有其他更好的方案来解决这个问题呢?让我们来到函数式的世界,看看 Scala 是怎么对付 null 的。Scala 内置了一个特殊类型,叫做 Option,顾名思义,可以认为 Option 可能包含一个值,也可能不包含,例如我们可以修改 Inventory.itemForBarcode() 如下:
def itemForBarcode(barcode: String): Option[Item] = { if (items.contains(barcode)) Some(items(barcode)) else None }
这一段 Scala 代码也比较好理解,*itemForBarcode* 接收一个 String 参数,返回 Option[Item],而*Option[Item]* 有两种子类型,一种是 Some[Item],表示有实际的 Item,另外一种是 None,表示没有。
现在 Sale.addBarcode() 是这么处理 Option[Item] 的:
def addBarcode(barcode: String) { inventory.itemForBarcode(barcode).foreach(item => { items :+ item listener.itemAdded(item) }) }
代码中对 Option[Item] 进行了迭代访问,与迭代一个集合的做法完全一样,这么做的优雅之处在于,如果 Option[Item]是 None,迭代中的逻辑不会被执行,也不会有任何副作用,与我们迭代一个空的集合一样。当然,如果 Option[Item]是 Some[Item],则 item 会被取出来并执行相应的逻辑。因此我们可以简化地把 Option 认为是一个包含1个或者0个元素的集合类。
事实上 Scala 的 Library 中大量使用了 Option,例如,由于 Scala 中的 Map 实际上有方法返回 Option,因此 Inventory.itemForBarcode() 可以简化成:
def itemForBarcode(barcode: String): Option[Item] = { items.get(barcode) }
现在,*Inventory.itemForBarcode()* 接口的协议是:我会返回一个 Option[Item],您自己去迭代里面的内容。有没有可能把接口简化下?让用户(这里的 Sale )不必关心迭代呢?让接口的协议变成:我会去找 Item,找到的话帮你执行逻辑 blabla ……
高阶函数( Lambda 表达式)
要回答上面的问题,我们得看一下另一个 Scala 的函数式特性,那就是高阶函数,如果是 Java 8 ,那就是 Lambda 表达式,我们可以这样定义 Inventory.itemForBarcode(),它接收一个 barcode 和一个接收 Item 的函数 f :
def itemForBarcode(barcode: String)(f: Item => Unit) { items.get(barcode).foreach(f) }
如果能找到 item , f 就会执行,现在 Sale.addBarcode() 变成了:
def addBarcode(barcode: String) { inventory.itemForBarcode(barcode) { item => items :+ item listener.itemAdded(item) } }
面向对象设计中有一条著名的原则: Tell, Don’t Ask,《Smalltalk by Example》一书的作者这样描述该法则:
Procedural code gets information then makes decisions. Object-oriented code tells objects to do things.
从 Inventory 获取数据( item 也好, null 也好, Option[Item] 也好),然后根据其内容是否存在再做操作,更多是过程式思想;相对的,扔给 Inventory 一个 barcode 和一段函数则是所谓的 Tell :去查一查,查到就干这个!
上述的代码用 Java 8 Lambda 表达式也能轻松实现,但如果是 Java 6/7 ,就比较麻烦了,你得搞一个 interface,然后传一个匿名内部类,非常麻烦。
小结
整个过程我接触了 null, 异常, Null Object Pattern, Option, 高阶函数等概念,这里有过程式编程泛型、面向对象编程泛型、函数式编程泛型,这是最让我惊异的,各种编程泛型对于错误处理有着不同的方式,你会发现 Java 作为一门面向对象语言其实糅合了不少过程式的处理方式,而 Scala 或者 Java 8 则有函数式的处理方式,而且总体上来说函数式的处理方式更加优雅。