Что является примером принципа подстановки Лискова?

Я слышал, что принцип замещения Лискова (LSP) является фундаментальным принципом объектно-ориентированного проектирования. Что это такое и каковы некоторые примеры его использования?

36 ответов

Решение

Принцип подстановки Лискова (LSP, lsp) - это концепция объектно-ориентированного программирования, которая гласит:

Функции, которые используют указатели или ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

По сути, LSP - это интерфейсы и контракты, а также то, как решать, когда расширять класс, а не использовать другую стратегию, например композицию, для достижения своей цели.

Наиболее эффективный способ проиллюстрировать этот момент - " Head First OOA&D". Они представляют сценарий, в котором вы являетесь разработчиком проекта по созданию платформы для стратегических игр.

Они представляют класс, представляющий доску, которая выглядит следующим образом:

Диаграмма классов

Все методы принимают координаты X и Y в качестве параметров, чтобы определить местоположение плитки в двумерном массиве Tiles, Это позволит разработчику игры управлять юнитами на доске в течение игры.

В книге далее изменяются требования, чтобы сказать, что структура игры должна также поддерживать 3D игровые поля, чтобы приспособиться к играм, в которых есть полет. Так что ThreeDBoard вводится класс, который расширяет Board,

На первый взгляд это кажется хорошим решением. Board обеспечивает как Height а также Width свойства и ThreeDBoard обеспечивает ось Z.

Где это ломается, когда вы смотрите на всех других членов, унаследованных от Board, Методы для AddUnit, GetTile, GetUnits и так далее, все принимают параметры X и Y в Board класс но ThreeDBoard нужен также параметр Z.

Поэтому вы должны снова реализовать эти методы с параметром Z. Параметр Z не имеет контекста для Board класс и унаследованные методы из Board класс теряет смысл. Блок кода, пытающийся использовать ThreeDBoard класс как его базовый класс Board было бы очень не повезло.

Может быть, мы должны найти другой подход. Вместо расширения Board, ThreeDBoard должен состоять из Board объекты. Один Board объект на единицу оси Z.

Это позволяет нам использовать хорошие объектно-ориентированные принципы, такие как инкапсуляция и повторное использование, и не нарушает LSP.

Отличным примером, иллюстрирующим LSP (данный дядей Бобом в подкасте, который я недавно слышал), было то, как иногда что-то, что звучит правильно на естественном языке, не совсем работает в коде.

В математике Square это Rectangle, На самом деле это специализация прямоугольника. "Is" заставляет вас моделировать это с наследованием. Однако если в коде вы сделали Square вытекают из Rectangle, затем Square должен использоваться везде, где вы ожидаете Rectangle, Это делает для некоторого странного поведения.

Представь, что у тебя было SetWidth а также SetHeight методы на вашем Rectangle базовый класс; это кажется совершенно логичным. Однако если ваш Rectangle ссылка указала на Square, затем SetWidth а также SetHeight не имеет смысла, потому что установка одного изменит другой, чтобы соответствовать ему. В этом случае Square не проходит Лисковский тест с заменой Rectangle и абстракция наличия Square наследовать от Rectangle плохой

Вы должны проверить другие бесценные мотивационные плакаты Принципов ТВЕРДЫХ.

Подстановочность - это принцип в объектно-ориентированном программировании, утверждающий, что в компьютерной программе, если 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{} 

LSP касается инвариантов.

Классический пример дается следующим объявлением псевдокода (реализации опущены):

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

class Square : Rectangle { }

Теперь у нас есть проблема, хотя интерфейс соответствует. Причина в том, что мы нарушили инварианты, вытекающие из математического определения квадратов и прямоугольников. Как работают геттеры и сеттеры, Rectangle должен удовлетворять следующему инварианту:

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

Однако этот инвариант должен быть нарушен правильной реализацией Squareследовательно, он не является действительной заменой Rectangle,

У Роберта Мартина есть отличная статья о принципе замены Лискова. В нем обсуждаются тонкие и не очень тонкие способы, которыми принцип может быть нарушен.

Некоторые важные части статьи (обратите внимание, что второй пример сильно сжат):

Простой пример нарушения 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 созданы. Действительно, многие рассматривают структуру этой функции как анафему для объектно-ориентированного дизайна.

Квадрат и прямоугольник, более тонкое нарушение.

