65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Code Contracts 检测 Liskov 替换原则违规

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.90/5 (7投票s)

2016年3月12日

CPOL

3分钟阅读

viewsIcon

19885

代码契约提供了一些工具,用于显式定义关于方法参数的假设,并通过静态代码分析帮助在运行时之前发现错误。本文演示了代码契约的用法。

引言

里氏替换原则是五个 SOLID 原则之一。它指出如果 S 是 T 的子类型,那么类型 T 的对象可以用类型 S 的对象替换,而不会改变该程序的任何期望属性[A1]。换句话说 - 子类应该可以替换它们的基类[A2]

违反 LSP 会让你感到惊讶

如果软件开发人员不遵守 LSP,那么产生的对象很有可能会以意想不到的方式运行,而且继承的级别越高,这种可能性就越大。例如,即使 salmon 是动物,并且 abstract 动物可以发出声音,Salmon 类的 MakeASound 方法也会抛出异常。

另一个例子可能是在 Child 类的实例上使用 Wine 参数执行 Drink 方法。预期任何 Human 都可以饮用任何 DrinkableFluid,因此,如果 Child 派生自 Human,而 Wine 派生自 AlcoholAlcohol 派生自 DrinkableFluidDrinkableFluid 派生自 Fluid – 为什么 Drink 方法会抛出异常呢?

如何通过代码契约避免意外

多年前,微软研究院团队启动了代码契约项目。它允许以先决条件、后置条件和对象不变式的形式定义契约。它提供静态和运行时契约检查。通过引入对通常忽略的输入数据形式的假设的自动分析,正确定义的契约可以节省时间和精力。

最明显和学术性的无意识假设是除以零

double Divide(double a, double b)
{
    return a / b;
}

解决这个问题的一个简单方法似乎是抛出一个异常

double Divide(double a, double b)
{
    if (b == 0)
    {
        throw new ArgumentException("Divider can't be 0.");
    }
    return a / b;  
}

简单 - 是的,但无效。该检查将在运行时执行,所以我们永远不会安全。我们可能会捕获异常,向用户显示警告并继续程序执行,但对于复杂的系统,它并没有真正的帮助。

使用代码契约,我们可以显式地定义我们的假设,如果在我们的代码中的任何地方违反了这些假设,我们将收到通知。不仅在运行时,而且在编译过程完成后立即收到通知。

double Divide(double a, double b)
{
    Contract.Requires(b != 0);
    return a / b;
}

如果在我们的代码中的任何地方,我们尝试使用 b 等于零来执行 Divide 方法,代码契约的 static 代码分析器将警告我们

CodeContracts: requires is false: b != 0

代码契约和 LSP

代码契约也可以帮助我们检测 LSP 违例。

让我们定义一些类

public class Human
{
    public int Age { get; set; }
    public int ConsumedCalories { get; set; }

    public Human(int age)
    {
        Contract.Requires(age > 0);
        Contract.Requires(age < 130);
        this.Age = age;
    }

    public virtual void Drink(DrinkableFluid fluid, int ml)
    {
        Contract.Requires(fluid != null);
        Contract.Requires(ml > 0);
        this.ConsumedCalories += Convert.ToInt32(ml * fluid.CaloriesPerMl);
    }
}

public class Child : Human
{
    public Child(int age)
        : base(age)
    {
        Contract.Requires(age > 0);
        Contract.Requires(age < 130);
        this.Age = age;
    }

    public override void Drink(DrinkableFluid fluid, int ml)
    {
        Contract.Requires(!fluid.GetType().IsAssignableFrom(typeof(Alcohol)));
        this.ConsumedCalories += Convert.ToInt32(ml * fluid.CaloriesPerMl);
    }
}

public abstract class DrinkableFluid
{
    public double CaloriesPerMl;
}

public class Sprite : DrinkableFluid
{
    public Sprite()
    {
        this.CaloriesPerMl = 0.27;
    }
}

public class Alcohol : DrinkableFluid
{
}

public class Wine : Alcohol
{
    public Wine()
    {
        this.CaloriesPerMl = 0.85;
    }
}

上面的代码的问题是设计不良的 Human-Child 继承。毫无疑问,葡萄酒和雪碧都是可以饮用的液体。但是葡萄酒不能被儿童饮用(出售,事实上不是饮用,但让我们假设在理想的世界中它也不能被饮用)。如果我们有 HumanChild 类,我们倾向于认为 Human 是一个成年人。儿童显然不是成人的子类型,因为他们不能替代成人。他们不能工作,不能喝酒,也不能做很多其他事情。Human-Child 继承层次结构是一个糟糕的抽象。

如果没有代码契约,我们只需在 Child 类的 Drink 方法中添加一个条件并抛出一些异常

public override void Drink(DrinkableFluid fluid, int ml)
{
    if (fluid is Alcohol)
    {
        throw new ArgumentException("Children can't drink alcohol");
    }
    this.Calories += Convert.ToInt32(ml * fluid.CaloriesPerMl);
}

结果,在有人执行以下代码之前,我们不会意识到这个问题

Human human= new Child(10);
DrinkableFluid fluid = new Wine();
human.Drink(fluid, 750);

但是有了代码契约,就没有风险了。在编译结束后,我们将收到警告

结论

代码契约提供了一些工具,用于显式定义关于方法参数的假设,并通过静态代码分析帮助在运行时之前发现错误。这些契约可以帮助您避免像里氏替换原则违例这样的细微问题。

链接

有关代码契约的更多信息,请访问

示例的源代码

参考文献

© . All rights reserved.