Liskov替代原则的例子是什么?

问题描述 投票:780回答:28

我听说Liskov替换原则(LSP)是面向对象设计的基本原则。它是什么以及它的使用例子是什么?

oop definition solid-principles design-principles liskov-substitution-principle
28个回答
757
投票

一个很好的例子说明了LSP(我最近在一个播客中由Bob叔叔给出的)是有时在自然语言中听起来不对的东西在代码中不起作用。

在数学中,SquareRectangle。实际上它是一个矩形的专业化。 “是一个”让你想要继承模型。但是,如果在代码中你使Square来自Rectangle,那么Square应该可用于任何你期望的Rectangle。这会产生一些奇怪的行为。

想象一下,你的SetWidth基类上有SetHeightRectangle方法;这似乎完全符合逻辑。然而,如果你的Rectangle参考指向Square,那么SetWidthSetHeight没有意义,因为设置一个会改变另一个匹配它。在这种情况下,Square未能通过Rectangle进行Liskov替换测试,并且从Square继承Rectangle的抽象是不好的。

enter image description here

你们应该看看其他无价的SOLID Principles Motivational Posters


17
投票

使用LSP的一个重要例子是软件测试。

如果我有一个类A是符合LSP的B类子类,那么我可以重用B的测试套件来测试A.

为了完全测试子类A,我可能需要添加一些测试用例,但至少我可以重用所有超类B的测试用例。

一种实现的方法是通过构建McGregor所谓的“并行层次结构进行测试”:我的ATest类将继承自BTest。然后需要某种形式的注入来确保测试用例适用于类型A而不是类型B的对象(简单的模板方法模式将会这样做)。

请注意,为所有子类实现重用超级测试套件实际上是一种测试这些子类实现是否符合LSP的方法。因此,人们也可以争辩说,应该在任何子类的上下文中运行超类测试套件。

另请参阅Stackoverflow问题“Can I implement a series of reusable tests to test an interface's implementation?”的答案


15
投票

我想每个人都在技术上涵盖了LSP:你基本上希望能够从子类型细节中抽象出来并安全地使用超类型。

所以Liskov有3个基本规则:

  1. 签名规则:语法中子类型中的超类型的每个操作都应该有效实现。编译器可以为您检查的东西。关于抛出更少的异常并且至少像超类型方法一样可访问,有一个小规则。
  2. 方法规则:这些操作的实现在语义上是合理的。 较弱的前提条件:子类型函数应至少采用超类型作为输入的内容,如果不是更多。 更强的后置条件:它们应该生成超类型方法产生的输出的子集。
  3. 属性规则:这超出了单个函数调用。 不变量:始终如一的事物必须保持真实。例如。 Set的大小永远不会消极。 进化属性:通常与不变性或对象可以处于的状态有关。或者对象只能增长而不会缩小,因此子类型方法不应该成功。

需要保留所有这些属性,并且额外的子类型功能不应违反超类型属性。

如果这三件事都得到了解决,那么你已经从基础内容中抽象出来了,并且你正在编写松散耦合的代码。

资料来源:Java项目开发 - Barbara Liskov


13
投票

长话短说,让我们留下矩形矩形和正方形方块,扩展父类时的实际示例,您必须保留确切的父API或EXTEND IT。

假设您有一个基于ItemsRepository的基础。

class ItemsRepository
{
    /**
    * @return int Returns number of deleted rows
    */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        return $numberOfDeletedRows;
    }
}

还有一个扩展它的子类:

class BadlyExtendedItemsRepository extends ItemsRepository
{
    /**
     * @return void Was suppose to return an INT like parent, but did not, breaks LSP
     */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        // we broke the behaviour of the parent class
        return;
    }
}

然后,您可以让客户端使用Base ItemsRepository API并依赖它。

/**
 * Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
 *
 * Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
 * but if the sub-class won't abide the base class API, the client will get broken.
 */
class ItemsService
{
    /**
     * @var ItemsRepository
     */
    private $itemsRepository;

    /**
     * @param ItemsRepository $itemsRepository
     */
    public function __construct(ItemsRepository $itemsRepository)
    {
        $this->itemsRepository = $itemsRepository;
    }