Однако есть и другие, гораздо более тонкие способы нарушения 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 унаследует SetWidth а также SetHeight функции. Эти функции совершенно не подходят для Square, поскольку ширина и высота квадрата одинаковы. Это должно быть существенным признаком того, что существует проблема с дизайном. Однако есть способ обойти проблему. Мы могли бы переопределить SetWidth а также SetHeight [...]

Но рассмотрим следующую функцию:

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

Если мы передадим ссылку на Square объект в эту функцию, Square объект будет поврежден, потому что высота не будет изменена. Это явное нарушение ЛСП. Функция не работает для производных своих аргументов.

[...]

Я вижу прямоугольники и квадраты в каждом ответе, и как нарушать 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 !
    }
}

Теперь подтипы нельзя использовать одинаково, поскольку они больше не дают того же результата.

Есть контрольный список, чтобы определить, нарушаете ли вы Лискова.

  • Если вы нарушаете один из следующих пунктов -> вы нарушаете Лисков.
  • Если вы не нарушаете ничего -> не могу ничего заключить.

Контрольный список:

  • В производном классе не должно быть новых исключений: если ваш базовый класс генерировал ArgumentNullException, тогда вашим подклассам было разрешено только генерировать исключения типа ArgumentNullException или любые исключения, полученные из ArgumentNullException. Бросок IndexOutOfRangeException является нарушением Лискова.
  • Предварительные условия не могут быть усилены: предположим, что ваш базовый класс работает с членом int. Теперь ваш подтип требует, чтобы int был положительным. Это улучшило предварительные условия, и теперь любой код, который до этого работал отлично с отрицательными значениями, не работает.
  • Постусловия не могут быть ослаблены: предположим, что ваш базовый класс требует, чтобы все соединения с базой данных были закрыты до возврата метода. В вашем подклассе вы отвергли этот метод и оставили открытое соединение для дальнейшего повторного использования. Вы ослабили пост-условия этого метода.
  • Инварианты должны быть сохранены: самое трудное и болезненное ограничение для выполнения. Инварианты некоторое время скрыты в базовом классе, и единственный способ выявить их - прочитать код базового класса. По сути, вы должны быть уверены, что при переопределении метода все неизменное должно оставаться неизменным после выполнения переопределенного метода. Лучшее, что я могу придумать, - это применить эти инвариантные ограничения в базовом классе, но это будет нелегко.
  • Ограничение истории: при переопределении метода вам не разрешено изменять неизменяемое свойство в базовом классе. Взгляните на этот код, и вы увидите, что Имя определено как немодифицируемое (закрытый набор), но 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#), поэтому мне плевать на них.

Ссылка:

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 ожидают, что размеры будут изменены независимо, но неизменные установщики Square нарушают это ожидание.

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 не были противоречивыми по отношению к Ti затем подтип Xi - который не будет подтипом Si - может быть назначен Ti,

Кроме того, для языков (например, Scala или Ceylon), которые имеют аннотации на сайте определения для параметров полиморфизма типов (например, универсальные), совместное или обратное направление аннотации дисперсии для каждого параметра типа типа T должно быть противоположным или одинаковым направлением соответственно каждому входному параметру или выходу (каждого метода T), который имеет тип параметра типа.

Кроме того, для каждого входного параметра или выхода, который имеет тип функции, требуемое направление отклонения изменяется на противоположное. Это правило применяется рекурсивно.


Подтип подходит для тех случаев, когда можно перечислить инварианты.

В настоящее время проводится много исследований о том, как моделировать инварианты, чтобы они обеспечивались компилятором.

Typestate (см. Стр. 3) объявляет и применяет инварианты состояния, ортогональные типу. Альтернативно, инварианты могут быть реализованы путем преобразования утверждений в типы. Например, чтобы утверждать, что файл открыт перед его закрытием, File.open() может вернуть тип OpenFile, который содержит метод close(), недоступный в File. API Tic-Tac-Toe может быть еще одним примером использования типизации для принудительного применения инвариантов во время компиляции. Система типов может быть даже полной по Тьюрингу, например, Scala. Языки с независимой типизацией и доказатели теорем формализуют модели типизации высшего порядка.

