你能用一个好的C#例子解释一下Liskov替换原则吗?

101
你能用一个简单的C#例子来解释Liskov替换原则(SOLID中的“L”)吗?并覆盖该原则的所有方面,尽可能以通俗易懂的方式表达。 如果可能的话。

10
简单地说,如果我遵循LSP原则,就可以将代码中的任何对象替换为Mock对象,而调用代码中无需进行任何调整或更改以适应替代。LSP原则是“通过Mock测试”模式的基本支持。 - kmote
这个答案中还有一些符合和违规的例子。链接 - StuartLC
3个回答

134

(该答案已于2013-05-13重写,阅读评论底部的讨论)

LSP指的是遵循基类合同的规则。

例如,在子类中抛出新异常是不允许的,因为使用基类的程序不会预期这种情况。如果基类引发ArgumentNullException异常而子类允许参数为空,则也存在LSP违规问题。

以下是一个违反LSP的类结构示例:

public interface IDuck
{
   void Swim();
   // contract says that IsSwimming should be true if Swim has been called.
   bool IsSwimming { get; }
}

public class OrganicDuck : IDuck
{
   public void Swim()
   {
      //do something to swim
   }

   bool IsSwimming { get { /* return if the duck is swimming */ } }
}

public class ElectricDuck : IDuck
{
   bool _isSwimming;

   public void Swim()
   {
      if (!IsTurnedOn)
        return;

      _isSwimming = true;
      //swim logic            
   }

   bool IsSwimming { get { return _isSwimming; } }
}

而且调用代码

void MakeDuckSwim(IDuck duck)
{
    duck.Swim();
}

正如你所看到的,这里有两个鸭子的例子:一个是有机鸭,另一个是电动鸭。只有当电动鸭启动时,它才能游泳。这违反了LSP原则,因为必须启动它才能游泳,而IsSwimming(也是契约的一部分)不会像基类中那样被设置。

当然,你可以通过像这样做来解决它:

void MakeDuckSwim(IDuck duck)
{
    if (duck is ElectricDuck)
        ((ElectricDuck)duck).TurnOn();
    duck.Swim();
}
但那会违反开闭原则,并且必须在每个地方都实现(因此仍会生成不稳定的代码)。
正确的解决方案是自动在Swim方法中打开鸭子,从而使电鸭表现出与IDuck接口定义完全相同的行为。 更新 有人添加了一条评论并将其删除。它提出了一个有效的观点,我想回应一下:
Swim方法内部打开鸭子的解决方法可能会在实际实现(ElectricDuck)中产生副作用。但可以通过使用 显式接口实现来解决这个问题。在使用IDuck接口时,期望它会游泳,因此如果不在Swim中打开它,更可能会出现问题。 更新 2 重新表述了一些部分,以使其更清晰。

1
@jgauffin:示例简单明了。但是您提出的解决方案首先违反了开闭原则,其不符合 Uncle Bob 的定义(请参见他文章结论部分)中写道:“里氏替换原则(又称契约式设计)是符合开闭原则的所有程序的一个重要特征。” 请参阅:http://www.objectmentor.com/resources/articles/lsp.pdf - pencilCake
1
我不明白这个解决方案是如何违反开闭原则的。如果你指的是 if duck is ElectricDuck 部分,请再次阅读我的回答。上周我参加了一个关于SOLID的研讨会 :) - jgauffin
3
@jgauffin - 我对这个例子有点困惑。我认为里氏替换原则在这种情况下仍然有效,因为Duck和ElectricDuck都从IDuck派生而来,您可以将ElectricDuck或Duck放置在任何需要IDuck 的位置上。如果ElectricDuck必须在鸭子游泳之前打开,那不是ElectricDuck或某些实例化ElectricDuck的代码的责任,并将IsTurnedOn属性设置为true吗?如果这违反了LSP,那么似乎很难遵守LSV,因为所有接口的方法都包含不同的逻辑。 - Xaisoft
1
@MystereMan:在我看来,LSP(Liskov Substitution Principle)完全是关于行为正确性的。以矩形/正方形为例,你会得到另一个属性被设置的副作用。而使用鸭子则会导致它无法游泳的副作用。LSP原则:如果S是T的子类型,则程序中类型为T的对象可以替换为类型为S的对象,而不会改变该程序的任何期望属性(例如正确性)。 - jgauffin
让我们举一个真实的例子。您有一个IUserRepository,其中包含一个保存方法。在默认存储库中(使用OR/M),保存方法运行良好。但是,当您将其更改为使用WCF服务时,它不适用于所有未序列化的用户对象。这使得应用程序的行为与预期不符。 - jgauffin
显示剩余10条评论

7

一个实用的LSP方法

在我寻找LSP的C#示例时,到处都是虚构的类和接口。这里是我在我们系统中实现的LSP的实际应用。