    /**
     * !!! Notice how this is suppose to return an int. My clients expect it based on the
     * ItemsRepository API in the constructor !!!
     *
     * @return int
     */
    public function delete()
    {
        return $this->itemsRepository->delete();
    }
} 

当用子类替换父类时,LSP会被破坏API的合同。

class ItemsController
{
    /**
     * Valid delete action when using the base class.
     */
    public function validDeleteAction()
    {
        $itemsService = new ItemsService(new ItemsRepository());
        $numberOfDeletedItems = $itemsService->delete();

        // $numberOfDeletedItems is an INT :)
    }

    /**
     * Invalid delete action when using a subclass.
     */
    public function brokenDeleteAction()
    {
        $itemsService = new ItemsService(new BadlyExtendedItemsRepository());
        $numberOfDeletedItems = $itemsService->delete();

        // $numberOfDeletedItems is a NULL :(
    }
}

您可以在我的课程中了解有关编写可维护软件的更多信息:https://www.udemy.com/enterprise-php/


9
投票

LSP的这种表述过于强烈:

如果对于类型S的每个对象o1,存在类型为T的对象o2,使得对于根据T定义的所有程序P,当o1代替o2时P的行为不变,则S是T的子类型。

这基本上意味着S是另一个完全封装的与T完全相同的实现。我可以大胆并决定性能是P的行为的一部分......

所以,基本上,任何后期绑定的使用都违反了LSP。当我们将一种对象替换为另一种对象时,获得不同的行为是OO的重点!

引用by wikipedia的表述更好,因为该属性取决于上下文,并不一定包括该程序的整个行为。


8
投票

我在每个答案中看到矩形和正方形,以及如何违反LSP。

我想展示如何通过一个真实的例子来符合LSP:

<?php

interface Database 
{
    public function selectQuery(string $sql): array;
}

class SQLiteDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // sqlite specific code

        return $result;
    }
}

class MySQLDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // mysql specific code

        return $result; 
    }
}

此设计符合LSP,因为无论我们选择使用哪种实现,行为都保持不变。

是的,您可以在此配置中违反LSP,执行一个简单的更改,如下所示:

<?php

interface Database 
{
    public function selectQuery(string $sql): array;
}

class SQLiteDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // sqlite specific code

        return $result;
    }
}

class MySQLDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // mysql specific code

        return ['result' => $result]; // This violates LSP !
    }
}

现在,子类型不能以相同的方式使用,因为它们不再产生相同的结果。


7
投票

一些附录: 我想知道为什么没有人写关于派生类必须遵守的基类的不变量,前置条件和后置条件。要使派生类D完全可由Base类B维护,D类必须遵守某些条件:

  • 必须由派生类保留基类的in-variants
  • 派生类不得强化基类的前提条件
  • 派生类不能削弱基类的后置条件。

因此派生必须知道基类强加的上述三个条件。因此,子类型的规则是预先确定的。这意味着,只有当子类型遵守某些规则时,才应遵守“IS A”关系。这些规则,以不变量,预先准备和后置条件的形式,应由正式的'design contract'决定。

关于这方面的进一步讨论可以在我的博客上找到:Liskov Substitution principle


7
投票

在一个非常简单的句子中,我们可以说:

子类不得违反其基类特征。它必须有能力。我们可以说它与子类型相同。


5
投票

Liscover Substitution Principal(LPS)

我们一直在设计程序模块,并创建了一些类层次结构。然后我们扩展一些类创建一些派生类。

我们必须确保新的派生类只是扩展而不替换旧类的功能。否则,新类在现有程序模块中使用时会产生不良影响。

Liskov的替换原则指出,如果程序模块使用Base类,则可以使用Derived类替换对Base类的引用,而不会影响程序模块的功能。

例:

以下是违反Liskov替代原则的典型例子。在该示例中,使用了2个类:Rectangle和Square。我们假设Rectangle对象在应用程序的某个地方使用。我们扩展应用程序并添加Square类。方形类由工厂模式返回,基于某些条件,我们不知道将返回什么类型的对象。但我们知道它是一个矩形。我们得到矩形对象,将宽度设置为5,将高度设置为10并获得该区域。对于宽度为5且高度为10的矩形,面积应为50.相反,结果为100

    // Violation of Likov's Substitution Principle
