敏捷开发-SOLID-Liskov替换原则
Liskov替换原则介绍
Liskov替换原则(Liskov Substitution Principle,LSP)是一组用于创建继承层次结构的指导原则。
按照Liskov替换原则创建的继承层次结构中,客户端代码能够放心地使用它的任意类或子类而不担心影响所期望的行为。
如果不遵守Liskov替换原则的规则,对一个类层次结构的扩展(也就是说,增加一个新的子类)很可能迫使所有使用基类或接口的客户端代码也要做相应的改动。相反,如果严格遵守Liskov替换原则的规则,客户端将无法看到对类层次结构所做的任何改动。
只要接口保持不变,就应该没有理由改动任何已有的客户端代码。因此,Liskov替换原则也辅助增强了开放与封闭原则和单一职责原则的应用效果。
正式定义
Liskov替换原则的正式定义是由杰出的计算机科学家Barbara Liskov给出的
如果S是T的子类型,那么所有T类型的对象都可以在不破坏程序的情况下被S类型的对象替换。
定义中有三个与Liskov替换原则相关的代码要素:
- 基类型:客户引用的类型(T)。子类型可以重写(或部分定制)客户所调用的基类的任意方法。
- 子类型:继承自基类型(T)的一组类(S)中的任意一个。客户端不应该,也不需要知道它们在实际调用哪个具体的子类型。无论使用的是哪个子类型实例,客户端代码所表现的行为都是一样的。
- 上下文:客户端和子类型交互的方式。如果客户端不和子类型交互,就谈不上是否违背或遵守了Liskov替换原则。
Liskov 替换原则的规则
要应用Liskov替换原则就必须遵守几个规则。这些规则可以划分为两类:契约规则(与类的期望有关)和变体规则(与代码中能被替换的类型有关)。
1. 契约规则
这些规则与子类型的契约及其相应的约束相关:
- 子类型不能加强前置条件。
- 子类型不能削弱后置条件。
- 子类型必须保持超类型中的数据不变式(不变式是一个必须保持为真的条件)。
为了理解这些契约规则,你必须先理解契约的概念,然后再弄清楚在创建子类型时确保遵守这些规则需要做的事情。
2. 变体规则
这些规则与方法的参数及返回类型相关:
- 子类型的方法参数类型必须是支持逆变的。
- 子类型的返回类型必须是支持协变的。
- 子类型不能引发不属于已有异常层次结构中的新异常。
契约
开发人员应该面向接口编程或面向契约编程。然而,除了表面上的方法签名,接口所表达的只是一个不够严谨的契约概念。如图所示,只从方法的签名很难看到很多与实际需求以及方法实现保证相关的信息。
从方法签名只能看到与实现期望相关的信息 |
---|
所有方法至少有一个可选的返回类型、一个名称和一个可选的正式参数的列表。每个参数都由一个具体类型和名称组成。在调用上图中展示的方法时,你知道(只从方法签名上看)需要传入三个参数,一个类型是float
,一个类型是SizeRegionInfo
。你也知道可以将类型为decimal
的返回值保存到一个变量中或在调用结束后操作该返回值。
作为方法编写者,你能控制方法和参数的名称。你要特别用心地确保方法名称能反映出它的真实目的,同时参数名称要尽可能是描述性的。CalculateShippingCost
函数的名称使用了动名词组的形式。这里的动词(由方法执行的动作)是Calculate
,名词(动词的对象)是
ShippingCost
。在某种意义上,这个名词就是返回值的名称。参数也选择了描述性的名称:packageDimensionsInInches
和packageWeightInKilorams
都是自解释的参数名称,特别是在该方法的上下文中。它们构成了方法文档化的开端。
然而,方法签名并没有包含方法的契约信息。比如,packageWeightInKilograms
参数是float
类型的。这就暗示客户端任何float
值都是有效的,包括负数值。但是因此参数表达的是重量,所以负数值应该是无效的。方法的契约应当强制要求重量值必须大于零。为了保证做到这一点,方法必须要实现一个前置条件。
前置条件
前置条件(precondition)是一个能保障方法稳定无错运行的先决条件。所有方法在被调用前都要求某些前置条件为真。默认情况下,接口并没有任何对方法具体实现的保障。
引发异常是一种强制履行契约的高效方式
public decimal CalculateShippingCost(
float packageWeightInKilograms,
Size<float> packageDimensionsInInches,
RegionInfo destination)
{
if (packageWeightInKilograms <= 0f) throw new Exception();
return decimal.MinusOne;
}
方法入口处的if语句是一种强制设置前置条件的方式,比如重量必须大于零千克的需求。如果条件packageWeightInKilograms <= 0f
为真,方法会引发一个异常并立即结束运行。这种方式肯定可以阻止方法在有参数无效的情况下被执行。
尽可能提供详尽的前置条件校验失败原因是很重要的
public decimal CalculateShippingCost(
float packageWeightInKilograms,
Size<float> packageDimensionsInInches,
RegionInfo destination)
{
if (packageWeightInKilograms <= 0f)
throw new ArgumentOutOfRangeException("packageWeightInKilograms",
"Package weightmust be positive and non-zero");
return decimal.MinusOne;
}
新的异常有了很多改进,它的名称解释了自己的目的:参数超出了有效范围,而且客户端能知道是哪个参数出错以及相应的问题描述。
通过像这样将更多的防卫子句链接到一起,你可以添加更多条件,这些条件是为了调用方法而不引发异常所必须满足的条件.
增加足够多的必要前置条件可以防止在参数无效的情况下方法被调用
public decimal CalculateShippingCost(
float packageWeightInKilograms,
Size<float> packageDimensionsInInches,
RegionInfo destination)
{
if (packageWeightInKilograms <= 0f)
throw new ArgumentOutOfRangeException("packageWeightInKilograms",
"Package weight must be positive and non-zero");
if (packageDimensionsInInches.X <= 0f || packageDimensionsInInches.Y <= 0f)
throw new ArgumentOutOfRangeException("packageDimensionsInInches",
"Package dimensions must be positive and non-zero");
return decimal.MinusOne;
}
如果有了这些前置条件,客户端代码就必须在调用方法前确保它们要传递的参数值要处于有效范围内。
当然,所有在前置条件中检查的状态必须是公开可访问的。
如果客户端无法验证由于未通过前置条件检查导致将要调用的方法引发异常,客户端就无法确保接下来的调用一定会成功。
因此,私有状态不应该是前置条件检查的目标,只有方法参数和类的公共属性才应该有前置条件。
后置条件
后置条件(postcondition)会在方法退出时检测一个对象是否处于一个无效的状态。只要方法内改动了状态,就有可能因为方法逻辑错误导致状态无效。
与实现前置条件一样,可以使用防卫子句来实现后置条件。然而,后置条件并不是布置在方法的入口处,而是必须布置在所有的状态编辑动作之后的方法尾部.
方法尾部的临界子句是一个后置条件,它能确保返回值处于有效范围内
public virtual decimal CalculateShippingCost(float packageWeightInKilograms,
Size<float>packageDimensionsInInches,
RegionInfo destination)
{
if (packageWeightInKilograms <= 0f)
throw new ArgumentOutOfRangeException("packageWeightInKilograms",
"Package weight must be positive and non-zero");
if (packageDimensionsInInches.X <= 0f || packageDimensionsInInches.Y <= 0f)
throw new ArgumentOutOfRangeException("packageDimensionsInInches",
"Package dimensions must be positive and non-zero");
// shipping cost calculation
var shippingCost = decimal.One;
if(shippingCost <= decimal.Zero)
throw new ArgumentOutOfRangeException("return", "The return value is out of range");
return shippingCost;
}
通过预先定义的有效范围检查状态值(如果值不在指定范围就引发异常),你能强制方法符合一个后置条件。
上面示例中的后置条件与对象的状态并不相关,而是与方法返回值相关。像方法参数要经过前置条件检查一样,方法的返回值也需要经过后置条件的校验。
如果方法中任意地方将返回值设置为零或者负数值,这个后置条件会检测到它并在方法尾部中止执行。
通过这种方式,该方法的客户端永远无法在意外地收到无效返回值时还能认为它依然有效。
注意,该方法的签名无法保证返回值必须大于零,要达到这个目的,必须通过客户端履行方法的契约来保证。
数据不变式
第三种类型的契约是数据不变式。数据不变式(data invariant )是在一个对象生命周期内始终都保持为真的一个谓词;
该谓词条件在从对象构造后一直到超出其作用范围前这段时间都为真。数据不变式都是与期望的对象内部状态有关。
ShippingStrategy调用的一个数据不变式的一个示例是:提供的比例税率为正值且不为零。如果如下代码中所示,在构造函数中设置比例税率,那么只需要在构造函数入口处增加一个防卫子句就可以防止将其设置为无效值。
给构造函数增加前置条件能够保证相应的数据不变式
public class ShippingStrategy
{
public ShippingStrategy(decimal flatRate)
{
if (flatRate <= decimal.Zero)
throw new ArgumentOutOfRangeException("flatRate",
"Flat rate must be positiveand non-zero");
this.flatRate = flatRate;
}
protected decimal flatRate;
}
因为flatRate
是一个受保护的成员变量,所以客户端只能通过构造函数来设置它。如果传入构造函数的值是有效的,这就保证了该ShippingStrategy
类实例的对象在生命周期内flatRate
值都是有效的,因为客户端没有其他方式可以修改它。
然而,如果把flatRate
定义为公共且可设置的属性,为了保护这个数据不变式,就必须把防卫子句布置到属性设置器内。
当数据不变式是一个公共属性时,防卫子句就应该布置在它的设置器中
public class ShippingStrategy
{
public ShippingStrategy(decimal flatRate)
{
FlatRate = flatRate;
}
public decimal FlatRate
{
get
{
return flatRate;
}
set
{
if (value <= decimal.Zero)
throw new ArgumentOutOfRangeException("value",
"Flat rate must be positive and non-zero");
flatRate = value;
}
}
}
现在客户端能够改变FlatRate
属性的值了,但是由于属性设置器内的if
语句和异常的保护,这个数据不变式是无法被破坏的。
Liskov契约规则
如果S是T的子类型,那么所有T类型的对象都可以在不破坏程序的情况下被S类型的对象替换。
与契约相关的指导原则:
- 子类型不能加强前置条件。
- 子类型不能削弱后置条件。
- 子类型必须保持超类型中的数据不变式。
如果你在基于现有类创建子类时遵守了所有这些规则,那么替换性将会在你处理契约时得到保留。
任何时候创建子类,都能带有所有组成父类的方法、属性和字段。当然,也包括方法和属性设置器内的所有契约。
所有前置条件、后置条件和数据不变式都被期望按照父类中的相同方式保留。
在适当的时候,子类被允许重写父类的方法实现,此时才有机会修改其中的契约。
Liskov替换原则明确规定一些变更是被禁止的,因为它们会导致原来使用超类实例的已有客户端代码在切换至子类时必须要做更改。
1. 前置条件不能被加强
当子类重写包含前置条件的超类方法时,它绝不应该加强现有的前置条件。这样做很可能会影响到那些已经假设超类为所有方法定义了最严格的前置条件契约的客户端代码。
子类通过增加一个新的防卫子句增强了前置条件
public class WorldWideShippingStrategy : ShippingStrategy
{
public override decimal CalculateShippingCost(
float packageWeightInKilograms,
Size<float> packageDimensionsInInches,
RegionInfo destination)
{
if (packageWeightInKilograms <= 0f)
throw new ArgumentOutOfRangeException("packageWeightInKilograms",
"Package weight must be positive and non-zero");
if (packageDimensionsInInches.X <= 0f || packageDimensionsInInches.Y <= 0f)
throw new ArgumentOutOfRangeException("packageDimensionsInInches",
"Package dimensions must be positive and non-zero");
if (destination == null)
throw new ArgumentNullException("destination", "Destination must be provided");
return decimal.One;
}
}
由于它与ShippingStrategy
类很相似,因此将该类实现为后者的子类。
CalcuteShippingCost
方法被重写后,多了一个新防卫子句,作为对regionInfo
参数代表的包裹目的地信息进行检查。
ShippingStrategy
类并没有要求必须提供包裹目的地信息,但是子类WorldWideShippingStrategy
则要求必须提供有效的包裹目的地参数,否则它就无法正确计算出包裹运输到目的地的费用。
加强前置条件的尝试让你能够保证得到有效的目的地参数,但这会引起一个已有的调用代码无法解决的问题。
如果某个类调用了ShippingStrategy
类的CalculateShippingCost
方法,它是可以向目的地参数传入空值且不担心有副作用产生。
但是,如果改为调用WorldWideShippingStrategy
类的CalculateShippingCost
方法后,它就必须要确保传给目的地参数的值不为空。
如果传入的是空值,就会违背相应的前置条件要求并且会引发一个异常。
客户端代码绝不应该假设类的具体行为。那样做只会在客户端代码和类之间引入紧密耦合关系,从而导致缺乏响应需求变更的能力。
当加强前置条件时,客户端无法在需要
ShippingStrategy
的情况下可靠地使用WorldWideShippingStrategy
[Test]
public void ShippingRegionMustBeProvided()
{
strategy.Invoking(s => s.CalculateShippingCost(1f, ValidDimensions, null))
.ShouldThrow<ArgumentNullException>("Destination must be provided")
.And.ParamName.Should().Be("destination");
}
如果该测试使用的strategy
对象是WorldWideShippingStrategy
类的实例,测试的结果会是成功的;没有提供必要的目的地信息,因此会按照期望引发一个异常。
相反,如果使用的是ShippingStrategy
类实例,该测试将会失败,因为ShippingStrategy
类的方法实现中并没有前置条件来检查目的地值是否为空,也不会按照测试期望的那样在检查到空值时引发异常。
一组重构过的单元测试,它们并不尝试对这两种不同的类测试相同的前置条件。重构后的单元测试分别针对这两种不同的运输策略类型
[TestFixture]
public class WorldWideShippingStrategyTests : ShippingStrategyTestsBase
{
[Test]
public void ShippingRegionMustBeProvided()
{
strategy.Invoking(s => s.CalculateShippingCost(1f, ValidSize, null))
.ShouldThrow<ArgumentNullException>("Destination must be provided")
.And.ParamName.Should().Be("destination");
}
protected override ShippingStrategy CreateShippingStrategy()
{
return new WorldWideShippingStrategy(decimal.One);
}
}
// . . .
public abstract class ShippingStrategyTestsBase
{
[Test]
public void ShippingWeightMustBePositive()
{
strategy.Invoking(s => s.CalculateShippingCost(-1f, ValidSize, null))
.ShouldThrow<ArgumentOutOfRangeException>("Package weight must be positive and non-zero")
.And.ParamName.Should().Be("packageWeightInKilograms");
}
}
2. 后置条件不能被削弱
在向子类应用后置条件时,规则恰好相反。不是不能加强后置条件,而是不能削弱它们。
对于所有与和契约相关的Liskov替换规则而言,你不能够削弱后置条件的原因很明显,因为已有的客户端代码在从原有的超类切换至新的子类时很可能会出错。理论上,如果严格遵守了Liskov替换原则,你所创建的任何子类都能够被所有已有的客户端代码使用且不会引起意料之外的错误。
在一个已有的客户端代码中引入意外失败的示例,其中包括了与
WorldWideShippingStrategy
类相关的单元测试和实现,该类用来对国际包裹运输建模。新的实现要求削弱后置条件
[Test]
public void ShippingDomesticallyIsFree()
{
strategy.CalculateShippingCost(1f, ValidDimensions, RegionInfo.CurrentRegion)
.Should().Be(decimal.Zero);
}
// . . .
public override decimal CalculateShippingCost(float packageWeightInKilograms, Size<float>
packageDimensionsInInches, RegionInfo destination)
{
if (destination == null)
throw new ArgumentNullException("destination", "Destination must be provided");
if (packageWeightInKilograms <= 0f)
throw new ArgumentOutOfRangeException("packageWeightInKilograms",
"Package weight must be positive and non-zero");
if (packageDimensionsInInches.X <= 0f || packageDimensionsInInches.Y <= 0f)
throw new ArgumentOutOfRangeException("packageDimensionsInInches",
"Package dimensions must be positive and non-zero");
var shippingCost = decimal.One;
if(destination == RegionInfo.CurrentRegion)
{
shippingCost = decimal.Zero;
}
return shippingCost;
}
示例中的单元测试用来在当前区域用作目的地(也就是本地运输)时断言WorldWideShippingStrategy
类不会对此次运输收费。这也在方法尾部的实现中有所体现。这个新测试再一次与基类的单元测试发生了冲突,原有的测试断言是:方法的返回值必须大于零。
原有的单元测试会在使用
WorldWideShippingStrategy
类实例时失败
[Test]
public void ShippingCostMustBePositiveAndNonZero()
{
strategy.CalculateShippingCost(1f, ValidDimensions, RegionInfo.CurrentRegion)
.Should().BeGreaterThan(0m);
}
这样的改动很容易影响已经对运输费用值有所假设的客户端代码。
比如,有客户端已经根据ShippingStrategy
类的前置条件契约假设了运输成本总是大于零的,然后该客户端会在后续的计算中使用该运输成本。当切换至新的WorldWideShippingStrategy
类时,该客户端却突然开始不断地对所有本地订单引发DivideByZeroException
异常。
如果你遵守Liskov替换原则并且决不削弱后置条件,就不会引入这个缺陷。
3. 数据不变式必须被保持
在创建新的子类时,它必须继续遵守基类中的所有数据不变式。这里很容易出问题,因为子类有很多机会来改变基类中的私有数据。
子类破坏了超类的数据不变式,因此也违背了Liskov替换原则
[Test]
public void ShippingFlatRateCanBeChanged()
{
strategy.FlatRate = decimal.MinusOne;
strategy.FlatRate.Should().Be(decimal.MinusOne);
}
// . . .
public class WorldWideShippingStrategy : ShippingStrategy
{
public WorldWideShippingStrategy(decimal flatRate)
: base(flatRate)
{
}
public decimal FlatRate
{
get
{
return flatRate;
}
set
{
flatRate = value;
}
}
}
尽管子类重用了基类的构造函数及其防卫子句,但它并没有保护好原有的数据不变式,因此也违背了Liskov替换原则。
上面示例中的单元测试可以证明客户端能够设置负数值。
如果该类能够正确地保护原有的数据不变式,就不应该允许将比例税率设置为负数值。
下面代码,重构后的基类不再允许直接修改比例税率字段,它的子类也正确地保持了比例税率属性这个数据不变式。
这种模式非常普遍:私有的字段有对应的受保护的或公共的属性,属性的设置器中包含的防卫子句用来保护属性相关的数据不变式。
基类只允许子类通过包括防卫子句的设置器来修改比例税率字段
public class WorldWideShippingStrategy : ShippingStrategy
{
public WorldWideShippingStrategy(decimal flatRate)
: base(flatRate)
{
}
public new decimal FlatRate
{
get
{
return base.FlatRate;
}
set
{
base.FlatRate = value;
}
}
}
// . . .
public class ShippingStrategy
{
public ShippingStrategy(decimal flatRate)
{
if (flatRate <= decimal.Zero)
throw new ArgumentOutOfRangeException("flatRate",
"Flat rate must be positive and non-zero");
this.flatRate = flatRate;
}
protected decimal FlatRate
{
get
{
return flatRate;
}
set
{
if (value <= decimal.Zero)
throw new ArgumentOutOfRangeException("value",
"Flat rate must be positive and non-zero");
flatRate = value;
}
}
}
在严格控制了字段的可见性并只允许通过引入防卫子句的属性设置器访问该字段后,该属性相关的数据不变式得到了保护。
对子类层次来说,这种方式也是值得推荐的,因为这意味着将来所有的子类都不再需要防卫子句检查,它们无法直接改写超类中的这个字段。
在维护了不变式的情况下,此单元测试通过
[Test]
public void ShippingFlatRateCannotBeSetToNegativeNumber()
{
strategy.Invoking(s => s.FlatRate = decimal.MinusOne)
.ShouldThrow<ArgumentOutOfRangeException>("Flat rate must be positive and nonzero")
.And.ParamName.Should().Be("value");
}
如果一个客户端尝试将FlatRate
属性设置为负数值或零,设置器中的临界子句会阻止赋值的动作并且引发一个ArgumentOutOfRangeException
异常。
代码契约
1. 前置条件
使用代码契约可以让前置条件代码变得很简洁。在引用mscorlib.dll
程序集中的 System.Diagnostics.Contracts
命名空间后,你就不再需要引用其他的程序集了。其中的 Contract
静态类提供了实现契约所需的主要功能。
如果你决定好采用代码契约这种方式,代码中会有很多地方使用Contract静态类。这倒不是个大问题,因为代码契约是一种普遍应用的代码基础结构,通常也不会认为要移除或者替换它。
但是,一旦应用之后再想剔除代码契约,工作量就会非常巨大,所以最好在一开始就做好决定:要么全面应用,要么根本不用。
System.Diagnostics.Contracts命名空间能够为方法提供防卫子句
using System.Diagnostics.Contracts;
public class ShippingStrategy
{
public decimal CalculateShippingCost(float packageWeightInKilograms, Size<float>
packageDimensionsInInches, RegionInfo destination)
{
Contract.Requires(packageWeightInKilograms > 0f);
Contract.Requires(packageDimensionsInInches.X > 0f && packageDimensionsInInches.Y
> 0f);
return decimal.MinusOne;
}
}
Contract.Requires
方法接受一个布尔谓词值。该谓词表示方法主体逻辑执行前需要的状态。
注意,这与手动防卫子句的if语句中的谓词恰好相反。手动临界子句会在状态无效时引发异常。而在代码契约中,谓词更接近于单元测试中的断言:布尔条件式的值必须为真,否则就违背了契约。
上面的示例要求packageWeightInKilograms
参数必须大于零,packageDimensionsInInches
参数的X和Y属性都必须大于零。
Contract.Requires
方法会在契约谓词没有得到满足时引发一个异常,只是这里的异常类为ContractException
,这与前面示例中已有单元测试期望的异常类不相符。因此,那些已有的单元测试会失败。
Expected System.ArgumentOutOfRangeException because Package dimension must be positive and nonzero,
but found System.Diagnostics.Contracts.__ContractsRuntime+ContractException with message
"Precondition failed: packageDimensionsInInches.X > 0f && packageDimensionsInInches.Y > 0f"
2. 后置条件
与前置条件类似,代码契约也为定义后置条件提供了快捷方法。Contract
静态类包含的Ensures
方法就是用来实现后置条件的。该方法同样是接受一个必须为真的谓词,以便继续执行到方法出口。
值得注意的是,Contract.Ensures
方法后不可以有返回语句之外的其他语句。作这样的要求是很有意义的,因为任何返回语句之外的后续代码都有可能破坏后置条件相关的状态。
使用Ensures方法创建的后置条件在方法出口时应该为真
[Test]
public void ShippingCostMustBePositive()
{
strategy.CalculateShippingCost(1, ValidSize, null)
.Should().BeGreaterThan(decimal.MinusOne);
}
// . . .
public class ShippingStrategy
{
public decimal CalculateShippingCost(float packageWeightInKilograms, Size<float>
packageDimensionsInInches, RegionInfo destination)
{
Contract.Requires<ArgumentOutOfRangeException>(packageWeightInKilograms > 0f,
"Package weight must be positive and non-zero");
Contract.Requires<ArgumentOutOfRangeException>(packageDimensionsInInches.X > 0f &&
packageDimensionsInInches.Y > 0f,
"Package dimensions must be positive and non-zero");
Contract.Ensures(Contract.Result<decimal>() > 0m);
return decimal.MinusOne;
}
}
这个示例中的谓词看起来与前面示例中的谓词不同,也与常见的判断返回值是否有效的后置条件使用方式不同。
检查运输费用是否大于零(实际上是非负数)需要明白返回值的相关信息。
返回值通常会,但并不总是会,声明和定义在方法内部的局部变量。你可以谨慎地断言返回的值大于零,但是这并不像你想象的那样简单。
为了获取从方法实际返回的值,你需要使用Contract.Result方法。这个泛型方法可以接受方法的返回值类型并返回方法最终退出时刻的任意结果。
通过Contract.Result方法获取契约所在方法的最终返回值,你就能确保在后置条件语句后没有任何代码能在不引起失败异常的情况下将返回值替换为无效值。
3. 数据不变式
通常类中的每个方法都包含自己的前置条件和后置条件,但数据不变式是针对类整体的契约。
代码契约允许你在类内创建一个私有的方法来声明和定义针对类整体的所有数据不变式。
有专用的方法可以用来保护数据不变式
public class ShippingStrategy
{
public ShippingStrategy(decimal flatRate)
{
this.flatRate = flatRate;
}
[ContractInvariantMethod]
private void ClassInvariant()
{
Contract.Invariant(this.flatRate > 0m, "Flat rate must be positive and non-zero");
}
protected decimal flatRate;
}
Contract
类的Invariant
方法和Requires
以及Ensures
方法的使用模式一致,都是接受一个为满足契约而必须为真的布尔谓词。上面的示例中,Invariant
方法还有第二个字符串参数用来描述数据不变式未被保护而导致契约失败的错误信息。
我们鼓励尽可能多地使用Invariant
方法来保护每个数据不变式,最好把不同目的的数据不变式分隔开,也就是说,不要用逻辑AND
运算符&&
把它们串在一起。这样做的好处就是你在失败时能够准确地知道是哪个数据不变式被破坏了。
如果ClassInvariant方法只是一个普通的私有方法,你就需要亲自在每个方法出入口调用该方法以确保正确保护了所有的数据不变式。幸运的是,代码契约给你提供了更好的方式:只需
要使用ContractInvariantMethodAttribute
来标记方法即可。
注意,属性并不需要Attribute
后缀,因此这里把属性名简化为ContractInvariantMethod
。
代码契约会要求类中的其他方法的出入口处必须调用带有这个标记的方法以确保该类的所有数据不变式没有被破坏。
可以应用ContractInvariantMethod
标记的方法必须满足既没有返回值也没有参数的前提条件,当然,你仍然可以决定方法的名称和访问级别,也就是说,可以根据需要为它起名,也可以把它的访问级别设置为公共的或者私有的。
一个类可以有多个ContractInvariantMethod
标记的方法,因此可以在逻辑上将不同目的的数据不变式组织在独立的方法中。
最后要强调的是,这些带有ContractInvariantMethod
标记的方法内必须只包含对Contract.Invariant
方法的调用。
4. 接口契约
由于在类实现上不加限制地应用代码契约而导致代码可读性急剧下降的问题。
实际上,这并不真的只是.NET代码契约特性实现的错,持续应用任何契约实现都会造成同样的问题。
无论用什么方式在代码中实现前置条件、后置条件和数据不变式,有效代码率都会降低。
接口契约能够解决这些问题并提供另外一个很有用的特性。
使用专用类为接口的所有实现定义前置条件、后置条件和数据不变式
[ContractClass(typeof(ShippingStrategyContract))]
interface IShippingStrategy
{
decimal CalculateShippingCost(
float packageWeightInKilograms,
Size<float> packageDimensionsInInches,
RegionInfo destination);
}
//. . .
[ContractClassFor(typeof(IShippingStrategy))]
public abstract class ShippingStrategyContract : IShippingStrategy
{
public decimal CalculateShippingCost(float packageWeightInKilograms, Size<float>
packageDimensionsInInches, RegionInfo destination)
{
Contract.Requires<ArgumentOutOfRangeException>(packageWeightInKilograms > 0f,
"Package weight must be positive and non-zero");
Contract.Requires<ArgumentOutOfRangeException>(packageDimensionsInInches.X > 0f &&
packageDimensionsInInches.Y > 0f,
"Package dimensions must be positive and non-zero");
Contract.Ensures(Contract.Result<decimal>() > 0m);
return decimal.One;
}
[ContractInvariantMethod]
private void ClassInvariant()
{
Contract.Invariant(flatRate > 0m, "Flat rate must be positive and non-zero");
}
}
当然,接口契约要先有应用契约的目标接口。
上面示例中,CalculateShippingCost
方法已经被提取到自己所属的IShippingStrategy
接口中了。该示例是对该接口应用契约,不再只是针对单个类实现应用契约。这一点很重要,它让这个示例与前面所有示例有了质的区别,因为它对所有该接口的实现类都有效。这就是你能通过一些实现和指令增强接口的方式,而增强后的接口能拥有更强大的需求和保障。
在编写接口契约代码时,你还需要一个专门的类来实现接口的方法,其中的方法体只包含对Contract
静态类的Requires
、Ensures
的调用。抽象ShippingStrategyContract
类提供的功能实现看起来与前面示例中的具体类相似,只是方法中缺少了实际的功能实现代码。即使在产品
代码中,契约类中包含的代码也是这样的。与真正的实现类一样,也有一个带有ContractInvariantMethod
标记的方法,其中只包含所有对Contract
静态类的Invariant
方法的调用。
不幸的是,为了关联接口和契约类的实现,你需要通过一个属性完成一次双向引用。这种方式不够好,因为它会对接口的简洁性造成影响,如果能避免会更好。
尽管如此,使用ContractClass
和ContractClassFor
属性分别对接口和契约类进行标记后,你只需要实现一次前置条件、后置条件和数据不变式的防卫代码,而它们对该接口上的所有实现类都是效的。
ContractClass
和ContractClassFor
属性都可以接受模板类,前者用于标记接口并需要传入契约类,而后者用于标记契约类并需要传入接口类。
最后需要特别强调的一点是:无论是手动编码还是使用代码契约实现,如果契约中有任何前置条件、后置条件或者数据不变式的断言失败了,客户端都不应该再捕获到代码异常。
对于客户端而言,捕获异常这个行为本身代表了它还能从捕获的异常状态中恢复,但是契约被破坏后通常都不会也不太可能被恢复。最理想的情况就是通过功能测试发现了所有违背契约的问题,并在交付产品之前修复所有这些发现的问题.
如果在交付产品代码后发现有个破坏契约的问题依然存在且很不幸地被用户发现时,最好的处理方式很可能就是强制关闭应用程序。这时让应用程序以失败的方式退出是恰当的,因为此时应用程序的内部状态很可能是无效且无法恢复的。
对于网页应用,这意味着显示一个全局的错误页面。
对于桌面应用,可以给用户一个友善的提示并能让他们有机会报告出现的问题。所有情况下,都应该使用日志记录发生的异常、当时完整的堆栈跟踪信息以及其他尽可能多的上下文信息。
协变和逆变
通常来说,变体(variance)这个术语主要应用于复杂类层次结构中以定义子类型的期望行为。
定义
1. 协变
下图展示了一个非常小的类层次结构,它只包含了两个根据分类命名的类型:Supertype
和Subtype
。Supertype
定义了被Subtype
继承的字段和方法。Subtype
通过定义自己的字段和方法来增强Supertype
。
在这个类层次结构中,Supertype和Subtype是父子关系 |
---|
多态(polymorphism)是一种子类型被看作超类型实例的能力。
任何能够接受Supertype
类型实例的方法也可以直接接受Subtype
类型实例,客户端或服务代码都不需要做类型转换,服务也不需要知道任何子类型相关的信息。服务代码根本就不关心具体是哪个子类型,它们只知道自己处理的是个Supertype
类型的实例。
由于泛型参数的协变,基类/子类关系被保留下来 |
---|
很有意思的是,这和前面一样也用到了多态这个强大的特性。因为有了协变,当有方法需要ICovariant<Supertype>
的实例时,你完全可以放心地使用ICovariant<Subtype>
的实例替代它。能取得这样的效果要归功于协变和多态的紧密配合。
如下代码清单展示了通用的Entity
基类和具体的User
子类间的类层次结构。所有Entity
类型都有一个GUID
标识符和一个字符串名称,而每个
User
类都有一个EmailAddress
属性和一个DateOfBirth
属性。
在这个小域中,User就是一个特殊的Entity类型
public class Entity
{
public Guid ID { get; private set; }
public string Name { get; private set; }
}
// . . .
public class User : Entity
{
public string EmailAddress { get; private set; }
public DateTime DateOfBirth { get; private set; }
}
这个示例与Supertype/Subtype
示例很类似,但是目的性更强。在这个小小的问题域中需要应用存储库模式。存储库模式会提供一个获取对象的接口,这些对象看起来是在内存中,但是实际上很有可能是从某个完全不同的存储介质中加载进来的.
展示了
EntityRepository
类和它的UserRepository
子类。不使用泛型,C#中的所有继承都是非变体
public class EntityRepository
{
public virtual Entity GetByID(Guid id)
{
return new Entity();
}
}
// . . .
public class UserRepository : EntityRepository
{
public override User GetByID(Guid id)
{
return new User();
}
}
这个示例与前面的有所不同,其中关键的一点是:不使用泛型类型,C#方法的返回类就不是协变的。实际上,在子类中尝试将GetByID
方法的返回类型更改为User
类会直接引起一个编译错误。
error CS0508: 'SubtypeCovariance.UserRepository.GetByID(System.Guid)': return type must be
'SubtypeCovariance.Entity' to match overridden member
'SubtypeCovariance.EntityRepository.GetByID(System.Guid)'
也许你只是靠经验判断这样的更改是无法工作的,但是真正出错的原因则是因为这种情况下的继承是不具备协变能力的。如果C#在继承时支持普通类上的协变,你就能够在UserRepository
类中直接更改方法的返回类型。
可惜C#并没有这种能力,所以你只有两个可用的选项:
- 第一,你可以把
UserRepository
类的GetByID
方法的返回类型修改回Entity
类型,然后在该方法返回的地方应用多态将Entity
类型的实例转换为User
类型的实例。这个方式不够好,因为这要求UserRepository
的客户端必须自己做实例类型转换,或者必须针对User
类型做探测,如果返回的是User类型,就执行某些针对性的代码。 - 第二,你还可以把
EntityRepository
重新定义为一个需要泛型的类型,可以把Entity
类型作为泛型参数传入。这个泛型参数是可以协变的,UserRepository
子类可以为User类型指定超类型。
泛型化的基类可以利用协变能力,从而允许子类重写返回类型
public interface IEntityRepository<TEntity>
where TEntity : Entity
{
TEntity GetByID(Guid id);
}
// . . .
public class UserRepository : IEntityRepository<User>
{
public User GetByID(Guid id)
{
return new User();
}
}
示例代码中并没有继续使用可以实例化的具体EntityRepository
,而是引入了一个没有GetByID
方法默认实现的接口。这里使用接口虽然不是必须的,但仍然是很合理的,因为我们前面一直在强调,让客户端代码依赖接口要比依赖实现好很多。
你也会注意到,接口泛型参数后面还有一个where
子句。应用where
子句的接口比原有实现有了更高的灵活度,因为where
子句可以保证接口的子类总是传入Entity
类型层次结构中的类型。
新的UserRepository
类的客户端无需再做向下的类型转换,因为它们直接得到的就是User
类型对象,而不是Entity
类型对象,同时,EntityRepository
和UserRepository
两个类之间的父子继承关系也得以保留。
2. 逆变
协变只是与方法返回类型的处理相关, 而逆变(contravariance)是与方法参数类型的处理相关。
由于泛型参数的逆变,基类/子类关系被倒置了 |
---|
IContravariant
接口定义的方法只接受由泛型参数指定类型的单个参数。这里的泛型参数由关键字in
标记,表示它是可逆变的。
后续的类层次结构可以以此类推,这表明了继承层次结构已经被颠倒了:IContravariant<Subtype>
成为了超类,IContravariant<Supertype>
则变成了子类,尽管从直觉上看来有些别扭和奇怪,但后面你很快会知道为什么逆变会有这种行为以及为什么它很有用。
IEqualityComparer
接口允许定义诸如EntityEqualityComparer
类这样的功能对象
public interface IEqualityComparer<in TEntity>where TEntity : Entity
{
bool Equals(TEntity left, TEntity right);
}
// . . .
public class EntityEqualityComparer : IEqualityComparer<Entity>
{
public bool Equals(Entity left, Entity right)
{
return left.ID == right.ID;
}
}
逆变颠倒了类层次结构,它允许在需要具体比较器的地方传入更通用的比较器
[Test]
public void UserCanBeComparedWithEntityComparer()
{
SubtypeCovariance.IEqualityComparer<User> entityComparer = new
EntityEqualityComparer();
var user1 = new User();
var user2 = new User();
entityComparer.Equals(user1, user2)
.Should().BeFalse();
}
如果没有逆变(接口定义中泛型参数前不起眼的in关键字),编译时会直接报错。
error CS0266: Cannot implicitly convert type 'SubtypeCovariance.EntityEqualityComparer' to
'SubtypeCovariance.IEqualityComparer<SubtypeCovariance.User>'. An explicit conversion exists
(are you missing a cast?)
错误信息告诉你,这里并没有从EntityEqualityComparer
到IEqualityComparer<User>
的类型转换器,直觉上就是这样的,因为Entity是超类型,而User
是子类型。然而,因为IEqualityComparer
支持逆变,所以现有的继承层次结构被颠倒了,此时你就能够做到通过使
用IEqualityComparer
接口向需要具体类型参数的地方传入更通用的类型。
3. 不变性
除了协变和逆变的行为外,类型本身具有不变性.这里的不变性是指“不会生成变体”。如果一个类型完全不能生成变体,那么就无法在该类型上形成类型层次结果。
有些泛型类型既不可协变也不可逆变,因此它们具有不变性
[TestFixture]
public class DictionaryTests
{
[Test]
public void DictionaryIsInvariant()
{
// Attempt covariance...
IDictionary<Supertype, Supertype> supertypeDictionary = new Dictionary<Subtype,
Subtype>();
// Attempt contravariance...
IDictionary<Subtype, Subtype> subtypeDictionary = new Dictionary<Supertype,
Supertype>();
}
}
DictionaryIsInvariant
测试方法第一行试图在给一个键和值类型都是Supertype
的字典赋值,但所赋字典对象的键和值类型却都是Subtype类型的。因为IDictionary
类型是不可协变的,因此这一句代码不会成功.
第二行代码也是无效的,因为它在尝试做倒置:给一个键和值类型都是Subtype
的字典赋值一个键和值类型都是Supertype
类型的字典。失败的原因是因为IDictionary
类型也是不可逆变的,因此无法倒置Subtype
和Supertype
类型间的继承层次结构。
从IDictionary
类型既不可协变也不可逆变的事实即可知道它必定是个非变体。的确是这样的,下面代码展示了IDictionary
接口的声明方式,你可以看到,定义中并没有对in
和out
关键字的引用,而这二者则分别是用来指定协变和逆变特性的。
IDictionary
接口的所有泛型参数都没有in
或out
关键字标记
public interface IDictionary<TKey, TValue>
: ICollection<KeyValuePair<TKey, TValue>>,
IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable
Liskov类型系统规则
Liskov替换原则定义了以下三个规则,其中前两个都是与变体相关的:
- 子类型的方法参数必须是可逆变的。
- 子类型的返回类型必须是可协变的。
- 不允许引发新异常。
只有方法参数支持逆变而且返回类型支持协变,你才可以编写出严格遵守Liskov替换原则的代码。
不允许引发新异常
这条规则比其他两条Liskov替换原则规则要直观的多,它主要是与编程语言的类型系统相关。你首先要思考的是:什么才是异常的真正目的?
异常机制的主旨就是将错误的汇报和处理环节分隔开。
通常情况下,汇报器和处理器就是目的和应用场景截然不同的两种类型。异常对象表示通过该异常类型发生的错误以及相应的数据。任何代码都可以捕获和响应异常,同样,任何代码也都可以构造和引发异常。
尽管如此,最好还是在代码确定能做一些有意义的处理时才去捕获异常。比如,简单的数据库事务的回滚处理,或是复杂的能让用户看到并反馈详细错误信息给开发人员的绚丽界面的处理。
同样,捕获异常后不做任何处理或只捕获最通用的Exception基类都是不可取的。二者结合的情况就更糟糕了。
对于只捕获最通用的Exception
基类这种情况,你实际上是在尝试捕获和响应任何异常,包括那些你实际上根本无法处理和恢复的情况,比如OutOfMemoryException
、StackOverflowException
或ThreadAbortException
等。如果要想改善这种状况,你需要确保总是从ApplicationException
类派生自己的异常,因为很多无法恢复的异常都是从SystemException
类派生出来的。然而,这要取决于你所依赖的第三方库是否也采用了这种好的实践方式。
展示的两个异常类型在同一个类层次结构下是兄弟关系。这种兄弟关系可以防止只针对单个异常的捕获代码块却能截获两种异常的情况。
两个异常都是Exception类,但是二者并非父子继承关系
public class EntityNotFoundException : Exception
{
public EntityNotFoundException()
: base()
{
}
public EntityNotFoundException(string message)
: base(message)
{
}
}
//. . .
public class UserNotFoundException : Exception
{
public UserNotFoundException()
: base()
{
}
public UserNotFoundException(string message)
: base(message)
{
}
}
如果想要在单个捕获代码块中同时捕获EntityNotFoundExcetpion
和UserNotFoundException
两个异常,你应该去捕获通用的Exception类型,但这并不是值得推荐的做法。
同一接口的两个不同的实现很有可能引发两种不同类型的异常
public Entity GetByID(Guid id)
{
Contract.Requires<EntityNotFoundException>(id != Guid.Empty);
return new Entity();
}
//. . .
public User GetByID(Guid id)
{
Contract.Requires<UserNotFoundException>(id != Guid.Empty);
return new User();
}
这两个类型都使用了代码契约来断言一个前置条件:方法的输入参数id
必须不等于Guid.Empty
。二者都会在不满足契约时引发自有的异常类型。我们一起来细想一下示例实现代码对使用存储库的客户端的影响。
客户端代码需要考虑去捕获这两种异常,但是如果不捕获Exception
类型,就无法在单个捕获代码块中同时捕获这两种异常类型。
因为无法将
UserNotFoundException
赋值给EntityNotFoundException
异常,所以单元测试会失败
[TestFixture(typeof(EntityRepository), typeof(Entity))]
[TestFixture(typeof(UserRepository), typeof(User))]
public class ExceptionRuleTests<TRepository, TEntity>
where TRepository : IEntityRepository<TEntity>, new()
{
[Test]
public void GetByIDThrowsEntityNotFoundException()
{
var repo = new TRepository();
Action getByID = () => repo.GetByID(Guid.Empty);
getByID.ShouldThrow<EntityNotFoundException>();
}
}
因为UserRepository
并不会像要求的那样引发EntityNotFoundException
异常,所以这个单元测试会失败。如果UserNotFoundException
是EntityNotFoundException
的子类,这个测试就可以成功而且单个捕获代码块也能够保证捕获两种类型。
总结
默认情况下,接口并不会向用户传达前置条件和后置条件的规则。通过创建防卫子句可以让应用程序在运行时进一步约束参数的有效值范围。Liskov替换原则提出了一些指导原则,比如,子类不能加强前置条件或削弱后置条件等。
相似地,Liskov替换原则也对子类型的可变性提出了一些规则。子类型的方法参数和返回类型应该分别是可逆变的和可协变的。此外,任何可能随着某个新的接口实现而引入的新异常都应该派生一个已有的基础异常类型。如果不这样做,会很可能导致现有的客户端错过捕获这个新的异常类型而导致程序崩溃。
如果没有遵守Liskov替换原则的这些规则,客户端要处理好类层次结构中所有类之间的关系会变得更难。理想情况下,不论运行时使用的是哪个具体的子类型,客户端都可以只引用一个基类或接口而且无需担心行为变化。这么多问题混合在一起会引起代码之间的依赖,因此最好是把它们分隔开。任何对Liskov替换原则定义的规则的违背都应该被看作技术债务,最好能尽早地处理掉这些技术债务,否则后患无穷。