场景:假设我们有三个数据库(抵押贷款客户、活期存款客户和储蓄账户客户)提供客户数据,并且我们需要给定客户的姓氏来获取客户详细信息。现在,我们可能会从这三个数据库中针对给定的姓氏获取多个客户详细信息。

实现:

业务模型层:

public class Customer
{
    // customer detail properties...
}

数据访问层:

public interface IDataAccess
{
    Customer GetDetails(string lastName);
}

上述接口由抽象类实现。
public abstract class BaseDataAccess : IDataAccess
{
    /// <summary> Enterprise library data block Database object. </summary>
    public Database Database;


    public Customer GetDetails(string lastName)
    {
        // use the database object to call the stored procedure to retrieve the customer details
    }
}

这个抽象类有一个名为“GetDetails”的通用方法,所有3个数据库都可以使用,每个数据库类都像下面展示的那样进行扩展。

房屋抵押客户数据访问:

public class MortgageCustomerDataAccess : BaseDataAccess
{
    public MortgageCustomerDataAccess(IDatabaseFactory factory)
    {
        this.Database = factory.GetMortgageCustomerDatabase();
    }
}

当前账户客户数据访问:

public class CurrentAccountCustomerDataAccess : BaseDataAccess
{
    public CurrentAccountCustomerDataAccess(IDatabaseFactory factory)
    {
        this.Database = factory.GetCurrentAccountCustomerDatabase();
    }
}

储蓄账户客户数据访问:

public class SavingsAccountCustomerDataAccess : BaseDataAccess
{
    public SavingsAccountCustomerDataAccess(IDatabaseFactory factory)
    {
        this.Database = factory.GetSavingsAccountCustomerDatabase();
    }
}

一旦设置了这三个数据访问类,现在我们将注意力转向客户端。在业务层中,我们有CustomerServiceManager类来向其客户返回客户细节。 业务层:
public class CustomerServiceManager : ICustomerServiceManager, BaseServiceManager
{
   public IEnumerable<Customer> GetCustomerDetails(string lastName)
   {
        IEnumerable<IDataAccess> dataAccess = new List<IDataAccess>()
        {
            new MortgageCustomerDataAccess(new DatabaseFactory()), 
            new CurrentAccountCustomerDataAccess(new DatabaseFactory()),
            new SavingsAccountCustomerDataAccess(new DatabaseFactory())
        };

        IList<Customer> customers = new List<Customer>();

       foreach (IDataAccess nextDataAccess in dataAccess)
       {
            Customer customerDetail = nextDataAccess.GetDetails(lastName);
            customers.Add(customerDetail);
       }

        return customers;
   }
}

为了让内容更简单易懂,我没有展示依赖注入。现在,如果我们有一个新的客户细节数据库,我们只需要添加一个扩展BaseDataAccess类并提供其数据库对象的新类。

当然,在所有参与的数据库中,我们需要相同的存储过程。

最后,CustomerServiceManager类的客户端只会调用GetCustomerDetails方法,传递lastName参数,不关心数据来自何处以及如何获取。

希望这会给你带来一种实际的方法来理解LSP(里氏替换原则)。


4
这个例子如何是LSP的示例? - Roshan
1
我也没有看到LSP的例子...为什么它有那么多赞? - StaNov
1
@RoshanGhangare,IDataAccess 有三种具体实现,可以在业务层中替换使用。 - Yawar Murtaza
1
@YawarMurtaza,你引用的例子是策略模式的典型实现,仅此而已。请问它在哪里违反了LSP,以及你是如何解决这种LSP违规的? - Yogesh
@Yogesh - 您可以将 IDataAccess 的实现与任何其具体类进行交换,这不会影响客户端代码 - 这就是 LSP 的要点。是的,某些设计模式存在重叠。其次,上述答案仅展示了如何在银行应用程序的生产系统中实现 LSP。我的意图不是展示如何打破 LSP 以及如何修复它 - 那将是一个培训教程,您可以在网上找到数百个这样的教程。 - Yawar Murtaza

2

这是应用里氏替换原则的代码。

public abstract class Fruit
{
    public abstract string GetColor();
}

public class Orange : Fruit
{
    public override string GetColor()
    {
        return "Orange Color";
    }
}

public class Apple : Fruit
{
    public override string GetColor()
    {
        return "Red color";
    }
}

class Program
{
    static void Main(string[] args)
    {
        Fruit fruit = new Orange();

        Console.WriteLine(fruit.GetColor());

        fruit = new Apple();

        Console.WriteLine(fruit.GetColor());
    }
}

LSV原则: “派生类应该能够替换其基类(或接口)” 和 “使用基类(或接口)引用的方法必须能够使用派生类的方法,而不需要知道细节或了解它。”


这段代码会打印出:橙色红色。如果你需要在VSCode中测试这段代码,请查看以下链接:https://code.visualstudio.com/docs/languages/csharp 和 https://channel9.msdn.com/Blogs/dotnet/Get-started-VSCode-Csharp-NET-Core-Windows - carloswm85

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接