预防错误的设计

标签: 软件设计 Option Scala 函数式 异常 | 发表时间:2014-10-28 20:44 | 作者:juvenxu
出处:http://www.juvenxu.com

上周参加了一个 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 则有函数式的处理方式,而且总体上来说函数式的处理方式更加优雅。

相关 [预防 错误 设计] 推荐:

预防错误的设计

- - Juven Xu
上周参加了一个 Michael Feathers 的 workshop,整个 workshop 内容非常多,其中大部分是围绕他那本著名的 《修改代码的艺术》所阐述,不过除此之外 Michael 还以  Beyond Error Handling, Using Design to Prevent Errors 为主题,讲了不少如何优雅地处理错误的做法和思路.

写给设计师:POKA-YOKE – 避免无心的错误

- Will - ITeye资讯频道
在1961年, 工业工程领域世界著名的品质管理专家新江滋生(Shingeo Shingo)先生根据其长期从事现场质量改进的丰富经验,首创了POKA-YOKE的概念. 简单地翻译一下,就是:避免(yokeru)无心的(inadvertent)错误(poka). 为了避免我们可爱的用户在使用产品的过程中出错,请看看设计师们都做了些什么吧:.

33个非常有创意的404错误页面设计

- We_Get - 博客园-首页原创精华区
  在浏览网页的时候出现404错误是非常令人沮丧的,我敢肯定,随后的一两秒内如果没有什么吸引你的注意力的话,你会离开网站,再也不会回来. 因此,很多网站都会去设计一个新颖的错误页面,以吸引用户继续浏览其它的内容. 今天这篇文章收集了33个非常有创意的404错误页面设计作品,希望能带给你灵感. 推荐35个非常有创意的404错误页面.

移动Web设计中的一些错误理念

- - 博客 - 伯乐在线
我的Web 开发开始于1994 年,那时我们犯了很多错误. 但不幸的是,在2011 年的移动开发领域,我又开始看到了这些错误的再次出现. 我不确信我是否喜欢一些人讨论移动Web的方式. 就像传统Web设计经历过青涩的青春期一样,移动Web设计现在正处于这一时期. 开发设计组织正开始关注移动互联网,因为他们留意到用户一步步地接纳了这一网络,就像前几年一步步接纳传统互联网一样.

移动应用设计中应避免的10种错误

- - 博客 - 伯乐在线
导读:本文是来自移动应用开发公司. Sourcebits系列文章 Mobile App Trends Series(译者:此网站要梯子)中的一篇,文章内容适合初涉此领域的读者,现翻译如下. 在了解了 跨平台移动应用设计的利弊和 移动应用开发常见错误 (译者:此两篇翻译中)之后,你想到了一个不错的应用创意,你甚至连应用的名字都想好了,接下来该干什么.

我们在设计iPhone应用时犯过的错误

- - 博客 - 伯乐在线
英文原文: Design Mistakes We Made in Our iPhone App,编译:Beforweb. 今年,我们(英文原文作者及团队)发布了 FreshBooks的第一款iPhone应用. 从前我们的产品一直是通过Web端应用的方式提供服务的. 这次,我们把iPhone应用的设计开发过程看作一张空白的花布,尽力在其中实现一些新的功能概念和设计想法.

从错误中汲取经验–产品设计案例分享

- - 人人都是产品经理
小编今天看到一篇译文是有关产品设计案例分享的内容,个人从中获益匪浅,因此分享给大家,希望大家能够从中汲取经验. 译文:Buffer是一款帮助你在Twitter、Facebook等平台上更高效的发布内容的应用,到目前我们已经有超过50万的用户了. 两年前刚刚开始打造这个产品的时候,我们就已经做好了充分的思想准备去面对各种挑战,包括设计开发过程中会遇到的障碍以及可能犯的错误.

Android 应用中十大导航设计错误

- - 极客公园-GeekPark
曾经热爱 Metro UI, 对 Android Design 了解深入. [核心提示]这一次,我们就设计错误的话题展开,指出一些大家在安卓开发领域设计应用导航时经常被犯下的错误以便更好的避免他们. 大家好,这里是 2014 年第一期正式的 ADiA 教程. 在上一次的 设计错误文章里,我们已经简略的提过了一下导航设计上的错误,这一次,我们就这个话题展开,指出一些大家在设计应用导航时经常被犯下的错误以便更好的避免他们.

扁平化设计(Flat Design)是错误的趋势吗?

- - 人人都是产品经理
知乎上有人就扁平化问题进行下面的提问:. 每个图 标或设计稿后面都蕴藏着设计师自己的想法和意图,也是产品人员们辛勤付出的劳动成果. 我整理了下来自 沈振宇@知乎就此问题的回答:. 我非常支持扁平化的趋势,准确地说,我认为目前的趋势是 抽象化,扁平化只是抽象化的一种典型形式. 所谓拟物,必须是现实世界中已经有的物体,而且是人们日常的生活中经常使用的物品.

列举web应用程序设计中常见的10种错误

- - GamerBoom.com 游戏邦
作者:Jakob Nielsen. 撰写有关应用程序设计错误的文章是件很困难的事情,因为最糟糕的错误往往具有领域专门性和特殊性. 通常情况下,应用程序的失败有以下3种原因:解决了错误的问题;目标问题正确,但使用的是错误的功能;功能设计正确,但过于复杂使得用户难以理解. 这3个错误都可能导致应用的失败,而我却还无法告诉你怎么做才是正确的.