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

SOLID 原则:里氏替换原则 -> 是什么、为什么以及如何做

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2013年8月9日

CPOL

7分钟阅读

viewsIcon

34699

downloadIcon

185

SOLID 原则:里氏替换原则,一个简单的 C# 示例

引言

本文将解释里氏替换原则,并用一个简单的 C# 示例进行说明。

背景

什么

使用指向基类指针或引用的函数必须能够使用派生类对象,而无需知晓其具体类型。

为什么?

当一个方法或类使用了一个基类,并且在使用了该基类的派生类时必须进行修改,那么这个方法或类就违反了里氏替换原则。抽象消费类的设计者应该能够针对该抽象进行编程,而无需担心某个派生类会“破坏”其代码。

如何

为了确保类设计不违反里氏原则,必须遵循一些规则。第一条规则是基于行为而非“物理”属性来实现继承。接下来一条重要的规则是 Bertrand Meyer 的前置条件和后置条件规则。这两条规则将在接下来的示例代码中进行演示。

里氏原则注意事项

  • 基于行为实现继承
  • 遵守 Bertrand Meyer 的前置条件和后置条件规则

解释

解释:违反里氏原则的矩形 - 正方形示例

基于行为实现继承

以下是一个关于非基于行为的继承如何违反里氏原则的著名示例。

Rectangle - Square 示例。从物理角度看,square 是一个特殊的 rectangle,但其行为与 rectangle 不一致。Rectangle 类包含两个简单的属性:WidthHeight。当我改变 width 属性的值时,height 属性会保持其原始值,反之亦然。两者都可以独立设置。这听起来很简单,但在涉及 square 的行为时,这是一个重要的行为方面,我们稍后会看到。

public class Rectangle
{
    protected int _width;
    protected int _height;
 
    public int Width
    {
        get { return _width; }
    }
    public int Height
    {
        get { return _height; }
    }
 
    public virtual void SetWidth(int width)
    {
        _width = width;
    }
 
    public virtual void SetHeight(int height)
    {
        _height = height;
    }
 
    public virtual int CalculateArea()
    {
        return _height*_width;
    }
}

现在,如果我想创建一个 Square 类,最直接的方法是让它成为 Rectangle 的派生类,因为从物理属性来看,Square 是一个 rectangle,两者都有 widthheight。为了让 Square 真正成为一个 square,我必须确保 widthheight 始终相等。Square 类通过覆盖 SetWidthSetHeight 方法并提供自己的版本来确保 widthheight 始终相等,从而遵循 square 的物理特性。现在,重要的是要认识到我们改变了 Square 的行为,它不再与 Rectangle 的行为一致。当 Width 属性的值改变时,Height 属性的值也会随之改变,反之亦然。换句话说,rectangleHeightWidth 不能独立设置,这是一个重要的行为改变,正如我们在下一个代码示例中看到的,这违反了里氏原则。

public class Square : Rectangle
{
 public override void SetWidth(int width)
 {
    _width = width;
    _height = width;
 }
 public override void SetHeight(int height)
 {
    _width = height;
    _height = height;
 }
}

当以下客户端使用 Square 时,它期望面积也是 width x height,但这并非事实。客户端在不更改客户端代码的情况下,无法使用 square 并让断言成立。这是因为其行为与 rectangle 不一致,而软件的本质就是行为!

public void Test_ClientForRectangle(Rectangle rectangle)
{
  int width = 4;
  int height = 5;
  rectangle.SetWidth(width);
  rectangle.SetHeight(height);
  int area = rectangle.CalculateArea();
  //Works for a rectangle but does not for a Square, so violates the Liskov Principle
  Assert.AreEqual<int>(width * height, area);
}

执行测试以显示 Rectangle 实例无法被 Square 实例替换

Rectangle rectangle = new Rectangle(); 
Test_ClientForRectangle(rectangle); //Ok 

Square square = new Square();
Test_ClientForRectangle(square);    //Not Ok

Rectangle - Square 示例说明了以错误的方式使用继承时可能出现的问题。解决方案是始终基于行为应用继承。下一个示例基于 Calculator 类,它将演示一些其他的里氏规则以及符合里氏原则和不符合里氏原则的派生类之间的区别。

遵守 Bertrand Meyer 的前置条件和后置条件规则

