Я слышал, что принцип замещения Лискова (LSP) является фундаментальным принципом объектно-ориентированного проектирования. Что это такое и каковы некоторые примеры его использования?
Я слышал, что принцип замещения Лискова (LSP) является фундаментальным принципом объектно-ориентированного проектирования. Что это такое и каковы некоторые примеры его использования?
Ответы:
Отличным примером, иллюстрирующим LSP (данный дядей Бобом в подкасте, который я недавно слышал), было то, как иногда что-то, что звучит правильно на естественном языке, не совсем работает в коде.
В математике а Square
есть Rectangle
. На самом деле это специализация прямоугольника. «Is» заставляет вас моделировать это с наследованием. Однако, если в коде, который вы сделали, Square
происходит от Rectangle
, то a Square
должен использоваться везде, где вы ожидаете a Rectangle
. Это делает для некоторого странного поведения.
Представьте, что у вас есть SetWidth
и SetHeight
методы в вашем Rectangle
базовом классе; это кажется совершенно логичным. Однако, если ваша Rectangle
ссылка указывает на a Square
, тогда SetWidth
и SetHeight
не имеет смысла, потому что установка одного изменит другой в соответствии с ним. В этом случае Square
не проходит Лисковский тест с заменой, Rectangle
и абстракция наличия Square
наследования Rectangle
является плохой.
Вы все должны проверить другие бесценные мотивационные плакаты Принципов SOLID .
Square.setWidth(int width)
была реализована следующим образом: this.width = width; this.height = width;
? В этом случае гарантируется, что ширина равна высоте.
Принцип замещения Лискова (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.
Подстановочность является принципом объектно-ориентированного программирования, утверждающим, что в компьютерной программе, если 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{}
Bird bird
. Вы должны бросить объект в FlyingBirds, чтобы использовать муху, что не очень хорошо, верно?
Bird bird
, это означает, что он не может использовать fly()
. Вот и все. Передача Duck
не меняет этот факт. Если клиент имеет FlyingBirds bird
, то, даже если он прошел, Duck
он всегда должен работать одинаково.
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
объект будет поврежден, потому что высота не будет изменена. Это явное нарушение ЛСП. Функция не работает для производных своих аргументов.[...]
Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
если предварительное условие дочернего класса сильнее предварительного условия родительского класса, вы не можете заменить дочернего элемента родителем, не нарушив этого предварительного условия. Отсюда и 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
Даже у типа с неизменяемыми полями все еще есть инварианты, например неизменяемые установщики 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
не контравариантен WRT к Ti
, то подтип Xi
-Какой не будет подтипом Si
-Не могли бы быть назначены Ti
.
Кроме того, для языков (например, Scala или Ceylon), которые имеют аннотации на сайте определения параметров полиморфизма типов (т. Е. Обобщения), совместное или противоположное направление аннотации для каждого параметра типа этого типа T
должно быть противоположным или одинаковым направлением. соответственно каждому входному параметру или выходу (каждого метода T
), который имеет тип параметра типа.
Кроме того, для каждого входного параметра или выхода, который имеет тип функции, требуемое направление отклонения меняется на противоположное. Это правило применяется рекурсивно.
Подтип подходит для тех случаев, когда можно перечислить инварианты.
В настоящее время проводится много исследований о том, как моделировать инварианты, чтобы они обеспечивались компилятором.
Typestate (см. Стр. 3) объявляет и применяет инварианты состояния, ортогональные типу. Альтернативно, инварианты могут быть реализованы путем преобразования утверждений в типы . Например, чтобы утверждать, что файл открыт до его закрытия, File.open () может вернуть тип OpenFile, который содержит метод close (), недоступный в File. Крестики-нолики API , может быть еще одним примером применения печатать для обеспечения инвариантов во время компиляции. Система типов может быть даже полной по Тьюрингу, например, Scala . Языки с независимой типизацией и доказатели теорем формализуют модели типизации высшего порядка.
Из-за необходимости семантики абстрагироваться от расширения , я ожидаю, что использование типизации для моделирования инвариантов, то есть унифицированной денотационной семантики высшего порядка, превосходит Typestate. «Расширение» означает неограниченную пермутированную композицию несогласованного модульного развития. Поскольку мне кажется, что антитеза объединения и, следовательно, степеней свободы, иметь две взаимозависимые модели (например, типы и Typestate) для выражения общей семантики, которые не могут быть объединены друг с другом для расширяемой композиции. , Например, похожее на выражение выражение было унифицировано в областях подтипирования, перегрузки функций и параметрической типизации.
Моя теоретическая позиция заключается в том, что для существования знаний (см. Раздел «Централизация слепа и непригодна») никогда не будет общей модели, которая могла бы обеспечить 100% -ное покрытие всех возможных инвариантов на языке полного языка Тьюринга. Чтобы знания существовали, неожиданных возможностей много, то есть беспорядок и энтропия всегда должны увеличиваться. Это энтропийная сила. Чтобы доказать все возможные вычисления потенциального расширения, нужно заранее вычислить все возможные расширения.
Вот почему существует теорема Остановки, т. Е. Неразрешимо, завершается ли каждая возможная программа на языке Тьюринга. Можно доказать, что какая-то конкретная программа завершается (та, для которой все возможности были определены и вычислены). Но невозможно доказать, что все возможные расширения этой программы прекращаются, если только возможности расширения этой программы не являются полными по Тьюрингу (например, через зависимую типизацию). Поскольку основным требованием для полноты по Тьюрингу является неограниченная рекурсия , интуитивно понятно, как теоремы Гёделя о неполноте и парадокс Рассела применимы к расширению.
Интерпретация этих теорем включает их в обобщенное концептуальное понимание энтропийной силы:
Я вижу прямоугольники и квадраты в каждом ответе, и как нарушать 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 !
}
}
Теперь подтипы нельзя использовать одинаково, поскольку они больше не дают того же результата.
Database::selectQuery
поддержки только подмножества SQL, поддерживаемого всеми механизмами БД. Это вряд ли практично ... Тем не менее, пример все еще легче понять, чем большинство других, используемых здесь.
Существует контрольный список, чтобы определить, нарушаете ли вы или нет Лискова.
Контрольный список:
Ограничение истории : при переопределении метода вам не разрешено изменять немодифицируемое свойство в базовом классе. Взгляните на этот код, и вы увидите, что Имя определено как немодифицируемое (закрытый набор), но 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 - это правило о договоре предложений: если базовый класс удовлетворяет договору, то производные классы LSP также должны удовлетворять этому договору.
В псевдо-питоне
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
удовлетворяет LSP, если каждый раз, когда вы вызываете Foo для объекта Derived, он дает те же результаты, что и вызов Foo для объекта Base, при условии, что аргумент arg одинаков.
2 + "2"
). Возможно, вы путаете «строго типизированный» со «статически типизированным»?
Длинная короткая история, давайте оставить прямоугольники прямоугольники и квадраты квадратов, практический пример , когда расширение родительского класса, вы должны либо сохранить точный родительский 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 нарушается , когда подставляя родительский класс с суб брейков класса контракта АНИ в .
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, я предположил, что это подразумевалось в очень строгом смысле, по сути приравнивая его к реализации интерфейса и приведению типов к типу. Что означало бы, что LSP обеспечивается или не обеспечивается самим языком. Например, в этом строгом смысле ThreeDBoard, безусловно, заменяет Board в том, что касается компилятора.
После прочтения более подробно о концепции, я обнаружил, что LSP обычно интерпретируется более широко, чем это.
Короче говоря, то, что означает для клиентского кода «знать», что объект за указателем имеет производный тип, а не тип указателя, не ограничивается безопасностью типов. Приверженность LSP также может быть проверена путем исследования фактического поведения объектов. Таким образом, исследуя влияние состояния объекта и аргументов метода на результаты вызовов метода или типы исключений, выбрасываемых из объекта.
Возвращаясь к примеру снова, теоретически можно заставить методы Board работать на ThreeDBoard просто отлично. На практике, однако, будет очень трудно предотвратить различия в поведении, которые клиент может не обработать должным образом, не ограничивая функциональность, которую ThreeDBoard намеревается добавить.
Имея эти знания в руках, оценка соблюдения LSP может быть отличным инструментом для определения того, когда композиция является более подходящим механизмом для расширения существующей функциональности, а не наследования.
Полагаю, что все понимали, что такое LSP технически: вы в основном хотите абстрагироваться от деталей подтипов и безопасно использовать супертипы.
Итак, у Лискова есть 3 базовых правила:
Правило подписи: должна быть правильная реализация каждой операции надтипа в подтипе синтаксически. Что-то, что компилятор сможет проверить для вас. Существует небольшое правило о том, что нужно генерировать меньше исключений и быть по крайней мере таким же доступным, как методы супертипа.
Правило методов: реализация этих операций семантически обоснована.
Правило свойств: это выходит за рамки отдельных вызовов функций.
Все эти свойства должны быть сохранены, и дополнительная функциональность подтипа не должна нарушать свойства супертипа.
Если об этих трех вещах заботятся, вы абстрагируетесь от базовых вещей и пишете слабосвязанный код.
Источник: Разработка программ на Java - Барбара Лисков
Важным примером использования LSP является тестирование программного обеспечения .
Если у меня есть класс A, который является LSP-совместимым подклассом B, то я могу повторно использовать набор тестов B для тестирования A.
Чтобы полностью протестировать подкласс A, мне, вероятно, нужно добавить еще несколько тестовых случаев, но как минимум я могу повторно использовать все тестовые случаи суперкласса B.
Это можно понять, построив то, что Макгрегор называет «параллельной иерархией для тестирования»: мой ATest
класс унаследован от BTest
. Затем требуется некоторая форма внедрения, чтобы тест-кейс работал с объектами типа A, а не типа B (подойдет простой шаблонный метод).
Обратите внимание, что повторное использование набора супер-тестов для всех реализаций подкласса на самом деле является способом проверки того, что эти реализации подкласса совместимы с LSP. Таким образом, можно также утверждать, что следует запускать тестовый набор суперкласса в контексте любого подкласса.
См. Также ответ на вопрос Stackoverflow: « Могу ли я реализовать серию повторно используемых тестов для проверки реализации интерфейса? »
Давайте проиллюстрируем на 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 слишком сильна:
Если для каждого объекта o1 типа S существует объект o2 типа T, такой, что для всех программ P, определенных в терминах T, поведение P остается неизменным, когда o1 заменяется на o2, тогда S является подтипом T.
Что в основном означает, что S - это другая, полностью инкапсулированная реализация той же вещи, что и T. И я мог бы быть смелым и решить, что производительность является частью поведения P ...
Так что, в принципе, любое использование позднего связывания нарушает LSP. Весь смысл ОО в том, чтобы получить другое поведение, когда мы заменяем объект одного вида другим!
Формулировка, на которую ссылается Википедия , лучше, поскольку свойство зависит от контекста и не обязательно включает в себя все поведение программы.
В очень простом предложении мы можем сказать:
Дочерний класс не должен нарушать характеристики своего базового класса. Он должен быть в состоянии с этим. Мы можем сказать, что это так же, как подтип.
Принцип замещения Лискова (LSP)
Все время мы разрабатываем программный модуль и создаем некоторые иерархии классов. Затем мы расширяем некоторые классы, создавая некоторые производные классы.
Мы должны убедиться, что новые производные классы просто расширяются, не заменяя функциональность старых классов. В противном случае новые классы могут создавать нежелательные эффекты, когда они используются в существующих программных модулях.
Принцип подстановки Лискова гласит, что если программный модуль использует базовый класс, то ссылка на базовый класс может быть заменена производным классом, не влияя на функциональность программного модуля.
Пример:
Ниже приведен классический пример нарушения принципа подстановки Лискова. В примере используются 2 класса: Rectangle и Square. Давайте предположим, что объект Rectangle используется где-то в приложении. Расширяем приложение и добавляем класс Square. Класс 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
Некоторые похожие концепции для лучшей структуры: Соглашение по конфигурации
Некоторое дополнение:
я удивляюсь, почему никто не написал об Инварианте, предварительных условиях и пост-условиях базового класса, которым должны следовать производные классы. Чтобы производный класс D полностью соответствовал базовому классу B, класс D должен соответствовать определенным условиям:
Таким образом, производный должен знать о трех вышеупомянутых условиях, наложенных базовым классом. Следовательно, правила подтипирования заранее определены. Это означает, что отношения «IS A» должны соблюдаться только тогда, когда подтип подчиняется определенным правилам. Эти правила в форме инвариантов, предварительных условий и постусловий должны определяться формальным « контрактом на проектирование ».
Дальнейшие обсуждения по этому вопросу доступны в моем блоге: принцип замещения Лискова
Проще говоря, LSP утверждает, что объекты одного и того же суперкласса должны иметь возможность обмениваться друг с другом, не нарушая ничего.
Например, если у нас есть Cat
и Dog
класс, производный от Animal
класса, любые функции, использующие класс Animal, должны иметь возможность использовать Cat
или Dog
вести себя нормально.
Будет ли реализация ThreeDBoard с точки зрения массива Board настолько полезной?
Возможно, вы захотите рассматривать ломтики ThreeDBoard в различных плоскостях как доску. В этом случае вы можете захотеть абстрагировать интерфейс (или абстрактный класс) для Board, чтобы учесть несколько реализаций.
Что касается внешнего интерфейса, вы можете выделить интерфейс Board для TwoDBoard и ThreeDBoard (хотя ни один из вышеперечисленных методов не подходит).
Квадрат - это прямоугольник, ширина которого равна высоте. Если квадрат устанавливает два разных размера для ширины и высоты, он нарушает инвариант квадрата. Это обходится путем введения побочных эффектов. Но если у прямоугольника есть setSize (высота, ширина) с предварительным условием 0 <высота и 0 <ширина. Метод производного подтипа требует height == width; более сильное предварительное условие (и это нарушает LSP). Это показывает, что хотя квадрат является прямоугольником, он не является допустимым подтипом, поскольку предварительное условие усиливается. Обход (вообще плохая вещь) вызывает побочный эффект, и это ослабляет почтовое условие (которое нарушает lsp). У setWidth на базе есть условие post 0 <width. Производная ослабляет его с высотой == ширина.
Поэтому квадрат с изменяемым размером не является прямоугольником с изменяемым размером.
Этот принцип был введен Барбарой Лисков в 1987 году и расширяет принцип Open-Closed, сосредоточив внимание на поведении суперкласса и его подтипов.
Его важность становится очевидной, когда мы рассматриваем последствия его нарушения. Рассмотрим приложение, которое использует следующий класс.
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;
}
}
}
Представьте, что однажды клиент требует умения манипулировать квадратами в дополнение к прямоугольникам. Поскольку квадрат является прямоугольником, класс квадрата должен быть производным от класса 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. Фактически создание производного квадрата класса вызывает изменения в прямоугольнике базового класса.
Самым ясным объяснением для 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 говорит, что «объекты должны быть заменены их подтипами». С другой стороны, этот принцип указывает на
Дочерние классы никогда не должны нарушать определения типов родительского класса.
и следующий пример помогает лучше понять 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();
}
Я рекомендую вам прочитать статью: Нарушение принципа подстановки Лискова (LSP) .
Там вы можете найти объяснение принципа подстановки Лискова, общие подсказки, которые помогут вам угадать, если вы уже нарушили его, и пример подхода, который поможет вам сделать вашу иерархию классов более безопасной.
ПРИНЦИП ЗАМЕНЫ ЛИСКОВ (Из книги Марка Симанна) утверждает, что мы должны иметь возможность заменить одну реализацию интерфейса на другую, не нарушая ни клиента, ни реализацию. Это тот принцип, который позволяет удовлетворять требования, возникающие в будущем, даже если мы можем ' не предвидеть их сегодня.
Если мы отсоединяем компьютер от стены (Внедрение), ни настенная розетка (Интерфейс), ни компьютер (Клиент) не выходят из строя (фактически, если это портативный компьютер, он может даже работать от батарей в течение определенного периода времени) , Однако с программным обеспечением клиент часто ожидает, что услуга будет доступна. Если служба была удалена, мы получаем исключение NullReferenceException. Чтобы справиться с ситуацией такого типа, мы можем создать реализацию интерфейса, который «ничего не делает». Это шаблон проектирования, известный как Null Object, [4], и он примерно соответствует отключению компьютера от стены. Поскольку мы используем слабую связь, мы можем заменить реальную реализацию чем-то, что ничего не делает, не вызывая проблем.
Принцип подстановки Ликова гласит, что если программный модуль использует базовый класс, то ссылку на базовый класс можно заменить производным классом, не влияя на функциональность программного модуля.
Намерение - Производные типы должны полностью заменить свои базовые типы.
Пример - ко-вариантные типы возврата в Java.
Вот выдержка из этого поста, в которой все проясняется:
[..] Чтобы понять некоторые принципы, важно понимать, когда они были нарушены. Это то, что я буду делать сейчас.
Что означает нарушение этого принципа? Это подразумевает, что объект не выполняет контракт, наложенный абстракцией, выраженной интерфейсом. Другими словами, это означает, что вы неправильно определили свои абстракции.
Рассмотрим следующий пример:
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». Тем не менее, это не так - до тех пор, пока ребенок не нарушает родительский договор.