class Rectangle {
    protected int m_width;
    protected int m_height;

    public void setWidth(int width) {
        m_width = width;
    }

    public void setHeight(int height) {
        m_height = height;
    }

    public int getWidth() {
        return m_width;
    }

    public int getHeight() {
        return m_height;
    }

    public int getArea() {
        return m_width * m_height;
    }
}

class Square extends Rectangle {
    public void setWidth(int width) {
        m_width = width;
        m_height = width;
    }

    public void setHeight(int height) {
        m_width = height;
        m_height = height;
    }

}

class LspTest {
    private static Rectangle getNewRectangle() {
        // it can be an object returned by some factory ...
        return new Square();
    }

    public static void main(String args[]) {
        Rectangle r = LspTest.getNewRectangle();

        r.setWidth(5);
        r.setHeight(10);
        // user knows that r it's a rectangle.
        // It assumes that he's able to set the width and height as for the base
        // class

        System.out.println(r.getArea());
        // now he's surprised to see that the area is 100 instead of 50.
    }
}

结论:

这个原则只是Open Close Principle的扩展,它意味着我们必须确保新派生类扩展基类而不改变它们的行为。

另见:Open Close Principle

更好结构的一些类似概念:Convention over configuration


4
投票

在一系列董事会中实施ThreeDBoard会有用吗?

也许您可能希望将各种平面中的ThreeDBoard切片视为Board。在这种情况下,您可能希望抽象出Board的接口(或抽象类)以允许多个实现。

在外部接口方面,您可能想要为TwoDBoard和ThreeDBoard分配Board接口(尽管上述方法都不合适)。


4
投票

正方形是一个矩形,其宽度等于高度。如果正方形为宽度和高度设置两个不同的大小,则它违反了方形不变量。这通过引入副作用来解决。但是如果矩形有一个setSize(高度,宽度),前提条件为0 <height和0 <width。派生的子类型方法需要height == width;一个更强的先决条件(并且违反了lsp)。这表明虽然square是一个矩形,但它不是一个有效的子类型,因为前提条件得到了加强。解决方法(通常是坏事)会导致副作用,这会削弱后置条件(违反lsp)。基础上的setWidth具有post条件0 <width。派生用height == width来削弱它。

因此,可调整大小的正方形不是可调整大小的矩形。


442
投票

Liskov替换原则(LSP,)是面向对象编程中的一个概念,它指出:

使用指针或对基类的引用的函数必须能够在不知道它的情况下使用派生类的对象。

从本质上讲,LSP是关于接口和契约以及如何决定何时扩展一个类而不是使用另一个策略(如组合)来实现您的目标。

我所看到的最有效的方式来说明这一点是在Head First OOA&D。他们提出了一个场景,您是一个项目开发人员,为战略游戏构建框架。

他们提出了一个代表董事会的类,如下所示:

所有方法都将X和Y坐标作为参数,以在Tiles的二维数组中定位切片位置。这将允许游戏开发者在游戏过程中管理棋盘中的单元。

这本书继续改变要求,说游戏框架工作也必须支持3D游戏板,以适应有飞行的游戏。因此引入了扩展ThreeDBoardBoard类。

乍一看,这似乎是一个很好的决定。 Board提供HeightWidth属性,ThreeDBoard提供Z轴。

当你看到从Board继承的所有其他成员时,它崩溃的地方。 AddUnitGetTileGetUnits等的方法都采用Board类中的X和Y参数,但ThreeDBoard也需要Z参数。

因此,您必须使用Z参数再次实现这些方法。 Z参数没有Board类的上下文,Board类的继承方法失去了意义。尝试使用ThreeDBoard类作为基类Board的代码单元将非常不幸。

也许我们应该找到另一种方法。 Board应该由ThreeDBoard对象组成,而不是扩展Board。每单位Z轴一个Board对象。

这允许我们使用良好的面向对象原则,如封装和重用,并且不违反LSP。


