Что является примером принципа подстановки Лискова? - PullRequest
783 голосов
/ 11 сентября 2008

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

Ответы [ 28 ]

754 голосов
/ 25 февраля 2009

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

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

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

enter image description here

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

439 голосов
/ 11 сентября 2008

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

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

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

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

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

Class Diagram

Все методы принимают координаты 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.

122 голосов
/ 12 сентября 2008

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.

80 голосов
/ 04 июля 2017

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

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

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

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

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

public class Ostrich extends Bird{}

Страус - это птица, но он не может летать, класс Страус - это подтип класса Bird, но он не может использовать метод fly, то есть мы нарушаем принцип LSP.

Хороший пример

public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{} 
71 голосов
/ 12 сентября 2008

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

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

Простой пример нарушения LSP

Одним из наиболее вопиющих нарушений этого принципа является использование C ++ Информация о типе времени выполнения (RTTI) для выбора функции на основе тип объекта. i.e.:

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

[...]

40 голосов
/ 26 ноября 2011

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 имел контравариантные входные параметры и ковариантный вывод.

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

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


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

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

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

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

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

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

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

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

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

In Pseudo-python

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

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

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

19 голосов
/ 11 сентября 2008

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

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

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

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

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

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

18 голосов
/ 13 августа 2016

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

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

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

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

Ссылка:

17 голосов
/ 26 марта 2013

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

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

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

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

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...