如何遵守Liskov替换原则(LSP)并仍然受益于多态性?

16

LSP原则指出:“派生类型不能改变基类型的行为”,换句话说,“派生类型必须完全可以替换其基类型。”

这意味着,如果在基类中定义虚方法,我们就违反了这个原则。

同样地,如果我们使用new关键字在派生类型中隐藏一个方法,那么我们也违反了这个原则。

换句话说,如果我们使用多态,就违反了LSP!

在许多应用程序中,我们在基类中使用虚方法,现在我意识到这违反了LSP。如果您使用模板方法模式,则会违反此原则,而我经常使用它。

那么,当您需要继承并希望从多态中受益时,如何设计符合此原则的应用程序?我感到很困惑!

请参阅此处的示例:http://www.oodesign.com/liskov-s-substitution-principle.html


6
LSP 表示“派生类型不能改变基类型的行为” - 这不是它的意思。 - Oliver Charlesworth
4
我的来源是LSP(Liskov Substitution Principle),“程序中的对象应该可以被它们的子类型的实例所替换,而不改变程序的正确性。” - Austin Salonen
1
LSP原则认为对象的任何子类型都应该能够替换基本类型,而无需修改程序。例如,如果我抽象了数据访问,那么我应该能够在不需要修改程序的情况下将数据库实现替换为文件系统实现。 - JG in SD
1
@TheLight:恐怕你有所误解;LSP(里式替换原则)不是关于改变行为,而是关于改变正确性/接口/合约。 - Oliver Charlesworth
换句话说,如果我们使用多态性,我们就违反了LSP!这是完全正确的。这也是要远离愚蠢的“SOLID”和其他伪科学相互矛盾的理论的原因。 - luke1985
显示剩余10条评论
6个回答

10

芭芭拉·利斯科夫(Barbara Liskov)撰写了一篇非常好的文章 《数据抽象和层次结构》,其中她特别涉及多态行为和虚拟软件构造。阅读这篇文章后,您可以看到,她深入描述了软件组件如何通过简单的多态调用实现灵活性和模块化。

LSP关注于实现细节而非抽象。具体而言,如果您使用某个类型T的接口或抽象,您应该期望传递所有T的子类型,并且不会观察到意外的行为或程序崩溃。

这里的关键词是意外,因为它可以描述程序的任何属性(正确性、执行任务、返回语义、暂时等等)。因此,将您的方法设为virtual本身并不意味着违反LSP


这很有道理。所以它确切地关于行为,而不是契约。 - The Light
正确性不就是得到预期的结果吗?我的意思是...如果你有一个计算器,正确性就是在没有错误的情况下得到结果。正确性将由使用案例定义... - Taochok

4
LSP规定使用派生类时必须像使用其超类一样,不改变程序正确性的情况下用子类型的实例替换程序中的对象。继承关系中不符合这一规则的是将正方形类从矩形类派生出来,因为前者必须有高度等于宽度,而后者可以有不同的高度和宽度。
public class Rectangle
{
    public virtual Int32 Height { get; set; }
    public virtual Int32 Width { get; set; }
}

public class Square : Rectangle
{
    public override Int32 Height
    {
        get { return base.Height; }
        set { SetDimensions(value); }
    }

    public override Int32 Width
    {
        get { return base.Width; }
        set { SetDimensions(value); }
    }

    private void SetDimensions(Int32 value)
    {
        base.Height = value;
        base.Width = value;
    }
}

在这种情况下,宽度和高度属性的行为发生了变化,这是违反规则的。让我们看一下输出结果以了解为什么行为会变化:

private static void Main()
{
    Rectangle rectangle = new Square();
    rectangle.Height = 2;
    rectangle.Width = 3;

    Console.WriteLine("{0} x {1}", rectangle.Width, rectangle.Height);
}

// Output: 3 x 2

1
它违反了LSP,不是因为代码错误或运行时存在任何问题。它违反了LSP,因为它没有正确的行为。 - The Light
1
不,存在一种绝对的正确定义,可以被普遍地定义。这就像说“我有开车”是正确的,只是因为你决定它对你来说是正确的。已经制定了通用语法规则,并被所有“最终用户”广泛接受。编程语言也是如此。 - Tommaso Belluzzo
1
我不同意这个代码示例违反了LSP。首先,矩形不一定具有“宽度!=高度”。其次,如果您可以更改矩形的各个尺寸,则应该能够同时更改它们在正方形中的尺寸。所有正方形所做的就是调用矩形的公共属性,因此它与矩形的行为不兼容。如果矩形有一个名为SetDimensions(w,h)的方法,那么正方形将违反LSP。 - jwg
一个正方形的定义是具有相等长度边的矩形。在基本几何中,所有正方形实际上都是矩形。该测试是错误的,因为它假设子类没有副作用,而不是实现。具有副作用的子类不违反LSP,只有基础实现失败才会违反。一个有效的测试应该是设置宽度,测试宽度,设置高度,测试高度。提供的示例违反了其他原则(例如属性设置器不应更新另一个属性,尽管在正方形的情况下,这个原则不值得保留),但不违反LSP。 - Jon Davis
1
输出为 3 x 3,而不是 2 x 2。 - Andrii Viazovskyi
显示剩余5条评论