4
投票

让我们用Java来说明:

class TrasportationDevice
{
   String name;
   String getName() { ... }
   void setName(String n) { ... }

   double speed;
   double getSpeed() { ... }
   void setSpeed(double d) { ... }

   Engine engine;
   Engine getEngine() { ... }
   void setEngine(Engine e) { ... }

   void startEngine() { ... }
}

class Car extends TransportationDevice
{
   @Override
   void startEngine() { ... }
}

这里没问题吧?汽车绝对是一种运输设备,在这里我们可以看到它覆盖了其超类的startEngine()方法。

让我们添加另一个运输设备:

class Bicycle extends TransportationDevice
{
   @Override
   void startEngine() /*problem!*/
}

现在一切都没有按计划进行!是的,自行车是一种运输设备,但它没有引擎,因此无法实现startEngine()方法。

这些是违反Liskov替代原则导致的问题,并且它们通常可以通过一种什么都不做,甚至无法实现的方法来识别。

这些问题的解决方案是正确的继承层次结构,在我们的例子中,我们可以通过区分具有和不具有引擎的运输设备类来解决问题。即使自行车是运输设备,它也没有发动机。在这个例子中,我们对运输设备的定义是错误的。它不应该有引擎。

我们可以重构我们的TransportationDevice类,如下所示:

class TrasportationDevice
{
   String name;
   String getName() { ... }
   void setName(String n) { ... }

   double speed;
   double getSpeed() { ... }
   void setSpeed(double d) { ... }
}

现在我们可以为非机动设备扩展TransportationDevice。

class DevicesWithoutEngines extends TransportationDevice
{  
   void startMoving() { ... }
}

并扩展TransportationDevice用于电动设备。这里更适合添加Engine对象。

class DevicesWithEngines extends TransportationDevice
{  
   Engine engine;
   Engine getEngine() { ... }
   void setEngine(Engine e) { ... }

   void startEngine() { ... }
}

因此,我们的Car类更加专业化,同时遵守Liskov替换原则。

class Car extends DevicesWithEngines
{
   @Override
   void startEngine() { ... }
}

我们的自行车课程也符合Liskov替代原则。

class Bicycle extends DevicesWithoutEngines
{
   @Override
   void startMoving() { ... }
}

3
投票

假设我们在代码中使用矩形

r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);

在我们的几何类中,我们了解到一个正方形是一种特殊类型的矩形,因为它的宽度与它的高度相同。让我们根据这些信息制作一个Square课程:

class Square extends Rectangle {
    setDimensions(width, height){
        assert(width == height);
        super.setDimensions(width, height);
    }
} 

如果我们在第一个代码中用Rectangle替换Square,那么它将会破坏:

r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);

这是因为Square有一个我们在Rectangle类中没有的新前提条件:width == height。根据LSP,Rectangle实例应该可以用Rectangle子类实例替代。这是因为这些实例传递了Rectangle实例的类型检查,因此它们会在代码中导致意外错误。

这是wiki article中“亚型”中的“先决条件无法加强”部分的一个例子。总而言之,违反LSP可能会在某些时候导致代码错误。


2
投票

我鼓励你阅读这篇文章:Violating Liskov Substitution Principle (LSP)

你可以在那里找到解释什么是Liskov替换原则,一般线索可以帮助你猜测你是否已经违反它,以及一个帮助你使你的类层次更安全的方法示例。


2
投票

到目前为止我发现的LSP的最清楚的解释是“Liskov替换原则说明派生类的对象应该能够替换基类的对象,而不会在系统中引入任何错误或修改基类的行为“来自here。本文给出了违反LSP并修复它的代码示例。


2
投票

LISKOV替换原则(来自Mark Seemann一书)指出,我们应该能够在不破坏客户端或实现的情况下将接口的一个实现替换为另一个。即使我们可以',这个原则也能够满足将来发生的需求。今天预见到他们。