Из-за необходимости семантики абстрагироваться от расширения, я ожидаю, что использование типизации для моделирования инвариантов, то есть унифицированной денотационной семантики высшего порядка, превосходит Typestate. "Расширение" означает неограниченную пермутированную композицию несогласованного модульного развития. Потому что мне кажется, что антитеза объединения и, следовательно, степеней свободы, иметь две взаимозависимые модели (например, типы и Typestate) для выражения общей семантики, которые не могут быть объединены друг с другом для расширяемой композиции., Например, похожее на выражение выражение расширение было унифицировано в областях подтипирования, перегрузки функций и параметрической типизации.

Моя теоретическая позиция заключается в том, что для того, чтобы знания существовали (см. Раздел "Централизация слепа и непригодна"), никогда не будет общей модели, которая могла бы обеспечить 100-процентное покрытие всех возможных инвариантов на языке Тьюринга. Чтобы знания существовали, неожиданных возможностей много, то есть беспорядок и энтропия всегда должны увеличиваться. Это энтропийная сила. Чтобы доказать все возможные вычисления потенциального расширения, нужно заранее вычислить все возможные расширения.

Вот почему существует теорема Остановки, т. Е. Неразрешимо, завершается ли каждая возможная программа на языке Тьюринга. Можно доказать, что какая-то конкретная программа завершается (та, для которой все возможности определены и вычислены). Но невозможно доказать, что все возможные расширения этой программы прекращаются, если только возможности расширения этой программы не являются полными по Тьюрингу (например, через зависимую типизацию). Поскольку основным требованием для полноты по Тьюрингу является неограниченная рекурсия, интуитивно понятно, как теоремы Гёделя о неполноте и парадокс Рассела применимы к расширению.

Интерпретация этих теорем включает их в обобщенное концептуальное понимание энтропийной силы:

  • Теоремы Гёделя о неполноте: любая формальная теория, в которой могут быть доказаны все арифметические истины, противоречива.
  • Парадокс Рассела: каждое правило членства для набора, которое может содержать набор, перечисляет конкретный тип каждого члена или содержит самого себя. Таким образом, множества либо не могут быть расширены, либо являются неограниченной рекурсией. Например, набор всего, что не является чайником, включает в себя, включает в себя, включает в себя и т. Д. Таким образом, правило является непоследовательным, если оно (может содержать набор и) не перечисляет конкретные типы (т.е. допускает все неопределенные типы) и не допускает неограниченного расширения. Это набор наборов, которые не являются членами самих себя. Эта неспособность быть непротиворечивой и полностью перечисляемой по всем возможным расширениям является теоремой Гёделя о неполноте.
  • Принцип подстановки Лискова: как правило, это неразрешимая проблема, является ли какой-либо набор подмножеством другого, то есть наследование обычно неразрешимо.
  • Ссылка Линского: неразрешимо, что такое вычисление чего-либо, когда оно описывается или воспринимается, то есть восприятие (реальность) не имеет абсолютной точки отсчета.
  • Теорема Коуза: нет никакой внешней опорной точки, таким образом, любой барьер для неограниченных возможностей внешних потерпит неудачу.
  • Второй закон термодинамики: вся вселенная (закрытая система, т.е. все) стремится к максимальному беспорядку, то есть максимально независимым возможностям.

Короче говоря, давайте оставим прямоугольники прямоугольниками и квадратами, практический пример при расширении родительского класса, вы должны либо СОХРАНИТЬ точный родительский API, либо РАСШИРИТЬ ЭТО.

Допустим, у вас есть база 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;
    }
}

Тогда у вас мог бы быть Клиент, работающий с API Base ItemsRepository и опирающийся на него.