防止设计违反里氏原则的下一个规则是前置条件和后置条件规则。为了确保派生类的行为继承自基类,派生类必须遵守 Bertrand Meyer 的前置条件和后置条件规则。根据 Bertrand Meyer 的说法,重要的是,在派生类中重新定义例程/方法时,只能用更弱的前置条件替换其前置条件,并用更强的后置条件替换其后置条件。

操作的前置条件是在调用操作之前必须为真的断言。前置条件说明了调用操作是否合理。操作的后置条件是在操作完成后必须为真的断言。

因此,里氏原则

  • 派生类:前置条件更弱
  • 派生类:后置条件更强

派生类不能加强前置条件(你不能要求比父类更多)。派生类不能削弱后置条件(你不能保证比父类少)。

rectangle-square 的示例中,这些条件可以表述为:

rectangle 的前置条件

  • Heightwidth 必须为正数
  • Heightwidth 不同

square 的前置条件

  • Heightwidth 必须为正数
  • 必须有相同的 heightwidth(更强的前置条件)

rectangle 的后置条件

  • 在设置矩形 width 时,height 必须保持与初始 height 相同

square 的后置条件

  • 在设置 square 的宽度时,height 不一定与初始 height 相同(更弱的后置条件)

这说明 square 的面积计算的前置条件实际上比 rectangle 的面积计算的前置条件更强,而后置条件却更弱,这就是为什么它违反了里氏原则。

里氏计算器示例

另一个解释前置条件和后置条件的示例是基于一个简单的 BaseCalculator。它是一个类,有一个带有某些前置条件和后置条件的 calculation 方法。BaseCalculator 类有两个派生类:LiskovCalculator(符合里氏原则)和 NonLiskovCalculator(违反里氏原则)。LiskovCalculatorBaseCalculator 具有更弱的前置条件和更强的后置条件。NonLiskovCalculatorBaseCalculator 具有更强的前置条件和更弱的后置条件。

BaseCalculator

/// <summary>
/// Precondition : input > 1 && input < 20 
/// Postcondition result = input - 10 stronger
/// Postcondition : ShowResultOnDisplay = true
/// </summary>
public class BaseCalculator
{
    public int result;
    public bool ShowResultOnDisplay;
    public bool ShowResulOnPrintOut;

    public virtual void DoSomeCalculation(int input)
    {
        if (input > 0 && input < 20)
        {
            result = input - 10;
            ShowResultOnDisplay = true;
        }
        else
        {
            throw (new ArgumentException("Preconditions are not met"));
        }
    }
}

LiskovCalculator:遵守里氏原则

/// <summary>
/// Weaker preconditions
/// Stronger postconditions 
/// 
/// Precondition input > 0
///  
/// Postcondition result = input - 10
/// Postcondition : ShowResultOnDisplay = true
/// Postcondition : ShowResulOnPrintOut = true
//  
/// </summary>
public class LiskovCalculator : BaseCalculator
{
    public override void DoSomeCalculation(int input)
    {
        if (input > 0)
        {
            result = input - 10;
            ShowResultOnDisplay = true;
            ShowResulOnPrintOut = true;
        }
        else
        {
            throw (new ArgumentException("Preconditions are not met"));
        }
    }
}

NonLiskovCalculator:违反里氏原则。

/// <summary>
/// Stronger preconditions
/// Weaker postconditions 
/// 
/// Precondition input > 0 && input < 5
/// Postcondition result = input - 10
/// </summary>
public class NonLiskovCalculator : BaseCalculator
{
    public override void DoSomeCalculation(int input)
    {
        if (input > 1 && input < 5)
        {
            result = input - 10;
        }
        else
        {
            throw (new ArgumentException("Preconditions are not met"));
        }
    }
}

为了测试前置条件和后置条件,我添加了单元测试来测试使用 BaseCalculatorLiskovCalculatorNonLiskovCalculator 的不同客户端。BaseClient 可以毫无问题地使用 BaseCalculatorLiskovCalculator,但在使用 NonLiskovCalculator 时会出现问题。立即可以看到问题发生的原因是 BaseCalculator 的前置条件被期望比 NonLiskovCalculator 的前置条件更弱,因此 BaseCalculator 的输入本身就违反了 NonLiskovCalculator 的输入。客户端期望 BaseCalculator 的后置条件。当返回 LiskovCalculator 的更强的后置条件时,检查有效;但当返回 NonLiskovCalculator 的更弱的后置条件时,检查无效。

计算器类的​​前置条件和后置条件

BaseCalculator.DoSomeCalculation 的前置条件

  • 0 < Input < 20