如果我们从墙上拔掉电脑(实施),墙上插座(接口)和电脑(客户端)都不会发生故障(实际上,如果是笔记本电脑,它甚至可以在电池上运行一段时间) 。但是,对于软件,客户通常希望服务可用。如果删除了服务,我们会得到NullReferenceException。为了处理这种情况,我们可以创建一个“什么也没做”的接口实现。这是一种称为Null Object的设计模式,[4]它大致相当于将计算机从墙上拔下来。因为我们正在使用松散耦合,所以我们可以用一些不会造成麻烦的东西替换真正的实现。


2
投票

Likov的替换原则指出,如果程序模块使用Base类,则可以用Derived类替换对Base类的引用,而不会影响程序模块的功能。

意图 - 派生类型必须完全替代其基本类型。

示例 - java中的Co-variant返回类型。


1
投票

LSP说''对象应该可以被它们的子类替换''。另一方面,这个原则指向

子类永远不应该破坏父类的类型定义。

以下示例有助于更好地理解LSP。

没有LSP:

public interface CustomerLayout{

    public void render();
}


public FreeCustomer implements CustomerLayout {
     ...
    @Override
    public void render(){
        //code
    }
}


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public void render(){
        if(!hasSeenAd)
            return; //it isn`t rendered in this case
        //code
    }
}

public void renderView(CustomerLayout layout){
    layout.render();
}

通过LSP修复:

public interface CustomerLayout{
    public void render();
}


public FreeCustomer implements CustomerLayout {
     ...
    @Override
    public void render(){
        //code
    }
}


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public void render(){
        if(!hasSeenAd)
            showAd();//it has a specific behavior based on its requirement
        //code
    }
}

public void renderView(CustomerLayout layout){
    layout.render();
}

0
投票

让我试一下,考虑一个界面:

interface Planet{
}

这是通过类实现的:

class Earth implements Planet {
    public $radius;
    public function construct($radius) {
        $this->radius = $radius;
    }
}

您将使用地球:

$planet = new Earth(6371);
$calc = new SurfaceAreaCalculator($planet);
$calc->output();

现在考虑另外一个扩展地球的类:

class LiveablePlanet extends Earth{
   public function color(){
   }
}

现在根据LSP,您应该能够使用LiveablePlanet代替地球,它不应该破坏您的系统。喜欢:

$planet = new LiveablePlanet(6371);  // Earlier we were using Earth here
$calc = new SurfaceAreaCalculator($planet);
$calc->output();

here采取的例子


-1
投票

以下是this post的摘录,可以很好地澄清事情:

[...]为了理解某些原则,重要的是要意识到它何时被违反。这就是我现在要做的。

违反这一原则意味着什么?它意味着一个对象不能满足用接口表示的抽象强加的契约。换句话说,这意味着您确定了您的抽象错误。

请考虑以下示例:

interface Account
{
    /**
     * Withdraw $money amount from this account.
     *
     * @param Money $money
     * @return mixed
     */
    public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
    private $balance;
    public function withdraw(Money $money)
    {
        if (!$this->enoughMoney($money)) {
            return;
        }
        $this->balance->subtract($money);
    }
}

这是违反LSP的吗?是。这是因为该帐户的合同告诉我们帐户将被撤销,但情况并非总是如此。那么,我该怎么做才能解决它?我只是修改合同:

interface Account
{
    /**
     * Withdraw $money amount from this account if its balance is enough.
     * Otherwise do nothing.
     *
     * @param Money $money
     * @return mixed
     */
    public function withdraw(Money $money);
}

Voilà,现在合同很满意。

这种微妙的违规通常会使客户能够分辨出所使用的具体对象之间的区别。例如,根据第一个帐户的合同,它可能如下所示:

class Client
{
    public function go(Account $account, Money $money)
    {
        if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
            return;
        }
        $account->withdraw($money);
    }
}

并且,这自动违反了开放式原则[即,对于提款要求。因为如果违反合同的对象没有足够的钱,你永远不会知道会发生什么。可能它只返回任何内容,可能会抛出异常。所以你必须检查它是否hasEnoughMoney() - 这不是一个接口的一部分。所以这个强制的具体类依赖检查是OCP违规]。

这一点也解决了我经常遇到的关于LSP违规的错误观念。它说“如果父母的行为在孩子身上发生了变化,那么就会违反LSP。”然而,只要孩子没有违反其父母的合同,它就不会。


