在面向对象编程中,里氏替换原则(Liskov Substitution Principle, LSP)是一个关键的设计原则,它指出如果S是T的子类型,那么T类型的对象可以被S类型的对象替换,而不会改变程序的期望行为。简而言之,子类应该能够替换其基类。如果开发者不遵守LSP,随着继承层次的增加,程序中的对象可能会以意想不到的方式行为,例如,一个动物类中的MakeASound
方法可能会抛出异常,即使该动物是一条鲑鱼,而鲑鱼作为动物的抽象类是可以发出声音的。
另一个例子是,如果一个Child
类继承自Human
类,而Wine
类继承自Alcohol
类,后者又继承自DrinkableFluid
类,那么任何Human
都应该能够饮用任何DrinkableFluid
。然而,如果Child
实例调用Drink
方法时传入Wine
参数,该方法抛出异常,这显然是违反了LSP的。
多年前,微软研究院启动了代码契约项目。它允许定义契约,形式包括前置条件、后置条件和对象不变式。它提供了静态和运行时契约检查。正确定义的契约可以通过引入通常被忽略的输入数据形式假设的自动分析,节省时间和精力。
最明显且学术化的无意识假设例子是除以零的情况:
public double Divide(double a, double b) {
return a / b;
}
解决这个问题的一个简单方案是抛出异常:
public double Divide(double a, double b) {
if (b == 0) {
throw new ArgumentException("Divider can't be 0.");
}
return a / b;
}
简单是简单,但并不有效。检查将在运行时执行,所以永远不会安全。可能会捕获异常,向用户显示警告并继续程序执行,但在复杂系统中,这并不是真正有帮助的。
使用代码契约,可以明确定义假设,如果在代码中的任何地方违反了这些假设,将得到通知。不仅仅是在运行时,而且在编译过程结束后立即。
public double Divide(double a, double b) {
Contract.Requires(b != 0);
return a / b;
}
如果在代码中的任何地方尝试执行Divide
方法,并且b
等于零,代码契约的静态代码分析器将警告:
CodeContracts: requires is false: b != 0
代码契约也可以帮助检测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);
但是有了代码契约,就没有风险。编译结束后,立即得到警告:
结论
代码契约提供了明确定义方法参数假设的工具,并通过静态代码分析帮助在运行时出现之前找到错误。契约可以帮助避免像里氏替换原则违规这样的微妙问题。