LiskovCalculator.DoSomeCalculation 的前置条件

  • Input > 0
    (最弱的前置条件)

NonLiskovCalculator.DoSomeCalculation 的前置条件

  • 1 < Input < 5
    (最强的​​前置条件)

BaseCalculator.DoSomeCalculation 的后置条件

  • Result = Input – 10
  • ShowResultOnDisplay = True

SpecialCalculator.DoSomeCalculation 的后置条件

  • Result = Input – 10;
  • ShowResultOnDisplay = True
  • ShowResultOnPrintOut = True
    (最强的​​后置条件)

NonSpecialCalculator.DoSomeCalculation 的后置条件

  • Result = Input – 10;
    (最弱的​​后置条件)

Liskov illustration

当你研究附带的解决方案时,一切都会变得更清楚。当你运行所有单元测试时,它们将显示使用 NonLiskov Calculator 的测试将失败,而使用 Liskov Calculator 的测试将成功。

Base client,可以处理 BaseCalculatorBaseCalculator 的所有派生类。

public void Test_BaseClient(BaseCalculator calculator)
{
    int input = 15;
    //When this client consumes a derived class that conforms Liskov,
    //a stronger PreCondition in the Client is compared with
    //a Weaker PreCondition in the derived class

    //PreCondition Check
    Assert.IsTrue(input > 0);
    Assert.IsTrue(input < 20);

    calculator.DoSomeCalculation(input);

    //When this client consumes a derived class that conforms Liskov,
    //a stronger PostCondition in the derived class method is compared
    //with a Weaker PostCondition in the Client

    //PostCondition Check
    Assert.AreEqual<int />(input - 10, calculator.result);
    Assert.IsTrue(calculator.ShowResultOnDisplay);
}

LiskovCalculator client,只能处理 LiskovCalculator

public void Test_LiskovClient(LiskovCalculator calculator)
{
    int input = 100;
    //PreCondition Check
    Assert.IsTrue(input > 0);
    calculator.DoSomeCalculation(input);

    //PostCondition Check
    Assert.AreEqual<int />(input - 10, calculator.result);
    Assert.IsTrue(calculator.ShowResultOnDisplay);
    Assert.IsTrue(calculator.ShowResulOnPrintOut);
}

NonLiskovCalculator client,只能处理 NonLiskovCalculator

public void Test_NonLiskovClient(NonLiskovCalculator calculator)
{
    int input = 2;
    //PreCondition Check
    Assert.IsTrue(input > 1);
    Assert.IsTrue(input < 5);
    calculator.DoSomeCalculation(input);

    //PostCondition Check
    Assert.AreEqual<int />(input - 10, calculator.result);
}

更多规则

逆变和协变规则

除了上述确认符合里氏原则的规则外,还有一些其他的规则:逆变规则(从更派生的到泛化的):派生类中定义的方法的参数必须相等或为派生程度更低(更泛化)的类型。这可以看作是更弱的前置条件。当客户端调用 BaseCalculcatorDoSomeCalculation 方法时,它也应该能够调用 LiskovCalculatorDoSomeCalculation 方法,而无需更改输入参数。

可以赋给 int 的值也可以赋给 object

//BaseCalculator
DoSomeCalculation(int input)
//LiskovCalculator
DoSomeCalculation(object input)

协变规则(从泛化的到更派生的):派生类中定义的方法的返回类型必须相等或为派生程度更高(更窄)的类型。这可以看作是更强的后置条件。当客户端使用 BaseCalculcatorDoSomeCalculation 方法的返回类型时,它也应该能够使用 LiskovCalculatorDoSomeCalculation 方法的返回类型。

如果我能处理 int,我也能处理 short

//BaseCalculator
DoSomeCalculation(int input)
{
    return output as int;
}
//LiskovCalculator
DoSomeCalculation(int input)
{
    return output as short;
}

历史约束规则

最后一条规则是历史约束规则。简单来说,当一个 BaseType 对状态有限制时,Derived 类型不能改变这种状态限制。例如,当一个 BaseType 有一个只能设置一次的 readonly 属性(例如,清除显示)时,Derived 类型不得引入一个为该属性分配新值的方法。

BaseCalculator
protected string _display;
public string Display
{
    get { return _display; }
}
//Constructor
BaseCalculator()
{
    _display = string.empty;
}
 
NonLiskovCalculator
SetDisplay()
{
    _display = "9999";
}

当你的编程符合本文提到的规则时,你就符合里氏兼容。

玩得开心!

© . All rights reserved.