122
投票

LSP涉及不变量。

下面的伪代码声明给出了经典示例(省略了实现):

class Rectangle {
    int getHeight()
    void setHeight(int value)
    int getWidth()
    void setWidth(int value)
}

class Square : Rectangle { }

现在我们遇到了一个问题,尽管界面匹配。原因是我们违反了由正方形和矩形的数学定义产生的不变量。 getter和setter的工作方式,Rectangle应该满足以下不变量:

void invariant(Rectangle r) {
    r.setHeight(200)
    r.setWidth(100)
    assert(r.getHeight() == 200 and r.getWidth() == 100)
}

但是,必须通过正确实施Square来违反此不变量,因此它不是Rectangle的有效替代品。


84
投票

可替代性是面向对象编程中的一个原则,表明在计算机程序中,如果S是T的子类型,则类型T的对象可以用类型S的对象替换

让我们用Java做一个简单的例子:

不好的例子

public class Bird{
    public void fly(){}
}
public class Duck extends Bird{}

鸭子可以飞,因为它是一只鸟,但是这个怎么样:

public class Ostrich extends Bird{}

鸵鸟是一只鸟,但它不能飞,鸵鸟类是Bird类的子类,但它不能使用fly方法,这意味着我们正在打破LSP原理。

好例子

public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{} 

71
投票

罗伯特马丁有一个很棒的paper on the Liskov Substitution Principle。它讨论了可能违反原则的微妙而不那么微妙的方式。

本文的一些相关部分(注意第二个例子是高度浓缩的):

A Simple Example of a Violation of LSP

最明显违反此原则的行为之一是使用C ++运行时类型信息(RTTI)根据对象的类型选择函数。即:

void DrawShape(const Shape& s)
{
  if (typeid(s) == typeid(Square))
    DrawSquare(static_cast<Square&>(s)); 
  else if (typeid(s) == typeid(Circle))
    DrawCircle(static_cast<Circle&>(s));
}

显然,DrawShape功能很糟糕。它必须知道Shape类的每个可能的导数,并且必须在创建Shape的新衍生物时进行更改。实际上,许多人认为这个函数的结构是面向对象设计的诅咒。

Square and Rectangle, a More Subtle Violation.

然而,还有其他更微妙的违反LSP的方式。考虑使用Rectangle类的应用程序,如下所述:

class Rectangle
{
  public:
    void SetWidth(double w) {itsWidth=w;}
    void SetHeight(double h) {itsHeight=w;}
    double GetHeight() const {return itsHeight;}
    double GetWidth() const {return itsWidth;}
  private:
    double itsWidth;
    double itsHeight;
};

[...]想象一下,有一天用户需要能够操纵除矩形之外的方块。 [...]

显然,正方形是所有正常意图和目的的矩形。由于ISA关系成立,因此将Square类建模为源自Rectangle是合乎逻辑的。 [...]

Square将继承SetWidthSetHeight函数。这些功能完全不适合Square,因为正方形的宽度和高度是相同的。这应该是设计存在问题的重要线索。但是,有一种方法可以回避这个问题。我们可以覆盖SetWidthSetHeight [...]

但请考虑以下功能:

void f(Rectangle& r)
{
  r.SetWidth(32); // calls Rectangle::SetWidth
}

如果我们将对Square对象的引用传递给此函数,则Square对象将被破坏,因为高度不会更改。这显然违反了LSP。该函数不适用于其参数的派生。

[...]


40
投票

LSP是必要的,其中一些代码认为它调用T类型的方法,并且可能在不知不觉中调用S类型的方法,其中S extends T(即S继承,衍生自或者是超类型T的子类型)。

例如,这种情况发生在具有T类型的输入参数的函数被调用(即调用)且参数值为S的情况下。或者,在T类型的标识符被赋值为S类型的值。

val id : T = new S() // id thinks it's a T, but is a S

LSP要求T类型(例如Rectangle)方法的期望值(即不变量),而不是在调用S类型的方法(例如Square)时违反。

val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation

