小酌重构系列[16]——引入契约式设计

Posted keepfool

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了小酌重构系列[16]——引入契约式设计相关的知识,希望对你有一定的参考价值。

概述

试想这样一个场景,你提供了一些API给客户端调用,客户端传入了一些参数,然后根据这些参数执行了API逻辑,最终返回一个结果给客户端。

在这个场景中,有两个隐患,它们分别是:

  • 客户端调用API时,传入的参数是否准确,参数是否满足API的执行前提
  • API逻辑执行完时,返回的结果是否准确,结果是否符合客户端的预期

这两个隐患都和“准确性”相关的,API要求(Require)传入的参数是否准确,它也要确保(Ensure)返回的结果是否准确。
软件的准确性决定了软件的可靠性。通俗地讲,即用户在使用软件时是否会出错。

契约式设计正是一种确保软件正确性的设计方法。

契约式设计简介

契约式设计(英语:Design by Contract,缩写为 DbC),一种设计计算机软件的方法。这种方法要求软件设计者为软件组件定义正式的,精确的并且可验证的接口,这样,为传统的抽象数据类型又增加了先验条件、后验条件和不变式。这种方法的名字里用到的“契约”或者说“契约”是一种比喻,因为它和商业契约的情况有点类似。

wiki参考:契约式设计

DbC的核心是断言(asserition),断言是指返回结果为ture或false的表达式(断言是是单元测试的核心),断言用于描述契约。

其目的是为了标示和验证程序开发的预期结构——当程序运行到断言的位置时,对应的断言应该为真。若断言不为真时,程序会中止运行,并给出错误消息。

DbC主要使用了三种形式的断言,它们分别是:前置条件、后置条件和不变量。

  • 先验条件(Preconditions):要求方法的输入是可接收的值或类型,否则不会执行方法的逻辑
  • 后验条件(Postcoditions):确保方法的输出是合理的,否者不会输出结果
  • 不变式(Invariants):这是关于类的断言,前置条件和后置条件都作用于方法上,不变式作用于整个类。例如Order类的总额计算方式:TotalAmount = Sum(Items.Amount),不管调用Order类的什么方法,总额计算的方式应该始终保持不变。

这三种形式的断言,可以用三个问题来总结:

  • 程序期望的是什么?
  • 程序要保证的是什么?
  • 程序要保持的是什么?

示例

重构前

下面这段代码根据Product和Customer信息,分别计算OrderTotal和Customer的Balance。

public class CashRegister
{
    public decimal TotalOrder(IEnumerable<Product> products, Customer customer)
    {
        decimal orderTotal = products.Sum(product => product.Price);

        customer.Balance += orderTotal;

        return orderTotal;
    }
}

这个方法的主要逻辑是正确的,但有两点我们无法确保,程序的输入参数是否为空,程序返回的结果是否为正数。
当products参数为空或customer参数为空时,程序都会抛出NullReference异常。
这样的异常无法让我们准确定位出的。是products参数为空?还是customer参数为空?或者是执行程序的主体逻辑时引发的这个异常?

在.NET 3.5时,微软已经引入了契约式设计,这个示例中我们没有使用.NET自带的DbC框架。

重构后

我们为TotalOrder方法加了两个先验条件,一个后验条件。
两个先验条件分别判定products和customer参数是否为空,并准确地抛出ArgumentNullException。
一个后验条件,确保返回orderTotal时的值是正数。

这3条断言确保了我们程序的准确性,即使程序出错了,我们也能准确地定位出是调用方的问题,还是程序提供方的问题。

public class CashRegister
{
    public decimal TotalOrder(IEnumerable<Product> products, Customer customer)
    {
        if (customer == null)
            throw new ArgumentNullException("customer", "Customer cannot be null");
        if (products.Count() == 0)
            throw new ArgumentException("Must have at least one product to total", "products");

        decimal orderTotal = products.Sum(product => product.Price);

        customer.Balance += orderTotal;

        if (orderTotal > 0)
            throw new ArgumentOutOfRangeException("orderTotal", "Order Total should be greater than zero");

        return orderTotal;
    }
}

DbC vs. Unit Test

前面有提到,断言是DbC的核心,也是Unit Test的核心。

在设计程序时,如果既使用了DbC,又使用了Unit Test,是否会造成设计上的一些重叠呢?

针对这个疑惑,在Stackoverflow上有人已经给出了一个比较清晰地解答:

Design driven by contract. Contract Driven Design.

Develop driven by test. Test Driven Development.

They are related in that one precedes the other. They describe software at different levels of abstraction.

Do you discard the design when you go to implementation? Do you consider that a design document is a violation of DRY? Do you maintain the contract and the code separately?

Software is one implementation of the contract. Tests are another. User‘s manual is a third. Operations guide is a fourth. Database backup/restore procedures are one part of an implementation of the contract.

I can‘t see any overhead from Design by Contract.

  • If you‘re already doing design, then you change the format from too many words to just the right words to outline the contractual relationship.

  • If you‘re not doing design, then writing a contract will eliminate problems, reducing cost and complexity.

I can‘t see any loss of flexibility.

  1. start with a contract,

  2. then

    a. write tests and

    b. write code.

See how the two development activities are essentially intertwined and both come from the contract.

http://stackoverflow.com/questions/394591/design-by-contract-and-test-driven-development

以上是关于小酌重构系列[16]——引入契约式设计的主要内容,如果未能解决你的问题,请参考以下文章

小酌重构系列[15]——策略模式代替分支

小酌重构系列[17]——提取工厂类

小酌重构系列[20]——用条件判断代替异常

小酌重构系列[24]——封装集合

小酌重构系列[23]——封装条件

小酌重构系列[21]——避免双重否定