Я чув, що Принцип заміщення Ліскова (ЛСП) є основним принципом об'єктно-орієнтованого проектування. Що це таке і які приклади його використання?
Я чув, що Принцип заміщення Ліскова (ЛСП) є основним принципом об'єктно-орієнтованого проектування. Що це таке і які приклади його використання?
Відповіді:
Чудовий приклад, що ілюструє LSP (наданий дядьком Боб у подкасті, який я чув недавно), - це те, як іноді щось, що звучить правильно на природній мові, не дуже працює в коді.
У математиці а Square
- це a Rectangle
. Адже це спеціалізація прямокутника. Значення "є" змушує вас моделювати це за допомогою спадкування. Однак якщо в коді, який ви Square
отримали Rectangle
, то ви Square
повинні використовуватись у будь-якому місці, де ви очікуєте Rectangle
. Це спричиняє деяку дивну поведінку.
Уявіть, що у вас в базовому класі були методи SetWidth
та SetHeight
методи Rectangle
; це здається цілком логічним. Однак , якщо ваша Rectangle
посилання вказала на Square
, то SetWidth
і SetHeight
не має сенсу , тому що установка одного змінять інше , щоб відповідати цьому. У цьому випадку Square
проходить тест на заміщення Ліскова, Rectangle
а абстракція Square
спадщини Rectangle
є поганою.
Вам слід ознайомитись з іншими безцінними мотиваційними плакатами ТВОРИХ Принципів .
Square.setWidth(int width)
була реалізована так this.width = width; this.height = width;
:? У цьому випадку гарантується, що ширина дорівнює висоті.
Принцип заміщення Ліскова (LSP, lsp) - це поняття в об'єктно-орієнтованому програмуванні, яке говорить:
Функції, що використовують покажчики або посилання на базові класи, повинні мати можливість використовувати об'єкти похідних класів, не знаючи цього.
В основі LSP лежить інтерфейс і контракти, а також як вирішити, коли розширити клас порівняно з іншою стратегією, такою як композиція для досягнення своєї мети.
Найбільш ефективний спосіб , який я бачив , щоб проілюструвати цей момент був в Head First ООА & 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{}
Страус - птах, але він не вміє літати, клас страусів - це підтип класу Птах, але він не може використовувати метод мухи, це означає, що ми порушуємо принцип 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()
. Це воно. Проходження a 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
Навіть тип з непорушними полями все ще має інваріанти, наприклад, незмінні сектори прямокутника очікують, що розміри будуть незалежно модифіковані, але незмінні сеттери Квадрату порушують це очікування.
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
не було противаріантним WTT, тоді можна було б призначити Ti
підтип, Xi
якому не було б підтип .Si
Ti
Крім того, для мов (наприклад, Scala або Цейлон), які мають анотації дисперсії на місці визначення типових параметрів поліморфізму (тобто генерики), спів- або контрарсив дисперсійної анотації для кожного параметра типу типу T
повинен бути протилежним або однаковим напрямком відповідно до кожного вхідного параметра або виводу (кожного методу T
), який має тип параметру типу.
Крім того, для кожного вхідного параметра або виходу, який має тип функції, потрібний напрямок дисперсії змінюється на зворотному рівні. Це правило застосовується рекурсивно.
Підтипування підходить там, де інваріанти можуть бути перераховані.
Наразі триває багато досліджень, як моделювати інваріанти, щоб їх застосовував компілятор.
Typestate (див. Сторінку 3) оголошує та застосовує інваріанти стану, ортогональні типу. Альтернативно, інваріанти можуть бути застосовані шляхом перетворення тверджень у типи . Наприклад, щоб стверджувати, що файл відкритий перед його закриттям, тоді File.open () може повернути тип OpenFile, який містить метод close (), який недоступний у файлі. Хрестики-нулики API , може бути ще одним прикладом застосування друкувати для забезпечення інваріантів під час компіляції. Система типу може бути навіть комплектацією Тьюрінга, наприклад, Scala . Залежно типізовані мови та докази теореми формалізують моделі типізації вищого порядку.
Через необхідність семантики в абстрагуванні над розширенням , я очікую, що використання типізації для моделювання інваріантів, тобто уніфікованої денотаційної семантики вищого порядку, перевершує Typestate. "Розширення" означає необмежену, перестановлену композицію неузгодженого модульного розвитку. Оскільки мені здається, що є антитезою об'єднання і, таким чином, ступенів свободи, мати дві взаємозалежні моделі (наприклад, типи і Typestate) для вираження спільної семантики, які не можуть бути уніфіковані один з одним для розширюваної композиції . Наприклад, розширення, подібне до виразу Expression , було уніфіковано в області підтипу, перевантаження функцій та параметрів параметричного введення тексту.
Моя теоретична позиція полягає в тому, що для існування знань (див. Розділ «Централізація сліпа і непридатна») ніколи не буде загальної моделі, яка зможе забезпечити 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);
}
}
Є ще два пункти: Протилежність аргументів методу та Коваріація типів повернення . Але це неможливо в C # (я розробник C #), тому я не дбаю про них.
Довідка:
LSP - це правило про договір пунктів: якщо базовий клас задовольняє контракт, то класи, отримані LSP, також повинні задовольняти цьому договору.
У псевдопітона
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
задовольняє LSP, якщо кожен раз, коли ви викликаєте Foo на похідному об'єкті, він дає точно такі ж результати, як і виклик Foo на об'єкт Base, доки аргумент буде однаковим.
2 + "2"
). Можливо, ви плутаєте "сильно набраний" зі "статично набраним"?
Якщо коротко розповісти, залишимо прямокутники прямокутників і квадратів, практичний приклад під час розширення батьківського класу, ви повинні або ЗАБЕЗПЕЧИТИ точний API для батьків, або розширити його.
Скажімо, у вас є базовий ItemRepository.
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 є технічно: Ви в основному хочете мати можливість абстрагуватися від деталей підтипу та безпечно використовувати супертипи.
Отже, у Ліскова є три основні правила:
Правило підпису: Кожна операція суперпертипу в підтипі має бути синтаксичною. Щось компілятор зможе перевірити для вас. Існує невелике правило про те, щоб кидати менше винятків і бути принаймні настільки ж доступними, як і методи супертипу.
Правило методів: Виконання цих операцій є семантично обгрунтованим.
Правило властивостей: Це виходить за межі окремих викликів функцій.
Усі ці властивості потрібно зберегти, а додаткова функціональність підтипу не повинна порушувати властивості супертипу.
Якщо про ці три речі обережно, ви абстрагувались від базових речей і ви пишете нещільно пов'язаний код.
Джерело: Розробка програми на Java - Барбара Лісков
Важливим прикладом використання LSP є тестування програмного забезпечення .
Якщо у мене є клас A, який відповідає LSP підкласу B, я можу повторно використати тестовий набір B для тестування А.
Для повного випробування підкласу 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 () неможливо реалізувати.
Ось такі проблеми, до яких призводить порушення Принципу заміни Ліскова, і їх найчастіше можна розпізнати методом, який нічого не робить або навіть не може бути реалізований.
Вирішення цих проблем - це правильна ієрархія спадкування, і в нашому випадку ми б вирішили проблему, диференціювавши класи транспортних пристроїв з двигунами і без них. Незважаючи на те, що велосипед є транспортним пристроєм, він не має двигуна. У цьому прикладі наше визначення транспортного пристрою неправильне. Він не повинен мати двигуна.
Ми можемо переробляти наш клас TransportDevice наступним чином:
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() { ... }
}
А також розширити TransportDevice для моторизованих пристроїв. Ось доцільніше додати об’єкт 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() { ... }
}
Така рецептура ЛСП занадто сильна:
Якщо для кожного об'єкта o1 типу S є об’єкт o2 типу T таким, що для всіх програм, визначених у терміні T, поведінка P не змінюється, коли o1 заміщений на o2, то S є підтипом T.
Що в основному означає, що S - це інша, повністю інкапсульована реалізація абсолютно тієї ж речі, що і Т.
Отже, в основному будь-яке використання пізнього зв’язування порушує LSP. Вся суть ОО полягає в тому, щоб отримати іншу поведінку, коли ми замінюємо об'єкт одного виду іншим на інший!
Формулювання, на яке посилається wikipedia , краще, оскільки властивість залежить від контексту і не обов'язково включає всю поведінку програми.
У дуже простому реченні ми можемо сказати:
Дочірній клас не повинен порушувати його базових характеристик. Він повинен бути з цим здатний. Можна сказати, що це те саме, що підтипу.
Принцип заміщення Ліскова (LSP)
Весь час ми розробляємо програмний модуль і створюємо деякі ієрархії класів. Потім ми розширюємо деякі класи, створюючи деякі похідні класи.
Ми повинні переконатися, що нові похідні класи просто поширюються, не замінюючи функціональність старих класів. В іншому випадку нові класи можуть створювати небажані ефекти, коли вони використовуються в існуючих програмних модулях.
Принцип заміщення Ліскова стверджує, що якщо програмний модуль використовує клас Base, то посилання на клас Base може бути замінено на клас похідних, не впливаючи на функціональність програмного модуля.
Приклад:
Нижче наведено класичний приклад, для якого порушено Принцип заміщення Ліскова. У прикладі використовуються 2 класи: Прямокутник і Квадрат. Припустимо, що об’єкт Rectangle використовується десь у додатку. Розширюємо додаток і додаємо клас Square. Квадратний клас повертається заводським зразком, виходячи з деяких умов, і ми не знаємо, який саме тип об’єкта буде повернутий. Але ми знаємо, що це прямокутник. Отримуємо об’єкт прямокутника, встановлюємо ширину 5 і висоту 10 і отримуємо площу. Для прямокутника шириною 5 і висотою 10 площа повинна бути 50. Натомість результат буде 100
// Violation of Likov's Substitution Principle
class Rectangle {
protected int m_width;
protected int m_height;
public void setWidth(int width) {
m_width = width;
}
public void setHeight(int height) {
m_height = height;
}
public int getWidth() {
return m_width;
}
public int getHeight() {
return m_height;
}
public int getArea() {
return m_width * m_height;
}
}
class Square extends Rectangle {
public void setWidth(int width) {
m_width = width;
m_height = width;
}
public void setHeight(int height) {
m_width = height;
m_height = height;
}
}
class LspTest {
private static Rectangle getNewRectangle() {
// it can be an object returned by some factory ...
return new Square();
}
public static void main(String args[]) {
Rectangle r = LspTest.getNewRectangle();
r.setWidth(5);
r.setHeight(10);
// user knows that r it's a rectangle.
// It assumes that he's able to set the width and height as for the base
// class
System.out.println(r.getArea());
// now he's surprised to see that the area is 100 instead of 50.
}
}
Висновок:
Цей принцип є лише розширенням принципу відкритого закриття, і це означає, що ми повинні переконатися, що нові похідні класи розширюють базові класи, не змінюючи їх поведінку.
Дивіться також: Відкрийте принцип закриття
Деякі подібні поняття для кращої структури: Конвенція щодо конфігурації
Додаток:
Цікаво, чому ніхто не написав про Інваріант, передумови та умови виконання базового класу, яким повинні відповідати похідні класи. Щоб похідний клас D повністю підходить базовому класу B, клас D повинен відповідати певним умовам:
Таким чином, похідні повинні знати про вищезазначені три умови, встановлені базовим класом. Отже, правила підтипу заздалегідь прийняті. Що означає, що відносини "IS A" повинні дотримуватися лише тоді, коли підтип дотримується певних правил. Ці правила, у вигляді інваріантів, прекодицій та посткондицій, повинні вирішуватися офіційним договором на проектування .
Подальші дискусії з цього приводу доступні в моєму блозі: Принцип заміни Ліскова
LSP, простіше кажучи, зазначає, що об’єкти одного і того ж надкласу повинні мати можливість міняти місцями один з одним, нічого не порушуючи.
Наприклад, якщо у нас є Cat
і в Dog
клас , похідний від Animal
класу, будь-які функції , що використовують клас Animal повинні бути в змозі використати Cat
або Dog
й поводяться нормально.
Чи корисна б реалізація ThreeDBoard з точки зору масиву Ради?
Можливо, ви, можливо, захочете обробляти шматочки ThreeDBoard у різних площинах як Раду. У такому випадку ви можете вилучити інтерфейс (або абстрактний клас) для Board, щоб дозволити кілька реалізацій.
Що стосується зовнішнього інтерфейсу, то, можливо, ви захочете визначити інтерфейс плати як для TwoDBoard, так і для ThreeDBoard (хоча жоден із перерахованих вище методів не підходить).
Квадрат - це прямокутник, де ширина дорівнює висоті. Якщо квадрат задає два різних розміри за шириною та висотою, він порушує квадрат інваріант. Це вирішується шляхом введення побічних ефектів. Але якщо прямокутник мав заданий розмір (висота, ширина) з попередньою умовою 0 <висота та 0 <ширина. Похідний метод підтипу вимагає висоти == ширина; сильніша передумова (і це порушує lsp). Це показує, що хоча квадрат є прямокутником, він не є дійсним підтипом, оскільки попередня умова посилена. Робота навколо (взагалі погана річ) викликає побічний ефект, і це послаблює стан поста (що порушує lsp). setWidth на базі має стан пост 0 <ширина. Отримане послаблює його з висотою == шириною.
Тому квадрат, що змінюється, не є прямокутником із зміною розміру.
Цей принцип був введений Барбарою Ліськовою в 1987 році і поширює принцип відкритого закриття, акцентуючи увагу на поведінці надкласу та його підтипів.
Його важливість стає очевидною, коли ми враховуємо наслідки її порушення. Розглянемо додаток, який використовує наступний клас.
public class Rectangle
{
private double width;
private double height;
public double Width
{
get
{
return width;
}
set
{
width = value;
}
}
public double Height
{
get
{
return height;
}
set
{
height = value;
}
}
}
Уявіть, що одного дня клієнт вимагає вміння маніпулювати квадратами крім прямокутників. Оскільки квадрат є прямокутником, клас квадрата повинен бути похідний від класу Прямокутник.
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, оскільки функція не працює для похідних його аргументів. Ширина та висота властивостей не є поліморфними, оскільки вони не оголошені віртуальними у прямокутнику (квадратний об’єкт буде пошкоджений, оскільки висота не буде змінена).
Однак, оголошуючи властивості сеттера віртуальними, ми зіштовхнемось з іншим порушенням, 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
екземплярів, і тому вони спричинять несподівані помилки у вашому коді.
Це був приклад для того, що "передумови не можна посилити в підтипі" у статті wiki . Отже, підводячи підсумок, порушення 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) .
Ви можете знайти пояснення, що таке Принцип заміщення Ліскова, загальні підказки, які допомагають вам здогадатися, чи ви вже порушили його, і приклад підходу, який допоможе вам зробити ієрархію вашого класу більш безпечною.
ПРИНЦИП ЛІСКОВОГО ЗАМОВЛЕННЯ (З книги Марка Семана) говорить, що нам слід мати можливість замінити одну реалізацію інтерфейсу іншою, не порушуючи ні клієнта, ні імплементації. Цей принцип дозволяє вирішити вимоги, які виникають у майбутньому, навіть якщо ми можемо ' t передбачити їх сьогодні.
Якщо ми від'єднаємо комп’ютер від стіни (Впровадження), ні розетка (Інтерфейс), ні комп’ютер (Клієнт) не виходять з ладу (адже, якщо це портативний комп'ютер, він може навіть заряджатись від своїх акумуляторів протягом певного часу) . Однак, використовуючи програмне забезпечення, клієнт часто очікує, що послуга буде доступною. Якщо послугу було видалено, ми отримуємо NullReferenceException. Для вирішення подібних ситуацій ми можемо створити реалізацію інтерфейсу, який не робить нічого. Це схема дизайну, відома як Null Object [4], і вона приблизно відповідає від'єднанню комп'ютера від стіни. Оскільки ми використовуємо нещільне з'єднання, ми можемо замінити реальну реалізацію тим, що нічого не робить, не створюючи проблем.
Принцип заміщення Лікова зазначає, що якщо програмний модуль використовує клас Base, то посилання на клас Base можна замінити на клас похідних, не впливаючи на функціональність програмного модуля.
Намір - похідні типи повинні бути повністю замінені здатними до їх базових типів.
Приклад - типи повернення ко-варіанту в 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);
}
}
Це порушення ЛСП? Так. Це тому, що контракт рахунку говорить нам, що рахунок буде знято, але це не завжди так. Отже, що мені робити, щоб це виправити? Я просто змінюю договір:
interface Account
{
/**
* Withdraw $money amount from this account if its balance is enough.
* Otherwise do nothing.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
Voilà, тепер контракт виконано.
Це тонке порушення часто нав'язує клієнтові можливість пояснити різницю між конкретними об'єктами, які використовуються. Наприклад, враховуючи перший контракт рахунку, він може виглядати наступним чином:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
І це автоматично порушує принцип відкритого закриття [тобто вимоги щодо зняття грошей. Тому що ти ніколи не знаєш, що станеться, якщо об’єкт, що порушує договір, не має достатньо грошей. Напевно, це просто нічого не повертає, напевно, буде викинуто виняток. Тож вам доведеться перевірити, чи цеhasEnoughMoney()
- який не є частиною інтерфейсу. Тож ця вимушена перевірка, що залежить від конкретного класу, є порушенням OCP].
Цей пункт також стосується помилкового уявлення, з яким я досить часто зустрічаюсь щодо порушення LSP. У ній сказано: "якщо поведінка батьків змінилася у дитини, значить, це порушує ЛСП". Однак це не відбувається - доки дитина не порушить батьківський договір.