即使是具有不可变字段的类型仍具有不变量,例如不可变的Rectangle setter期望维度被独立修改,但是不可变的Square setter违反了这个期望。

class Rectangle( val width : Int, val height : Int )
{
   def setWidth( w : Int ) = new Rectangle(w, height)
   def setHeight( h : Int ) = new Rectangle(width, h)
}

class Square( val side : Int ) extends Rectangle(side, side)
{
   override def setWidth( s : Int ) = new Square(s)
   override def setHeight( s : Int ) = new Square(s)
}

LSP要求子类型S的每个方法必须具有逆变输入参数和协变输出。

逆变意味着方差与继承的方向相反,即子类型Si的每个方法的每个输入参数的类型S,必须是相应方法的相应输入参数的类型Ti的相同或超类型。超级型T

协方差意味着方差在继承的同一方向上,即类型So,子类型S的每个方法的输出,必须是相应的或类型的To的相应输出的相应方法的子类型。超级型T

这是因为如果调用者认为它有类型T,认为它调用T的方法,那么它提供Ti类型的参数并将输出分配给类型To。当它实际调用S的相应方法时,则将每个Ti输入参数分配给Si输入参数,并将So输出分配给类型To。因此,如果Si不是逆变的w.r.t.到Ti,然后一个子类型Xi - 它不会是Si的子类型 - 可以被分配给Ti

此外,对于在类型多态性参数(即泛型)上具有定义 - 位置方差注释的语言(例如Scala或Ceylon),类型T的每个类型参数的方差注释的共同或反向必须是opposite或相同分别指向具有类型参数类型的每个输入参数或输出(T的每个方法)。

另外,对于具有功能类型的每个输入参数或输出,所需的方差方向相反。此规则以递归方式应用。


Subtyping is appropriate可以枚举不变量。

关于如何建模不变量的研究正在进行中,因此它们由编译器强制执行。

Typestate(参见第3页)声明并强制执行与类型正交的状态不变量。或者,converting assertions to types可以强制执行不变量。例如,要在关闭文件之前声明文件已打开,则File.open()可以返回OpenFile类型,该类型包含File中不可用的close()方法。 tic-tac-toe API可以是使用类型在编译时强制执行不变量的另一个示例。类型系统甚至可以是图灵完整的,例如, Scala。依赖类型语言和定理证明器使高阶类型的模型形式化。

由于需要对abstract over extension进行语义化,我希望使用键入来模拟不变量,即统一的高阶指称语义,优于Typestate。 “扩展”是指无协调,模块化开发的无限制,置换组合。因为在我看来,它是统一的对立面,因而是自由度,有两个相互依赖的模型(例如类型和类型状态)来表达共享语义,这些模型不能相互统一以实现可扩展的组合。例如,类似Expression Problem的扩展在子类型,函数重载和参数类型域中统一。

我的理论立场是,对于knowledge to exist(参见“集中化是盲目和不合适”一节),永远不会有一个通用的模型可以在图灵完备的计算机语言中强制实现100%覆盖所有可能的不变量。为了存在知识,存在着意想不到的可能性,即无序和熵必须一直在增加。这是熵力。为了证明潜在扩展的所有可能计算,是计算先验所有可能的扩展。

这就是Halting定理存在的原因,即Turing完全编程语言中的每个可能的程序是否终止都是不可判定的。可以证明某些特定程序终止(所有可能性都已定义和计算)。但是不可能证明该程序的所有可能扩展都终止,除非扩展该程序的可能性不是图灵完成(例如通过依赖类型)。由于图灵完备性的基本要求是unbounded recursion,因此直观地理解哥德尔的不完备性定理和罗素的悖论如何适用于扩展。

