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





5.00/5 (14投票s)
SOLID 原则:里氏替换原则,一个简单的 C# 示例
引言
本文将解释里氏替换原则,并用一个简单的 C# 示例进行说明。
背景
什么
使用指向基类指针或引用的函数必须能够使用派生类对象,而无需知晓其具体类型。
为什么?
当一个方法或类使用了一个基类,并且在使用了该基类的派生类时必须进行修改,那么这个方法或类就违反了里氏替换原则。抽象消费类的设计者应该能够针对该抽象进行编程,而无需担心某个派生类会“破坏”其代码。
如何
为了确保类设计不违反里氏原则,必须遵循一些规则。第一条规则是基于行为而非“物理”属性来实现继承。接下来一条重要的规则是 Bertrand Meyer 的前置条件和后置条件规则。这两条规则将在接下来的示例代码中进行演示。
里氏原则注意事项
- 基于行为实现继承
- 遵守 Bertrand Meyer 的前置条件和后置条件规则
解释
解释:违反里氏原则的矩形 - 正方形示例
基于行为实现继承
以下是一个关于非基于行为的继承如何违反里氏原则的著名示例。
Rectangle
- Square
示例。从物理角度看,square
是一个特殊的 rectangle
,但其行为与 rectangle
不一致。Rectangle
类包含两个简单的属性:Width
和 Height
。当我改变 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
,两者都有 width
和 height
。为了让 Square
真正成为一个 square
,我必须确保 width
和 height
始终相等。Square
类通过覆盖 SetWidth
和 SetHeight
方法并提供自己的版本来确保 width
和 height
始终相等,从而遵循 square
的物理特性。现在,重要的是要认识到我们改变了 Square
的行为,它不再与 Rectangle
的行为一致。当 Width
属性的值改变时,Height
属性的值也会随之改变,反之亦然。换句话说,rectangle
的 Height
和 Width
不能独立设置,这是一个重要的行为改变,正如我们在下一个代码示例中看到的,这违反了里氏原则。
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
的前置条件
Height
和width
必须为正数Height
与width
不同
square
的前置条件
Height
和width
必须为正数- 必须有相同的
height
和width
(更强的前置条件)
rectangle
的后置条件
- 在设置矩形
width
时,height
必须保持与初始height
相同
square
的后置条件
- 在设置
square
的宽度时,height
不一定与初始height
相同(更弱的后置条件)
这说明 square
的面积计算的前置条件实际上比 rectangle
的面积计算的前置条件更强,而后置条件却更弱,这就是为什么它违反了里氏原则。
里氏计算器示例
另一个解释前置条件和后置条件的示例是基于一个简单的 BaseCalculator
。它是一个类,有一个带有某些前置条件和后置条件的 calculation
方法。BaseCalculator
类有两个派生类:LiskovCalculator
(符合里氏原则)和 NonLiskovCalculator
(违反里氏原则)。LiskovCalculator
比 BaseCalculator
具有更弱的前置条件和更强的后置条件。NonLiskovCalculator
比 BaseCalculator
具有更强的前置条件和更弱的后置条件。
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"));
}
}
}
为了测试前置条件和后置条件,我添加了单元测试来测试使用 BaseCalculator
、LiskovCalculator
和 NonLiskovCalculator
的不同客户端。BaseClient
可以毫无问题地使用 BaseCalculator
和 LiskovCalculator
,但在使用 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;
(最弱的后置条件)
当你研究附带的解决方案时,一切都会变得更清楚。当你运行所有单元测试时,它们将显示使用 NonLiskov Calculator 的测试将失败,而使用 Liskov Calculator 的测试将成功。
Base client,可以处理 BaseCalculator
和 BaseCalculator
的所有派生类。
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);
}
更多规则
逆变和协变规则
除了上述确认符合里氏原则的规则外,还有一些其他的规则:逆变规则(从更派生的到泛化的):派生类中定义的方法的参数必须相等或为派生程度更低(更泛化)的类型。这可以看作是更弱的前置条件。当客户端调用 BaseCalculcator
的 DoSomeCalculation
方法时,它也应该能够调用 LiskovCalculator
的 DoSomeCalculation
方法,而无需更改输入参数。
可以赋给 int
的值也可以赋给 object
。
//BaseCalculator
DoSomeCalculation(int input)
//LiskovCalculator
DoSomeCalculation(object input)
协变规则(从泛化的到更派生的):派生类中定义的方法的返回类型必须相等或为派生程度更高(更窄)的类型。这可以看作是更强的后置条件。当客户端使用 BaseCalculcator
的 DoSomeCalculation
方法的返回类型时,它也应该能够使用 LiskovCalculator
的 DoSomeCalculation
方法的返回类型。
如果我能处理 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";
}
当你的编程符合本文提到的规则时,你就符合里氏兼容。
玩得开心!