小酌重构系列[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

【关注】keepfool

时间: 2024-10-11 17:41:20

小酌重构系列[16]——引入契约式设计的相关文章

小酌重构系列[6]&mdash;&mdash;引入参数对象

简述 如果方法有超过3个以上的参数,调用方法时就会显得冗词赘句.这时将多个参数封装成一个对象,调用方法会显得干净整洁.这就是本文要讲的重构策略"引入参数对象"--将方法的参数封为类,并用这个类的对象替换方法中原有的参数. 引入参数对象 下图演示了这个重构策略,OrderSerivce表示订单服务,GetOrders()方法根据一些条件获取订单信息.在重构前,GetOrders()方法看起来像长篇大论,调用时也颇为麻烦:在重构后,GetOrders()方法看起来言简意赅,调用时仅需要传入

小酌重构系列目录汇总

为了方便大家阅读这个系列的文章,我弄了个目录汇总. 方法.字段重构 移动方法 (2016-04-24) 提取方法.提取方法对象 (2016-04-26) 方法.字段的提升和降低 (2016-05-01) 分解方法 (2016-05-02) 为布尔方法命名 (2016-05-03) 引入对象参数 (2016-05-04) 类.接口重构 使用委派代替继承 (2016-05-07) 提取接口 (2016-05-08) 解除依赖 (2016-05-09) 分离职责 (2016-05-11) 提取基类.提

小酌重构系列[11]&mdash;&mdash;提取基类、提取子类、合并子类

概述 继承是面向对象中的一个概念,在小酌重构系列[7]--使用委派代替继承这篇文章中,我"父子关系"描述了继承,这是一种比较片面的说法.后来我又在UML类图的6大关系,描述了继承是一种"is a kind of"关系,它更偏向于概念层次,这种解释更契合继承的本质.本篇要讲的3个重构策略提取基类.提取子类.合并子类都是和继承相关的,如果大家对继承的理解已经足够深刻了,这3个策略用起来应该会得心应手. 提取基类 定义:如果有超过一个类有相似的功能,应该提取出一个基类,并

引入契约式设计

概念:本文中的"引入契约式设计"是指我们应该对应该对输入和输出进行验证,以确保系统不会出现我们所想象不到的异常和得不到我们想要的结果. 正文:契约式设计规定方法应该对输入和输出进行验证,这样你便可以保证你得到的数据是可以工作的,一切都是按预期进行的,如果不是按预期进行,异常或是错误就应该被返回,下面我们举的例子中,我们方法中的参数可能会值为null的情况,在这种情况下由于我们没有验证,NullReferenceException异常会报出.另外在方法的结尾处我们也没有保证会返回一个正确

小酌重构系列[12]&mdash;&mdash;去除上帝类

关于上帝类 神说:"要有光",就有了光.--<圣经>.上帝要是会写程序,他写的类一定是"上帝类".程序员不是上帝,不要妄想成为上帝,但程序员可以写出"上帝类".上帝是唯一的,上帝的光芒照耀人间,上帝是很爱面子的,他知道程序员写了"上帝类",抢了他的风头,于是他降下神罚要惩戒程序员.--既然你写了"上帝类",那么就将你流放到艰难地修改和痛苦的维护的炼狱中,在地狱之火中永久地熬炼. 你看,上帝也是有

小酌重构系列[21]&mdash;&mdash;避免双重否定

避免双重否定 在自然语言中,双重否定表示肯定.但是在程序中,双重否定会降低代码的可读性,使程序不易理解,容易产生错觉.人通常是用"正向思维"去理解一件事情的,使用双重否定的判断,需要开发者以"逆向思维"的方式去理解它的含义.另外,在写程序时,"!"符号很容易被疏忽和遗漏,一不小心则会编写出错误的代码,从而产生bug.所以,在程序中,我们应当尽量避免使用双重否定. 优惠券是否未被使用? 还是以在线商城给用户发放优惠券为例,由于优惠券的初始状态是未被

小酌重构系列[8]&mdash;&mdash;提取接口

前言 世间唯一"不变"的是"变化"本身,这句话同样适用于软件设计和开发.在软件系统中,模块(类.方法)应该依赖于抽象,而不应该依赖于实现. 当需求发生"变化"时,如果模块(类.方法)依赖于具体实现,具体实现也需要修改:如果模块(类.方法)依赖于接口,则无需修改现有实现,而是基于接口扩展新的实现. 面向实现?面向接口? 接口可以被复用,但接口的实现却不一定能被复用. 面向实现编程,意味着软件的模块(类.方法)之间的耦合性非常高,每次遭遇"

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

前言 在一些较为复杂的业务中,客户端需要依据条件,执行相应的行为或算法.在实现这些业务时,我们可能会使用较多的分支语句(switch case或if else语句).使用分支语句,意味着"变化"和"重复",每个分支条件都代表一个变化,每个分支逻辑都是相似行为或算法的重复.当追加新的条件时,我们需要追加分支语句,并追加相应的行为或算法. 上一篇文章"使用多态代替条件判断"中,我们讲到它可以处理这些"变化"和"重复&qu

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

概述 异常处理的关键在于何时处理异常以及如何使用异常,有些开发者会觉得try catch的处理和使用难以把握,于是他们秉承着"您可错杀一千,不可放过一个"的想法,给所有的方法添加try catch. 这种方式会对应用程序造成什么影响吗? 从用户角度出发,用户确实难以察觉到什么,应用程序运行正常,使用的体验好像也没什么差别. 从程序角度出发,大量的try catch会降低代码的可读性,只有在异常触发时才会对程序的性能造成较大的影响. 这两种角度有对错吗? 二者都没有错,第一种角度甚至要远