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






3.90/5 (7投票s)
代码契约提供了一些工具,用于显式定义关于方法参数的假设,并通过静态代码分析帮助在运行时之前发现错误。本文演示了代码契约的用法。
引言
里氏替换原则是五个 SOLID 原则之一。它指出如果 S 是 T 的子类型,那么类型 T 的对象可以用类型 S 的对象替换,而不会改变该程序的任何期望属性[A1]。换句话说 - 子类应该可以替换它们的基类[A2]。
违反 LSP 会让你感到惊讶
如果软件开发人员不遵守 LSP,那么产生的对象很有可能会以意想不到的方式运行,而且继承的级别越高,这种可能性就越大。例如,即使 salmon
是动物,并且 abstract
动物可以发出声音,Salmon
类的 MakeASound
方法也会抛出异常。
另一个例子可能是在 Child
类的实例上使用 Wine
参数执行 Drink
方法。预期任何 Human
都可以饮用任何 DrinkableFluid
,因此,如果 Child
派生自 Human
,而 Wine
派生自 Alcohol
,Alcohol
派生自 DrinkableFluid
,DrinkableFluid
派生自 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 继承。毫无疑问,葡萄酒和雪碧都是可以饮用的液体。但是葡萄酒不能被儿童饮用(出售,事实上不是饮用,但让我们假设在理想的世界中它也不能被饮用)。如果我们有 Human
和 Child
类,我们倾向于认为 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);
但是有了代码契约,就没有风险了。在编译结束后,我们将收到警告
结论
代码契约提供了一些工具,用于显式定义关于方法参数的假设,并通过静态代码分析帮助在运行时之前发现错误。这些契约可以帮助您避免像里氏替换原则违例这样的细微问题。
链接
有关代码契约的更多信息,请访问
- https://msdn.microsoft.com/en-us/library/dd264808%28v=vs.110%29.aspx
- https://codeproject.org.cn/Articles/103779/Introducing-Code-Contracts
- https://visualstudiogallery.msdn.microsoft.com/1ec7db13-3363-46c9-851f-1ce455f66970
- https://github.com/Microsoft/CodeContracts
示例的源代码
参考文献
- [A1]维基百科:https://en.wikipedia.org/wiki/Liskov_substitution_principle
- [A2]Microsoft .NET:面向企业的应用程序架构,第二版,Dino Esposito,Andrea Saltarello