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

违反 Liskov 替换原则 (LSP)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2013 年 9 月 6 日

CPOL

6分钟阅读

viewsIcon

53946

如何在使用继承时避免对自己造成伤害。

引言

本文讨论违反 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 属性。然而请注意,MyArrayCount 是不可变的,因为数组有固定数量的元素。但这是有效的。子类型可以提供更多保证。

现在,我们决定集合应该允许添加新元素

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 的参数)。因此,特定的整数集合也应该从只包含 CountMyCollectionBase 继承。

修改后,我们得到:

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>,因此可以用于需要 MyAlmostPositiveIntsMyOverlappingInts 实例的地方。

MyNegativeSafeIntsBase 定义的安全范围是 <-10;5>,因此可以用于需要 MyAlmostNegativeIntsMyOverlappingInts 实例的地方。

请注意,理论上我们也可以从 MyPositiveSafeIntsBaseMyNegativeSafeIntsBase 派生 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 替换原则,如何通过不恰当的继承层次结构来违反它,并展示了可能的解决方案。

© . All rights reserved.