4
“派生类型不得改变基础类型的行为”,这意味着必须能够像使用基础类型一样使用派生类型。例如,如果您能够调用x = baseObj.DoSomeThing(123),那么您也必须能够调用x = derivedObj.DoSomeThing(123)。派生方法不应该在基础方法没有抛出异常的情况下抛出异常。使用基类的代码也应该能够与派生类良好地配合工作。它不应该“看到”它正在使用另一种类型。这并不意味着派生类必须完全做相同的事情;那样是无意义的。换句话说,使用派生类型不应该破坏原本使用基础类型运行良好的代码。
举个例子,假设您声明了一个记录器,使您能够将消息记录到控制台。
logger.WriteLine("hello");

您可以在需要生成日志的类中使用构造函数注入。现在,您可以传递一个从控制台记录器派生的文件记录器,而不是将其传递给控制台记录器。如果文件记录器抛出异常,说“消息字符串中必须包含行号”,那么这将违反LSP原则。但是,将日志记录到文件而不是控制台并不是问题。也就是说,如果记录器向调用者显示相同的行为,则一切都没问题。
如果您需要编写以下代码,则将违反LSP原则:
if (logger is FileLogger) {
    logger.Write("10 hello"); // FileLogger requires a line number

    // This throws an exception!
    logger.Write("hello");
} else {
    logger.Write("hello");
}

顺便说一下:关键字 new 不影响多态性,而是声明一个全新的方法,恰好与基类型中的某个方法同名但与之无关。特别地,不可能通过基类型来调用它。为了实现多态性,必须使用关键字 override 并且该方法必须是虚拟的(除非你正在实现接口)。

如果FileLogger继承自ConsoleLogger,那么在什么情况下程序会拒绝接受FileLogger(派生类)?你能举个例子并说明一些属性或方法吗? - The Light
这是OCP违规的例子。 - The Light
不,你解释的是OCP而不是LSP。LSP是关于行为是否对基类型的所有子类型仍然正确。如果您正在检查特定的派生类型,然后具有不同的实现,则违反了OCP,因为稍后对于DatabaseLogger,您可能希望在示例中具有不同的格式。 - The Light
调用记录器方法的代码并不想要有不同的行为,它需要以不同的方式调用该方法,因为派生类型具有特殊的需求。相反,它应该能够完全相同地调用记录器:logger.Write("hello"); // 应该适用于ConsoleLogger和FileLogger - Olivier Jacot-Descombes
如果派生类需要特殊处理,那么它肯定是OCP违规而不是LSP。如果你说调用logger.Write("hello")会在不应该的地方产生意外结果,那么它可能是LSP违规,但如果只是格式化和你的例子,FileLogger可以有自己的Write实现并在那里添加格式化。所以我的意思是这不是一个好的LSP违规示例,抱歉! - The Light
显示剩余3条评论

2
我认为里氏替换原则(LSP)主要是将可能不同的函数实现移动到子类中,并尽可能使父类通用。因此,只要您在子类中进行更改,且这些更改不强制您修改父类中的代码,则不会违反里氏替换原则(LSP)。

0

子类型必须能够被基类型替代。

就联系而言。

派生类可以替换基类的前置条件,条件相同或更弱,后置条件相同或更强。

链接


你能详细说明一下吗?我不确定我理解最后一句话的意思。 - RayLoveless

-1
为了使多态性起作用,必须遵守LSP。打破它的好方法是在派生类型中引入不在基类型中的方法。在这种情况下,多态性无法工作,因为这些方法在基类型中不可用。您可以拥有一个不同的子类型实现方法,同时遵守多态性和LSP。

3
“在派生类型中引入方法”并不一定会违反父类型定义的合约。 - Austin Salonen
但是,如果仅在派生类型中可用某个方法,并强制在可以访问该行为之前进行转换,这是否违反了该原则?调用类型需要知道使用哪个派生类型,而它不应该关心。显然,这与多态相矛盾,但它是否违反了LSP原则? - levelnis
1
重点在于,在需要 ParentType 的任何地方,任何 ChildType 都应该可以使用而不会破坏任何东西。如果 ChildType “需要” 调用其自己定义的函数,那么是的,这将违反 LSP。额外的功能本身并不会导致违反 LSP。参见 StreamFileStream 类。FileStream 有额外的功能,但仍然可以用于只需要 Stream 的任何事情。 - Austin Salonen

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