违反 Liskov 替换原则 (LSP)





5.00/5 (7投票s)
如何在使用继承时避免对自己造成伤害。
引言
本文讨论违反 Liskov 替换原则 (LSP) 的情况。
旨在展示
- 违反Liskov 替换原则的示例
- 出现的潜在问题
- 提出的解决方案
背景
Liskov 替换原则与子类型关系
简单来说:Liskov 替换原则体现在,如果我们有一个装液体的瓶子,我们可以往里面倒水、牛奶、可乐或酸,而不会期望瓶子会爆炸。
Liskov 替换原则的正式定义是:
如果 S 是 T 的子类型,那么类型 T 的对象可以被类型 S 的对象替换,而不会改变程序的任何期望的属性。
子类型关系的定义本身听起来非常相似:
如果 S 是 T 的子类型,那么任何类型为 S 的项都可以在需要类型为 T 的项的上下文中安全使用。
这两者之间的关键区别在于“期望”和“安全”这两个词。Liskov 替换原则应该比子类型关系更具限制性。所以,基本上这都是关于类型安全,以及从弱类型到强类型的整个光谱,而LSP是非常严格的。
Liskov 替换原则与类型安全
类型安全就像机场的安检。安检人员不会放行一个有潜在危险的人。这并不意味着这个人真的想破坏什么,而是考虑到规则,存在相当大的风险。在强类型安全和Liskov 替换原则的情况下,规则非常严格。
计算机语言在强制类型安全方面有所不同。实践中:类型越强,代码编写时编译错误越多。类型越弱,程序运行时异常越多。
在某些情况下,程序员可以通过遵循编程原则来增强其应用程序的类型安全,超出计算机语言的语法规则。
在 OOP 中违反Liskov 替换原则
C# 支持 面向对象编程风格。而 OOP 又支持继承和多态。这意味着我们可以编译以下代码行:
class MyCollection
{
public int Count { get; set; }
}
class MyArray : MyCollection
{
}
class Program
{
static void Main()
{
MyCollection collection = new MyArray();
}
}
将 MyCollection
用作 MyArray
的替代看起来是可取的,因为它们都有一个 Count
属性。然而请注意,MyArray
的 Count
是不可变的,因为数组有固定数量的元素。但这是有效的。子类型可以提供更多保证。
现在,我们决定集合应该允许添加新元素
class MyCollection
{
public int Count { get; private set; }
public virtual void AddItem(int item) { /*...*/ }
}
class MyArray : MyCollection
{
public override void AddItem(int item)
{
throw new System.NotSupportedException();
}
}
class Program
{
static void Main()
{
MyCollection collection = new MyArray();
collection.AddItem(0); // <-- Bum!
}
}
我们就是这样违反了Liskov 替换原则。代码仍然可以编译,但因为我们可以将 `MyArray` 向下转换为 `MyCollection`,所以我们也能够向固定大小的数组添加新项,这是不正确的。所以,这就是违反Liskov 替换原则时出现的问题。在这种情况下,我们必须抛出运行时异常。
一个有趣的事实是,.NET 的 Array
在继承自 ICollection<T>
时也违反了LSP。
class Program
{
static void Main()
{
ICollection<int> collection = new int[] {0};
collection.Add(1); // <-- Throws NotSupportedException
}
}
Add
方法不是 Array
的显式成员,因为它是在接口级别实现的。但我们总是能够将 Array
转换为 ICollection<T>
,并通过底层接口级别操作其实例。
我们也可以通过在派生类中限制方法参数来以不同的方式违反Liskov 替换原则。下面是一个例子。假设还有其他类型:
MyAlmostPositiveInts
- 只允许添加大于或等于 -5 的整数;MyAlmostNegativeInts
- 只允许添加小于或等于 5 的整数;MyOvelappingInts
- 只允许添加包含在 -10 和 10 之间的整数(包括两端);
class MyCollection
{
public int Count { get; private set; }
public virtual void AddItem(int item) { /*...*/ }
}
class MyArray : MyCollection
{
public override void AddItem(int item)
{
throw new System.NotSupportedException();
}
}
class MyAlmostPositiveInts : MyCollection
{
public override void AddItem(int item)
{
if (item < -5)
throw new System.ArgumentException();
}
}
class MyAlmostNegativeInts : MyCollection
{
public override void AddItem(int item)
{
if (item > 5)
throw new System.ArgumentException();
}
}
class MyOvelappingInts : MyCollection
{
public override void AddItem(int item)
{
if (item < -10 || item > 10)
throw new System.ArgumentException();
}
}
总而言之:如果你需要在重写的方法中添加一些限制,而这些限制在基线实现中不存在,你可能就违反了Liskov 替换原则。
解决方案
自上一章以来,我们已经能够编写以下代码:
class Program
{
static void Main()
{
MyCollection collection = new MyCollection();
MyCollection array = new MyArray();
MyCollection almostPositiveInts = new MyAlmostPositiveInts();
MyCollection almostNegativeInts = new MyAlmostNegativeInts();
MyCollection overlappingInts = new MyOvelappingInts();
collection.AddItem(-1); // <-- OK
collection.AddItem(0); // <-- OK
collection.AddItem(1); // <-- OK
array.AddItem(-1); // <-- NotSupportedException
array.AddItem(0); // <-- NotSupportedException
array.AddItem(1); // <-- NotSupportedException
almostPositiveInts.AddItem(-15); // <-- ArgumentException
almostPositiveInts.AddItem(-5); // <-- OK
almostPositiveInts.AddItem(0); // <-- OK
almostPositiveInts.AddItem(5); // <-- OK
almostPositiveInts.AddItem(15); // <-- OK
almostNegativeInts.AddItem(-15); // <-- OK
almostNegativeInts.AddItem(-5); // <-- OK
almostNegativeInts.AddItem(0); // <-- OK
almostNegativeInts.AddItem(5); // <-- OK
almostNegativeInts.AddItem(15); // <-- ArgumentException
overlappingInts.AddItem(-15); // <-- ArgumentException
overlappingInts.AddItem(-5); // <-- OK
overlappingInts.AddItem(0); // <-- OK
overlappingInts.AddItem(5); // <-- OK
overlappingInts.AddItem(15); // <-- ArgumentException
}
}
解决方案应该用编译时错误或至少警告来替换运行时异常。
对于 MyArray
,我们可以简单地提取一个包含 Count
属性的基类:
class MyCollectionBase
{
public int Count { get; private set; }
}
class MyCollection : MyCollectionBase
{
public virtual void AddItem(int item) { /*...*/ }
}
class MyArray : MyCollectionBase
{
}
class Program
{
static void Main()
{
MyCollection collection = new MyCollection();
MyCollectionBase array = new MyArray();
collection.AddItem(-1); // <-- OK
collection.AddItem(0); // <-- OK
collection.AddItem(1); // <-- OK
array.AddItem(-1); // <-- Compiler error
array.AddItem(0); // <-- Compiler error
array.AddItem(1); // <-- Compiler error
}
}
对于特定的整数集合,第一步是相同的。我们必须在基类级别禁止添加新项,因为基类没有为用户提供关于所添加整数范围的任何限制。因此,如果用户收到一个类型为 MyCollection
的引用,他们会假设可以插入任何整数,而目前这并不总是真的(例如,MyPositiveInts
可能被赋值给一个类型为 MyCollection
的参数)。因此,特定的整数集合也应该从只包含 Count
的 MyCollectionBase
继承。
修改后,我们得到:
class MyCollectionBase
{
public int Count { get; private set; }
}
class MyCollection : MyCollectionBase
{
public void AddItem(int item) { /*...*/ }
}
class MyArray : MyCollectionBase
{
}
class MyAlmostPositiveInts : MyCollectionBase
{
public void AddItem(int item)
{
if (item < 5)
throw new System.ArgumentException();
}
}
class MyAlmostNegativeInts : MyCollectionBase
{
public void AddItem(int item)
{
if (item > -5)
throw new System.ArgumentException();
}
}
class MyOvelappingInts : MyCollectionBase
{
public void AddItem(int item)
{
if (item < -10 || item > 10)
throw new System.ArgumentException();
}
}
class Program
{
static void Main()
{
MyCollection collection = new MyCollection();
MyCollectionBase array = new MyArray();
MyCollectionBase almostPositiveInts = new MyAlmostPositiveInts();
MyCollectionBase almostNegativeInts = new MyAlmostNegativeInts();
MyCollectionBase overlappingInts = new MyOvelappingInts();
collection.AddItem(-1); // <-- OK
collection.AddItem(0); // <-- OK
collection.AddItem(1); // <-- OK
array.AddItem(-1); // <-- Compiler error
array.AddItem(0); // <-- Compiler error
array.AddItem(1); // <-- Compiler error
almostPositiveInts.AddItem(-15); // <-- Compiler error
almostPositiveInts.AddItem(-5); // <-- Compiler error
almostPositiveInts.AddItem(0); // <-- Compiler error
almostPositiveInts.AddItem(5); // <-- Compiler error
almostPositiveInts.AddItem(15); // <-- Compiler error
almostNegativeInts.AddItem(-15); // <-- Compiler error
almostNegativeInts.AddItem(-5); // <-- Compiler error
almostNegativeInts.AddItem(0); // <-- Compiler error
almostNegativeInts.AddItem(5); // <-- Compiler error
almostNegativeInts.AddItem(15); // <-- Compiler error
overlappingInts.AddItem(-15); // <-- Compiler error
overlappingInts.AddItem(-5); // <-- Compiler error
overlappingInts.AddItem(0); // <-- Compiler error
overlappingInts.AddItem(5); // <-- Compiler error
overlappingInts.AddItem(15); // <-- Compiler error
}
}
从那时起,如果我们使用 MyCollectionBase
引用,我们就必须将其转换为特定的子类型才能添加新项。因此,我们 just can't violate Liskov 替换原则。
class Program
{
static void Main()
{
MyCollectionBase collectionBaseAlmostPositiveInts = new MyAlmostPositiveInts();
MyCollectionBase collectionBaseAlmostNegativeInts = new MyAlmostNegativeInts();
MyCollectionBase collectionBaseOvelappingInts = new MyOvelappingInts();
MyAlmostPositiveInts almostPositiveInts =
(MyAlmostPositiveInts)collectionBaseAlmostPositiveInts;
almostPositiveInts.AddItem(-15); // <-- ArgumentException
almostPositiveInts.AddItem(-5); // <-- OK
almostPositiveInts.AddItem(0); // <-- OK
almostPositiveInts.AddItem(5); // <-- OK
almostPositiveInts.AddItem(15); // <-- OK
MyAlmostNegativeInts almostNegativeInts =
(MyAlmostNegativeInts)collectionBaseAlmostNegativeInts;
almostNegativeInts.AddItem(-15); // <-- OK
almostNegativeInts.AddItem(-5); // <-- OK
almostNegativeInts.AddItem(0); // <-- OK
almostNegativeInts.AddItem(5); // <-- OK
almostNegativeInts.AddItem(15); // <-- ArgumentException
MyOverlappingInts overlappingInts =
(MyOverlappingInts)collectionBaseOverlappingInts;
overlappingInts.AddItem(-15); // <-- ArgumentException
overlappingInts.AddItem(-5); // <-- OK
overlappingInts.AddItem(0); // <-- OK
overlappingInts.AddItem(5); // <-- OK
overlappingInts.AddItem(15); // <-- ArgumentException
}
}
如果我们考虑当前的情况,我们会发现每次想添加一个项时,我们都必须检查 MyCollectionBase
的实际类型。实际上,这将非常不方便。仍然存在运行时 ArgumentException
。然而,它们不是违反Liskov 替换原则的结果。它们之所以存在,是因为编译器不知道如何读取我们 Add
方法中的 if 语句,而不是因为我们在派生类中加强了限制。无论如何,我们稍后会处理这个问题。
如何在不显式强制转换的情况下以安全的方式访问 Add
方法?要做到这一点,整数集合必须从同一个基类继承或实现包含 Add
的同一个接口。不幸的是,由于违反了Liskov 替换原则,这已经发生且不安全。不过,我们仍然可以在一定程度上提取公共基类。请注意,整数集合有交集。我们可以利用这一点。
class MyCollectionBase
{
public int Count { get; private set; }
}
class MySafeIntsBase : MyCollectionBase
{
public virtual void AddItem(int item)
{
if (item < -5 || item > 5)
throw new System.ArgumentException();
}
}
class MyPositiveIntsSafeBase : MySafeIntsBase
{
public override void AddItem(int item)
{
if (item < -5 || item > 10)
throw new System.ArgumentException();
}
}
class MyNegativeIntsSafeBase : MySafeIntsBase
{
public override void AddItem(int item)
{
if (item < -10 || item > 5)
throw new System.ArgumentException();
}
}
class MyAlmostPositiveInts : MyPositiveIntsSafeBase
{
public override void AddItem(int item)
{
if (item < -5)
throw new System.ArgumentException();
}
}
class MyAlmostNegativeInts : MyNegativeIntsSafeBase
{
public override void AddItem(int item)
{
if (item > 5)
throw new System.ArgumentException();
}
}
class MyOvelappingInts : MySafeIntsBase
{
public override void AddItem(int item)
{
if (item < -10 || item > 10)
throw new System.ArgumentException();
}
}
继承的每个级别都会放宽关于所添加项的限制。这是允许的。我们只能不能收紧规则。遗憾的是,编译器不会遵循我们的指导方针,我们仍然可以向 MySafeIntsBase
集合插入任何整数。好的一点是,我们的继承层次结构和类名表明了我们的意图。
MySafeIntsBase
定义的安全范围是 <-5;5>,因此可以用于需要 MyAlmostPositiveInts
(<-5; +∞)、MyAlmostNegativeInts
(<-∞; 5) 或 MyOverlappingInts
(<-10;10>) 实例的地方。这将为开发人员提供一个线索,表明他应该只在那里插入对所有这些类型都有效的整数。
MyPositiveSafeIntsBase
定义的安全范围是 <-5;10>,因此可以用于需要 MyAlmostPositiveInts
或 MyOverlappingInts
实例的地方。
MyNegativeSafeIntsBase
定义的安全范围是 <-10;5>,因此可以用于需要 MyAlmostNegativeInts
或 MyOverlappingInts
实例的地方。
请注意,理论上我们也可以从 MyPositiveSafeIntsBase
和 MyNegativeSafeIntsBase
派生 MyOverlappingInts,因为集合 <-10;5> 和 <-5;10> 的并集是 <-10;10>。我们可以从更具限制性的类型构建不那么具限制性的类型。但 C# 不允许多重继承。我们可以通过利用接口来实现目标,但我将在下一节关于代码契约的讨论中结合这一步。
代码契约
自 .NET Framework 4.0 起,增加了一个名为 代码契约 的编程安全支持。它们特别扩展了编译器的静态分析能力。在我们的例子中,如果我们尝试将无效的整数值放入 MySafeIntsBase
集合,它们能够显示编译警告。我不会解释如何配置和使用代码契约,但下面是一个高度安全的解决方案,几乎消除了违反Liskov 替换原则的可能性(几乎,因为代码契约会生成编译警告,而不是错误)。
#region IMySafeIntsBase contract binding
[ContractClass(typeof(IMySafeIntsBaseContract))]
public partial interface IMySafeIntsBase
{
void AddItem(int item);
}
[ContractClassFor(typeof(IMySafeIntsBase))]
abstract class IMySafeIntsBaseContract : IMySafeIntsBase
{
public void AddItem(int item)
{
Contract.Requires<ArgumentException>(
item >= -5 && item <= 5,
"Value should be between <-5;5>.");
}
}
#endregion
#region IMySafeIntsBase contract binding
[ContractClass(typeof(IMyPositiveIntsSafeBaseContract))]
public partial interface IMyPositiveIntsSafeBase
{
void AddItem(int item);
}
[ContractClassFor(typeof(IMyPositiveIntsSafeBase))]
abstract class IMyPositiveIntsSafeBaseContract : IMyPositiveIntsSafeBase
{
public void AddItem(int item)
{
Contract.Requires<ArgumentException>(
item >= -5 && item <= 10,
"Value should be between <-5;10>.");
}
}
#endregion
#region IMyNegativeIntsSafeBase contract binding
[ContractClass(typeof(IMyNegativeIntsSafeBaseContract))]
public partial interface IMyNegativeIntsSafeBase
{
void AddItem(int item);
}
[ContractClassFor(typeof(IMyNegativeIntsSafeBase))]
abstract class IMyNegativeIntsSafeBaseContract : IMyNegativeIntsSafeBase
{
public void AddItem(int item)
{
Contract.Requires<ArgumentException>(
item >= -10 && item <= 5,
"Value should be between <-10;5>.");
}
}
#endregion
#region IMyAlmostPositiveInts contract binding
[ContractClass(typeof(IMyAlmostPositiveIntsContract))]
public partial interface IMyAlmostPositiveInts
{
void AddItem(int item);
}
[ContractClassFor(typeof(IMyAlmostPositiveInts))]
abstract class IMyAlmostPositiveIntsContract : IMyAlmostPositiveInts
{
public void AddItem(int item)
{
Contract.Requires<ArgumentException>(
item >= -5,
"Value should be greater than or equal -5.");
}
}
#endregion
#region IMyAlmostNegativeInts contract binding
[ContractClass(typeof(IMyAlmostNegativeIntsContract))]
public partial interface IMyAlmostNegativeInts
{
void AddItem(int item);
}
[ContractClassFor(typeof(IMyAlmostNegativeInts))]
abstract class IMyAlmostNegativeIntsContract : IMyAlmostNegativeInts
{
public void AddItem(int item)
{
Contract.Requires<ArgumentException>(
item <= 5,
"Value should be less than or equal 5.");
}
}
#endregion
#region IMyOvelappingInts contract binding
[ContractClass(typeof(IMyOvelappingIntsContract))]
public partial interface IMyOvelappingInts
{
void AddItem(int item);
}
[ContractClassFor(typeof(IMyOvelappingInts))]
abstract class IMyOvelappingIntsContract : IMyOvelappingInts
{
public void AddItem(int item)
{
Contract.Requires<ArgumentException>(
item >= -10 && item <= 10,
"Value should be between <-10;10>.");
}
}
#endregion
class MyCollectionBase
{
public int Count { get; private set; }
}
class MyAlmostPositiveInts : MyCollectionBase, IMyAlmostPositiveInts, IMyPositiveIntsSafeBase, IMySafeIntsBase
{
private void AddItem(int item) { }
void IMyAlmostPositiveInts.AddItem(int item) { AddItem(item); }
void IMyPositiveIntsSafeBase.AddItem(int item) { AddItem(item); }
void IMySafeIntsBase.AddItem(int item) { AddItem(item); }
}
class MyAlmostNegativeInts : MyCollectionBase, IMyAlmostNegativeInts,
IMyNegativeIntsSafeBase, IMySafeIntsBase
{
private void AddItem(int item) { }
void IMyAlmostNegativeInts.AddItem(int item) { AddItem(item); }
void IMyNegativeIntsSafeBase.AddItem(int item) { AddItem(item); }
void IMySafeIntsBase.AddItem(int item) { AddItem(item); }
}
class MyOvelappingInts : MyCollectionBase, IMyOvelappingInts,
IMyPositiveIntsSafeBase, IMyNegativeIntsSafeBase, IMySafeIntsBase
{
private void AddItem(int item) { }
void IMyOvelappingInts.AddItem(int item) { AddItem(item); }
void IMyPositiveIntsSafeBase.AddItem(int item) { AddItem(item); }
void IMyNegativeIntsSafeBase.AddItem(int item) { AddItem(item); }
void IMySafeIntsBase.AddItem(int item) { AddItem(item); }
}
class Program
{
static void Main()
{
IMySafeIntsBase safe = new MyOvelappingInts();
safe.AddItem(-15); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 5 (Value should be between <-5;5>.)
safe.AddItem(-10); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 5 (Value should be between <-5;5>.)
safe.AddItem(-5); // <-- OK
safe.AddItem(0); // <-- OK
safe.AddItem(5); // <-- OK
safe.AddItem(10); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 5 (Value should be between <-5;5>.)
safe.AddItem(15); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 5 (Value should be between <-5;5>.)
IMyPositiveIntsSafeBase positiveSafe = (IMyPositiveIntsSafeBase)safe;
positiveSafe.AddItem(-15); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 10
// (Value should be between <-5;10>.)
positiveSafe.AddItem(-10); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 10
// (Value should be between <-5;10>.)
positiveSafe.AddItem(-5); // <-- OK
positiveSafe.AddItem(0); // <-- OK
positiveSafe.AddItem(5); // <-- OK
positiveSafe.AddItem(10); // <-- OK
positiveSafe.AddItem(15); // <-- Warning, CodeContracts: requires is false:
// item >= -5 && item <= 10
// (Value should be between <-5;10>.)
IMyNegativeIntsSafeBase negativeSafe = (IMyNegativeIntsSafeBase)safe;
negativeSafe.AddItem(-15); // <-- Warning, CodeContracts: requires is false:
// item >= -10 && item <= 5
// (Value should be between <-10;5>.)
negativeSafe.AddItem(-10); // <-- OK
negativeSafe.AddItem(-5); // <-- OK
negativeSafe.AddItem(0); // <-- OK
negativeSafe.AddItem(5); // <-- OK
negativeSafe.AddItem(10); // <-- Warning, CodeContracts: requires is false:
// item >= -10 && item <= 5
// (Value should be between <-10;5>.)
negativeSafe.AddItem(15); // <-- Warning, CodeContracts: requires is false:
// item >= -10 && item <= 5
// (Value should be between <-10;5>.)
IMyOvelappingInts overlaping = (IMyOvelappingInts)safe;
overlaping.AddItem(-15); // <-- Warning, CodeContracts: requires is false:
// item >= -10 && item <= 10
// (Value should be between <-10;10>.)
overlaping.AddItem(-10); // <-- OK
overlaping.AddItem(-5); // <-- OK
overlaping.AddItem(0); // <-- OK
overlaping.AddItem(5); // <-- OK
overlaping.AddItem(10); // <-- OK
overlaping.AddItem(15); // <-- Warning, CodeContracts: requires is false:
// item >= -10 && item <= 10
// (Value should be between <-10;10>.)
}
}
摘要
编写健壮的代码总是需要大量的努力。项目越大,越应该应用类型安全,以便于维护。在本文中,我试图解释什么是Liskov 替换原则,如何通过不恰当的继承层次结构来违反它,并展示了可能的解决方案。