/**
 * 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://enterprise-level-php.com/

Давайте проиллюстрируем на 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() не может быть реализован.

Это те проблемы, к которым приводит нарушение принципа подстановки Лискова, и чаще всего их можно распознать с помощью метода, который ничего не делает или даже не может быть реализован.

Решением этих проблем является правильная иерархия наследования, и в нашем случае мы решили бы эту проблему путем дифференциации классов транспортных устройств с двигателями и без них. Даже если велосипед является транспортным средством, у него нет двигателя. В этом примере наше определение транспортного устройства неверно. У него не должно быть двигателя.

Мы можем реорганизовать наш класс 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() { ... }
}

И расширить транспортное устройство для моторизованных устройств. Здесь более уместно добавить объект Engine.

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

   void startEngine() { ... }
}

Таким образом, наш класс автомобилей становится более специализированным, придерживаясь принципа подстановки Лискова.

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

И наш велосипедный класс также соответствует принципу замещения Лискова.

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

LSP - это правило о договоре условий: если базовый класс удовлетворяет договору, то производные классы LSP также должны удовлетворять этому договору.

В псевдо-питоне

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

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

удовлетворяет LSP, если каждый раз, когда вы вызываете Foo для объекта Derived, он дает те же результаты, что и вызов Foo для объекта Base, если аргумент arg одинаков.

Я предполагаю, что все понимали, что такое LSP технически: вы в основном хотите абстрагироваться от деталей подтипов и безопасно использовать супертипы.

Итак, у Лискова есть 3 базовых правила:

  1. Правило подписи: должна быть правильная реализация каждой операции надтипа в подтипе синтаксически. Что-то, что компилятор сможет проверить для вас. Существует небольшое правило о том, что нужно генерировать меньше исключений и быть по крайней мере таким же доступным, как и методы супертипа.

  2. Правило методов: реализация этих операций семантически обоснована.

    • Более слабые предварительные условия: функции подтипа должны принимать по крайней мере то, что супертип принял в качестве входных данных, если не больше.
    • Более сильные постусловия: они должны создавать подмножество вывода, созданного методами супертипа.
  3. Правило свойств: это выходит за рамки отдельных вызовов функций.

    • Инварианты: вещи, которые всегда верны, должны оставаться верными. Например. Размер набора никогда не бывает отрицательным.
    • Эволюционные свойства: обычно что-то связанное с неизменяемостью или типом состояний, в которых может находиться объект. Или, возможно, объект только растет и никогда не сжимается, поэтому методы подтипа не должны этого делать.

Все эти свойства должны быть сохранены, и дополнительная функциональность подтипа не должна нарушать свойства супертипа.

Если об этих трех вещах позаботятся, вы абстрагировались от базовых вещей и пишете слабосвязанный код.

Источник: Разработка программ на Java - Барбара Лисков

Важным примером использования LSP является тестирование программного обеспечения.

Если у меня есть класс A, который является LSP-совместимым подклассом B, то я могу повторно использовать набор тестов B для тестирования A.

Чтобы полностью протестировать подкласс A, мне, вероятно, нужно добавить еще несколько тестовых случаев, но, как минимум, я могу повторно использовать все тестовые случаи суперкласса B.

Это можно понять путем построения того, что Макгрегор называет "параллельной иерархией для тестирования": ATest класс унаследует от BTest, Затем требуется некоторая форма внедрения, чтобы тест-кейс работал с объектами типа A, а не типа B (подойдет простой шаблонный метод).

Обратите внимание, что повторное использование набора супер-тестов для всех реализаций подкласса фактически является способом проверки того, что эти реализации подкласса совместимы с LSP. Таким образом, можно также утверждать, что следует запускать тестовый набор суперкласса в контексте любого подкласса.

См. Также ответ на вопрос Stackru: " Могу ли я реализовать серию повторно используемых тестов для проверки реализации интерфейса?"

Функции, которые используют указатели или ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

Когда я впервые прочитал о LSP, я предположил, что это подразумевалось в очень строгом смысле, по сути приравнивая его к реализации интерфейса и приведению типов к типу. Что означало бы, что LSP обеспечивается или не обеспечивается самим языком. Например, в этом строгом смысле ThreeDBoard, безусловно, заменяет Board в том, что касается компилятора.

После прочтения более подробно о концепции, я обнаружил, что LSP обычно интерпретируется более широко, чем это.

Короче говоря, то, что означает для клиентского кода "знать", что объект за указателем имеет производный тип, а не тип указателя, не ограничивается безопасностью типов. Приверженность LSP также может быть проверена путем проверки фактического поведения объектов. Таким образом, исследуя влияние состояния объекта и аргументов метода на результаты вызовов метода или типы исключений, выбрасываемых из объекта.

Возвращаясь к примеру снова, теоретически можно заставить методы Board работать на ThreeDBoard просто отлично. На практике, однако, будет очень трудно предотвратить различия в поведении, которые клиент может не обработать должным образом, без ущерба для функциональности, которую ThreeDBoard намеревается добавить.

Имея эти знания в руках, оценка соблюдения LSP может быть отличным инструментом для определения, когда композиция является более подходящим механизмом для расширения существующей функциональности, а не наследования.

Странно, но никто не опубликовал оригинальную статью, в которой описан lsp. Это не так просто, как у Роберта Мартина, но оно того стоит.

Принцип замещения Лискова

  • Переопределенный метод не должен оставаться пустым
  • Переопределенный метод не должен вызывать ошибку
  • Поведение базового класса или интерфейса не должно подвергаться модификации (переработке) из-за поведения производного класса.

Проще говоря, LSP заявляет, что объекты одного суперкласса должны иметь возможность обмениваться друг с другом, ничего не нарушая.

Например, если у нас есть Cat и Dog класс, производный от Animal класса, любые функции, использующие класс Animal, должны иметь возможность использовать Cat или Dog и вести себя нормально.

В очень простом предложении мы можем сказать:

Дочерний класс не должен нарушать характеристики своего базового класса. Он должен быть в состоянии с этим. Мы можем сказать, что это так же, как подтип.

Эта формулировка LSP слишком сильна:

Если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется, когда o1 заменяется на o2, тогда S является подтипом T.

Что в основном означает, что S - это другая, полностью инкапсулированная реализация той же вещи, что и T. И я мог бы быть смелым и решить, что производительность является частью поведения P...

Так что, в принципе, любое использование позднего связывания нарушает LSP. Весь смысл ОО в том, чтобы получить другое поведение, когда мы заменяем объект одного вида другим!

Формулировка, на которую ссылается Википедия, лучше, поскольку свойство зависит от контекста и не обязательно включает в себя все поведение программы.

Принцип замещения Лискова (LSP)

Все время мы разрабатываем программный модуль и создаем некоторые иерархии классов. Затем мы расширяем некоторые классы, создавая некоторые производные классы.

Мы должны убедиться, что новые производные классы просто расширяются, не заменяя функциональность старых классов. В противном случае новые классы могут создавать нежелательные эффекты, когда они используются в существующих программных модулях.

Принцип подстановки Лискова гласит, что если программный модуль использует базовый класс, то ссылка на базовый класс может быть заменена производным классом, не влияя на функциональность программного модуля.

Пример:

Ниже приведен классический пример нарушения принципа подстановки Лискова. В примере используются 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

Некоторые похожие концепции для лучшей структуры: Соглашение по конфигурации

Этот принцип был введен Барбарой Лисков в 1987 году и расширяет принцип открытости-закрытости, фокусируясь на поведении суперкласса и его подтипов.

Его важность становится очевидной, если мы рассмотрим последствия его нарушения. Рассмотрим приложение, использующее следующий класс.

public class Rectangle 
{ 
  private double width;

  private double height; 

  public double Width 
  { 
    get 
    { 
      return width; 
    } 
    set 
    { 
      width = value; 
    }
  } 

  public double Height 
  { 
    get 
    { 
      return height; 
    } 
    set 
    { 
      height = value; 
    } 
  } 
}

Представьте, что однажды клиенту требуется умение манипулировать квадратами в дополнение к прямоугольникам. Поскольку квадрат является прямоугольником, класс square должен быть производным от класса Rectangle.

public class Square : Rectangle
{
} 

Однако при этом мы столкнемся с двумя проблемами:

Для квадрата не требуются переменные высоты и ширины, унаследованные от прямоугольника, и это может привести к значительным потерям памяти, если нам придется создавать сотни тысяч квадратных объектов. Свойства установщика ширины и высоты, унаследованные от прямоугольника, не подходят для квадрата, поскольку ширина и высота квадрата идентичны. Чтобы установить одинаковое значение для высоты и ширины, мы можем создать два новых свойства следующим образом:

public class Square : Rectangle
{
  public double SetWidth 
  { 
    set 
    { 
      base.Width = value; 
      base.Height = value; 
    } 
  } 

  public double SetHeight 
  { 
    set 
    { 
      base.Height = value; 
      base.Width = value; 
    } 
  } 
}

Теперь, когда кто-то установит ширину квадратного объекта, его высота изменится соответственно и наоборот.

Square s = new Square(); 
s.SetWidth(1); // Sets width and height to 1. 
s.SetHeight(2); // sets width and height to 2. 

Давайте продолжим и рассмотрим эту другую функцию:

public void A(Rectangle r) 
{ 
  r.SetWidth(32); // calls Rectangle.SetWidth 
} 

Если мы передадим в эту функцию ссылку на квадратный объект, мы нарушим LSP, потому что функция не работает для производных от своих аргументов. Свойства width и height не являются полиморфными, потому что они не объявлены виртуальными в прямоугольнике (квадратный объект будет поврежден, потому что высота не будет изменена).

Однако, объявив свойства установщика виртуальными, мы столкнемся с другим нарушением - OCP. Фактически, создание квадрата производного класса вызывает изменения в прямоугольнике базового класса.

В нем говорится, что если C является подтипом E, то E можно заменить объектами типа C без изменения или нарушения поведения программы. Проще говоря, производные классы должны заменять свои родительские классы. Например, если сын фермера - фермер, то он может работать вместо своего отца, но если сын фермера играет в крикет, он не может работать вместо своего отца.

Пример нарушения:

public class Plane{

  public void startEngine(){}      

}        
public class FighterJet extends Plane{}
    
public class PaperPlane extends Plane{}

В данном примере FighterPlane и PaperPlane классы, расширяющие Plane класс, который содержит startEngine()метод. Так ясно что FighterPlane можно запустить двигатель, но PaperPlane не может так это ломается LSP.

PaperPlane класс, хотя и расширяется Planeclass и должен быть заменяемым вместо него, но не является подходящей сущностью, которой можно было бы заменить экземпляр Plane, потому что бумажный самолетик не может запустить двигатель, поскольку у него его нет. Итак, хороший пример:

Уважаемый пример:

public class Plane{ 
} 
public class RealPlane{

  public void startEngine(){} 

}
public class FighterJet extends RealPlane{} 
public class PaperPlane extends Plane{}

Некоторое приложение:
Интересно, почему никто не написал об Инварианте, предварительных условиях и условиях пост базового класса, которым должны следовать производные классы. Чтобы производный класс D полностью соответствовал Базовому классу B, класс D должен соответствовать определенным условиям:

  • Варианты базового класса должны быть сохранены производным классом
  • Предварительные условия базового класса не должны быть усилены производным классом
  • Постусловия базового класса не должны быть ослаблены производным классом.

Таким образом, производный должен знать о трех вышеупомянутых условиях, наложенных базовым классом. Следовательно, правила подтипирования заранее определены. Это означает, что отношения "IS A" должны соблюдаться только тогда, когда подтип подчиняется определенным правилам. Эти правила в форме инвариантов, предварительных условий и постусловий должны определяться формальным " контрактом на проектирование".

Дальнейшие обсуждения по этому вопросу доступны в моем блоге: принцип замещения Лискова

Большая картинка :

  • В чем суть принципа замещения Лискова? Это о том, что является (а что нет) подтипом данного типа.
  • Почему это так важно ? Потому что есть разница между подтипом и подклассом.

Пример

В отличие от других ответов, я начну не с нарушения принципа замещения Лискова (LSP), а с соблюдения LSP. Я использую Java, но это будет почти то же самое на всех языках ООП.

а также

Здесь довольно популярны геометрические примеры.

      class Circle {
    private int radius;

    public Circle(int radius) {
        if (radius < 0) {
            throw new RuntimeException("Radius should be >= 0");
        }
        this.radius = radius;
    }

    public int getRadius() {
        return this.radius;
    }
}

Радиус не может быть отрицательным. Вот suclass:

      class ColoredCircle extends Circle {
    private Color color; // defined elsewhere

    public ColoredCircle(int radius, Color color) {
        super(radius);
        this.color = color;
    }

    public Color getColor() {
        return this.color;
    }
}

Согласно LSP, этот подкласс является подтипом.

LSP заявляет, что:

Если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется, когда o1 заменяется на o2, тогда S является подтипом T. ( Барбара Лисков, «Абстракция данных и иерархия», SIGPLAN Notices, 23,5 (май 1988 г.))

Здесь для каждого экземпляра рассмотрим экземпляр с одинаковым радиусом. Для каждой программы, использующей объекты, если вы замените на, поведение любой программы, использующей объекты, останется таким же после замены. (Обратите внимание, что это теоретически: вы быстрее исчерпаете память, используя экземпляров, чем использование экземпляров, но здесь это не имеет отношения.)

Как мы находим зависимость? Мы просто снимаем атрибут и сохранить атрибут. Я называю трансформацию -> проекцией из пространство на пространстве.

Пример счетчика

Давайте создадим еще один пример, чтобы проиллюстрировать нарушение LSP.

а также

Представьте себе этот подкласс предыдущего класса:

      class Square extends Circle {
    private int sideSize;

    public Square(int sideSize) {
        super(0);
        this.sideSize = sideSize;
    }

    @Override
    public int getRadius() {
        return -1; // I'm a square, I don't care
    }

    public int getSideSize() {
        return this.sideSize;
    }
}

Нарушение LSP

Теперь посмотрим на эту программу:

      public class Liskov {
    public static void program(Circle c) {
        System.out.println("The radius is "+c.getRadius());
    }

Тестируем программу с объектом и с объектом.

          public static void main(String [] args){
        Liskov.program(new Circle(2)); // prints "The radius is 2"
        Liskov.program(new Square(2)); // prints "The radius is -1"
    }
}

Что случилось ? Интуитивно, хотя это подкласс, это не подтип, потому что ни один обычный экземпляр никогда не будет иметь радиус -1.

Формально это нарушение принципа замены Лискова.

У нас есть программа, определенная в терминах, и нет объекта, который мог бы заменить (или любой кстати) в этой программе и оставьте поведение без изменений: помните, что радиус любого всегда положительный.

Подкласс и подтип

Теперь мы знаем, почему подкласс не всегда является подтипом . Когда подкласс не является подтипом, т.е. когда есть нарушение LSP, поведение некоторых программ (по крайней мере, одной) не всегда будет ожидаемым. Это очень неприятно и обычно интерпретируется как ошибка.

В идеальном мире компилятор или интерпретатор сможет проверить, является ли данный подкласс реальным подтипом, но мы находимся не в идеальном мире.

Статическая типизация

Если есть некоторая статическая типизация, вы связаны сигнатурой суперкласса во время компиляции. не могу вернуть или .

Если статическая типизация отсутствует, вы получите сообщение об ошибке во время выполнения, если тип одного аргумента неправильный (если типизация не является слабой) или количество аргументов несовместимо (если язык не является очень разрешительным).

Примечание о статической типизации: существует механизм ковариации типа возвращаемого значения (метод S может возвращать подкласс типа возвращаемого значения того же метода T) и контравариантности типов параметров (метод S может принимать суперкласс параметра того же параметра того же метода T). Это конкретный случай предусловия и постусловия, описанный ниже.

Дизайн по контракту

Есть больше. Некоторые языки (я думаю о Eiffel) предоставляют механизм, обеспечивающий соблюдение LSP.

Не говоря уже об определении проекции исходного объекта, мы можем ожидать такого же поведения любой программы, если она заменена на if, для любого аргумента и любым способом:

  • если это действительный вызов, то также должен быть действительный вызов (1).
  • результат (возвращаемое значение, отображение на консоли и т. д.) должен быть равен результату или, по крайней мере, одинаково действителен (2).
  • должен позволить во внутреннем состоянии и должен позволить во внутреннем состоянии, так что следующие вызовы функций будут гарантировать, что (1), (2) и (3) будут по-прежнему действительны (3).

(Обратите внимание, что (3) предоставляется бесплатно, если функция чисто. Вот почему нам нравится иметь неизменяемые объекты.)

Эти условия касаются семантики (чего ожидать) класса, а не только синтаксиса класса. Кроме того, эти условия очень сильные. Но они могут быть аппроксимированы утверждениями в дизайне с помощью контрактного программирования. Эти утверждения - способ гарантировать соблюдение семантики типа. Нарушение контракта приводит к ошибкам во время выполнения.

  • Предварительное условие определяет, какой вызов является допустимым. При создании подкласса предусловие может быть только ослаблено (принимает больше чем) (a).
  • Постусловие определяет, какой результат является допустимым. При создании подкласса постусловие может быть только усилено ( обеспечивает более чем ) (б).
  • Инвариант определяет допустимое внутреннее состояние. При создании подкласса инвариант должен оставаться неизменным (c).

Мы видим, что, грубо говоря, (a) обеспечивает (1), а (b) обеспечивает (2), но (c) слабее, чем (3). Более того, утверждения иногда трудно выразить.

Подумайте о классе имея уникальный метод который возвращает следующее целое число. Как написать для этого постусловие? Подумайте о классе имея метод который возвращает значение с плавающей запятой от 0,0 до 1,0. Как написать постусловие для проверки гауссовского распределения? Это возможно, но стоимость будет настолько высока, что мы будем полагаться на тестирование, а не на постусловия.

Заключение

К сожалению, подкласс не всегда является подтипом. Это может привести к неожиданному поведению - ошибке.

Языки ООП предоставляют механизм, позволяющий избежать этой ситуации. Сначала на синтаксическом уровне. На семантическом уровне тоже, в зависимости от языка программирования: часть семантики может быть закодирована в тексте программы с помощью утверждений. Но вы должны убедиться, что подкласс является подтипом.

Помните, когда вы начали изучать ООП? «Если отношение IS-A, тогда используйте наследование». Это верно с другой стороны: если вы используете наследование, убедитесь, что отношение IS-A.

LSP определяет подтип на более высоком уровне, чем утверждения. Утверждения - ценный инструмент для обеспечения соблюдения LSP.

Квадрат - это прямоугольник, ширина которого равна высоте. Если квадрат устанавливает два разных размера для ширины и высоты, он нарушает инвариант квадрата. Это обходится путем введения побочных эффектов. Но если у прямоугольника есть setSize (высота, ширина) с предварительным условием 0 <высота и 0 <ширина. Метод производного подтипа требует высоты == ширина; более сильное предварительное условие (и это нарушает lsp). Это показывает, что хотя квадрат является прямоугольником, он не является допустимым подтипом, поскольку предварительное условие усиливается. Обход (вообще плохая вещь) вызывает побочный эффект, и это ослабляет почтовое условие (которое нарушает lsp). У setWidth на базе есть условие post 0

Поэтому квадрат с изменяемым размером не является прямоугольником с изменяемым размером.

Будет ли реализация ThreeDBoard с точки зрения массива Board настолько полезной?

Возможно, вы захотите рассматривать ломтики ThreeDBoard в различных плоскостях как доску. В этом случае вы можете захотеть абстрагировать интерфейс (или абстрактный класс) для Board, чтобы учесть несколько реализаций.

Что касается внешнего интерфейса, вы можете выделить интерфейс Board для TwoDBoard и ThreeDBoard (хотя ни один из вышеперечисленных методов не подходит).

Самым ясным объяснением для LSP, которое я нашел до сих пор, было "Принцип подстановки Лискова говорит, что объект производного класса должен иметь возможность заменить объект базового класса без внесения каких-либо ошибок в систему или изменения поведения базового класса". отсюда В статье приведен пример кода для нарушения LSP и его исправления.

Допустим, мы используем прямоугольник в нашем коде

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 экземпляры и поэтому они будут вызывать неожиданные ошибки в вашем коде.

Это был пример для части "предварительные условия не могут быть усилены в подтипе" в статье вики. Подводя итог, можно сказать, что нарушение LSP в какой-то момент приведет к ошибкам в вашем коде.

Принцип подстановки Вики Лисков (LSP)

Предпосылки нельзя усилить в подтипе. Постусловия не могут быть ослаблены в подтипе. Инварианты супертипа должны сохраняться в подтипе.

Предусловие и постусловие method's types [Около]

Предварительные условия (например, parameterType функции и parameterValue) могут быть такими же или более слабыми.

Постусловия (например, возвращаемый тип и возвращаемое значение функции) могут быть такими же или более сильными.

Инвариантная переменная [About] супертипа должна оставаться неизменной.

Быстрый пример

class C1 {}
class C2: C1 {}
class C3: C2 {}

class A {
    func foo(a: C2) -> C2 {
        return C2()
    }
}

class B: A {
    override func foo(a: C1) -> C3 {
        return C3()
    }
}

Ява

class C1 {}
class C2 extends C1 {}
class C3 extends C2 {}

class A {
    public C2 foo(C2 a) {
        return new C2();
    }
}

class B extends A {

    @Override
    public C3 foo(C2 a) { //You are available pass only C2 as parameter
        return new C3();
    }
}

Подтип

Контравариантность типов аргументов и ковариация возвращаемого типа.

  1. Контравариантность аргументов метода в подтипе.
  2. Ковариация возвращаемых типов в подтипе.
  3. Никакие новые исключения не должны генерироваться методами подтипа, за исключением случаев, когда эти исключения сами являются подтипами исключений, генерируемых методами супертипа.

[Дисперсия, ковариация, контравариантность]

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();
}