Вопрос

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

Это было полезно?

Решение 2

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

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

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

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

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

Class Diagram

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

Далее в книге изменяются требования, в которых указывается, что game frame work также должна поддерживать 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 (приведенным дядей Бобом в подкасте, который я недавно прослушал), было то, как иногда то, что звучит правильно на естественном языке, не совсем работает в коде.

В математике a Square является Rectangle.Действительно, это специализация прямоугольника."is a" заставляет вас захотеть смоделировать это с помощью наследования.Однако, если в коде вы сделали Square производный от Rectangle, затем a Square должен быть пригоден для использования в любом месте, где вы ожидаете Rectangle.Это приводит к некоторому странному поведению.

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

enter image description here

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

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.

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

давайте приведем простой пример на Java:

Плохой пример

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

Утка может летать, потому что она птица, Но как насчет этого:

public class Ostrich extends Bird{}

Ostrich - это птица, Но она не может летать, класс Ostrich - это подтип класса 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

Одним из наиболее вопиющих нарушений этого принципа является использование 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 необходим там, где какой-то код думает, что он вызывает методы определенного типа 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

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

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), которые имеют аннотации к дисперсии сайта определения параметров полиморфизма типов (т. е.generics), совпадающее или противоположное направление аннотации отклонения для каждого параметра типа T должно быть противоположный или в том же направлении соответственно для каждого входного параметра или вывода (каждого метода T), который имеет тип параметра type.

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


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

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

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

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

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

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

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

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

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

На Псевдо-python

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

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

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

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

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

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

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

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

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

Существует контрольный список, чтобы определить, нарушаете ли вы 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 #), поэтому они меня не волнуют.

Ссылка:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Допустим, у вас есть База Хранилище предметов.

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 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://www.udemy.com/enterprise-php/

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Пример:

Ниже приведен классический пример, для которого нарушается принцип подстановки Лискова.В приведенном примере используются 2 класса:Прямоугольник и Квадрат.Давайте предположим, что объект Rectangle используется где-то в приложении.Мы расширяем приложение и добавляем класс Square.Класс square возвращается заводским шаблоном, основанным на некоторых условиях, и мы не знаем точно, какой тип объекта будет возвращен.Но мы знаем, что это Прямоугольник.Мы получаем объект rectangle, устанавливаем ширину равной 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, и это означает, что мы должны убедиться, что новые производные классы расширяют базовые классы без изменения их поведения.

Смотрите также: Принцип Открытия Закрытия

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

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

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

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

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

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

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

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.Итак, подводя итог, можно сказать, что нарушение LSP, вероятно, в какой-то момент приведет к ошибкам в вашем коде.

Давайте проиллюстрируем это на 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) { ... }
}

Теперь мы можем расширить возможности транспортировки для немоторизованных устройств.

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, которое я нашел до сих пор, было "Принцип замены Лискова гласит, что объект производного класса должен иметь возможность заменять объект базового класса без внесения каких-либо ошибок в систему или изменения поведения базового класса" из здесь.В статье приведен пример кода для нарушения LSP и его исправления.

ПРИНЦИП ПОДСТАНОВКИ ЛИСКОВА (из книги Марка Симанна) гласит, что мы должны быть в состоянии заменить одну реализацию интерфейса другой, не нарушая работу ни клиента, ни реализации.Именно этот принцип позволяет учитывать требования, которые возникнут в будущем, даже если мы не можем предвидеть их сегодня.

Если мы отключаем компьютер от сети (реализация), ни розетка (интерфейс), ни компьютер (клиент) не выходят из строя (фактически, если это портативный компьютер, он может даже некоторое время работать от батареек).Однако при использовании программного обеспечения клиент часто ожидает, что услуга будет доступна.Если служба была удалена, мы получаем исключение NullReferenceException.Чтобы справиться с ситуацией такого типа, мы можем создать реализацию интерфейса, который “ничего не делает”. Это шаблон проектирования, известный как нулевой объект [4], и он примерно соответствует отключению компьютера от сети.Поскольку мы используем слабую связь, мы можем заменить реальную реализацию чем-то, что ничего не делает, не вызывая проблем.

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

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

Пример - Одновариантные возвращаемые типы в java.

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

Позвольте мне попробовать, рассмотреть интерфейс:

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

Примеры взяты из здесь

Вот выдержка из этот пост это все прекрасно проясняет:

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

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

Рассмотрим следующий пример:

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

Вуаля, теперь контракт выполнен.

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

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

И это автоматически нарушает принцип "открыто-закрыто" [то есть требование о выводе денег.Потому что вы никогда не знаете, что произойдет, если у объекта, нарушающего контракт, не хватит денег.Вероятно, это просто ничего не возвращает, вероятно, будет выдано исключение.Поэтому вы должны проверить, действительно ли это hasEnoughMoney() -- который не является частью интерфейса.Таким образом, эта принудительная проверка, зависящая от конкретного класса, является нарушением OCP].

Этот пункт также касается неправильного представления о нарушении LSP, с которым я довольно часто сталкиваюсь.В нем говорится, что “если поведение родителя изменилось у дочернего элемента, то это нарушает LSP”. Однако это не так — до тех пор, пока дочерний элемент не нарушает контракт своего родителя.

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top