对这些定理的解释将它们纳入对熵力的概括性概念理解中:

  • 哥德尔的不完备性定理:任何可以证明所有算术真理的形式理论都是不一致的。
  • Russell's paradox:可以包含集合的集合的每个成员规则,枚举每个成员的特定类型或包含自身。因此集合既不能扩展也不能无限递归。例如,一套不是茶壶的东西,包括它自己,包括它自己,包括它自己等等。因此,如果规则(可能包含一个集合)并不枚举特定类型(即允许所有未指定的类型)并且不允许无界扩展,则规则是不一致的。这是一组不属于自己的集合。哥德尔的不完备性定理是无法在所有可能的扩展中保持一致和完全列举的。
  • Liskov替换原则:通常,任何集合是否是另一个集合的子集是一个不可判定的问题,即继承通常是不可判定的。
  • Linsky Referencing:在描述或感知事物时,某事物的计算是不可判定的,即感知(现实)没有绝对的参照点。
  • 科斯定理:没有外部参考点,因此无限外部可能性的任何障碍都将失败。
  • Second law of thermodynamics:整个宇宙(一个封闭的系统,即一切)趋向于最大程度的无序,即最大的独立可能性。

20
投票

LSP是关于clases合同的规则:如果基类满足合同,那么LSP派生类也必须满足该合同。

在Pseudo-python中

class Base:
   def Foo(self, arg): 
       # *... do stuff*

class Derived(Base):
   def Foo(self, arg):
       # *... do stuff*

如果每次在Derived对象上调用Foo时,它都会满足LSP,它给出的结果与在Base对象上调用Foo完全相同,只要arg是相同的。


19
投票

使用指针或对基类的引用的函数必须能够在不知道它的情况下使用派生类的对象。

当我第一次阅读LSP时,我认为这是非常严格意义上的,基本上将其等同于接口实现和类型安全转换。这意味着LSP要么由语言本身确保,要么得到保证。例如,在严格意义上,就编译器而言,ThreeDBoard肯定可替代Board。

在阅读了更多关于概念的内容后,我发现LSP通常比这更广泛地被解释。

简而言之,客户端代码“知道”指针后面的对象是派生类型而不是指针类型意味着什么并不限于类型安全。通过探测对象的实际行为,也可以测试对LSP的遵守情况。也就是说,检查对象的状态和方法参数对方法调用结果的影响,或者从对象抛出的异常类型。

再回到示例,理论上可以使Board方法在ThreeDBoard上正常工作。然而,在实践中,很难防止客户端可能无法正确处理的行为差异,而不会影响ThreeDBoard要添加的功能。

掌握了这些知识,评估LSP依从性可以成为确定何时组合是扩展现有功能而不是继承的更合适机制的一个很好的工具。


18
投票

有一个检查清单,以确定您是否违反Liskov。

  • 如果您违反以下任何一项 - >您违反了Liskov。
  • 如果你不违反任何 - >无法做出任何结论。

清单:

  • 在派生类中不应抛出新的异常:如果您的基类抛出了ArgumentNullException,那么您的子类只允许抛出ArgumentNullException类型的异常或从ArgumentNullException派生的任何异常。抛出IndexOutOfRangeException是违反Liskov的。
  • 前提条件无法加强:假设您的基类使用成员int。现在你的子类型要求int为正数。这是强化前置条件,现在任何在使用负数之前完全正常工作的代码都会被破坏。
  • 后置条件不能被削弱:假设您的基类需要在返回方法之前关闭与数据库的所有连接。在您的子类中,您覆盖该方法并保持连接打开以便进一步重用。你已经削弱了该方法的后置条件。
  • 必须保留不变量:要实现的最困难和痛苦的约束。不变量有时隐藏在基类中,揭示它们的唯一方法是读取基类的代码。基本上,您必须确保在重写方法时,在执行重写方法后,任何不可更改的内容都必须保持不变。我能想到的最好的事情是在基类中强制执行这种不变约束,但这并不容易。
  • 历史约束:覆盖方法时,不允许修改基类中的不可修改属性。看看这些代码,您可以看到Name被定义为不可修改(私有集),但SubType引入了允许修改它的新方法(通过反射): public class SuperType { public string Name { get; private set; } public SuperType(string name, int age) { Name = name; Age = age; } } public class SubType : SuperType { public void ChangeName(string newName) { var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName); } }

还有2个项目:方法参数的反演和返回类型的协方差。但是在C#中我不可能(我是C#开发人员)所以我不关心他们。

参考:

© www.soinside.com 2019 - 2024. All rights reserved.