Чи коли-небудь добре порушувати LSP?


10

Я продовжую це питання , але я перемикаю свою увагу з коду на принцип.

З мого розуміння принципу заміщення Ліскова (LSP), які б методи не були в моєму базовому класі, вони повинні бути реалізовані в моєму підкласі, і відповідно до цієї сторінки, якщо ви перекриєте метод в базовому класі, і він нічого не робить або кидає виняток, ви порушуєте принцип.

Тепер мою проблему можна підсумувати так: у мене є конспект Weapon classі два класи, Swordі Reloadable. Якщо в ньому Reloadableміститься конкретний method, що називається Reload(), мені доведеться перейти вниз, щоб отримати доступ до цього method, і в ідеалі ви хочете цього уникнути.

Тоді я думав скористатися Strategy Pattern. Таким чином, кожна зброя усвідомлювала лише ті дії, які вона здатна виконувати, тому, наприклад, Reloadableзброя може, очевидно, перезавантажуватися, але Swordне може, і навіть не знає про це Reload class/method. Як я вже зазначив у своїй публікації про переповнення стека, мені не доведеться робити приниження, і я можу підтримувати List<Weapon>колекцію.

На іншому форумі перша відповідь запропонувала дозволити Swordсобі знати Reload, просто нічого не робіть. Ця сама відповідь була надана на сторінці переповнення стека, яку я пов’язував вище.

Я не повністю розумію, чому. Навіщо порушувати принцип і дозволяти Мечу усвідомлювати це Reload, і залишати його порожнім? Як я вже говорив у своєму дописі Stack Overflow, SP, в значній мірі вирішив мої проблеми.

Чому це не життєздатне рішення?

public final Weapon{

    private final String name;
    private final int damage;
    private final List<AttackStrategy> validactions;
    private final List<Actions> standardActions;

    private Weapon(String name, int damage, List<AttackStrategy> standardActions, List<Actions> attacks)
    {
        this.name = name;
        this.damage = damage;
        standardActions = new ArrayList<Actions>(standardActions);
        validAttacks = new ArrayList<AttackStrategy>(validActions);
    }

    public void standardAction(String action){} // -- Can call reload or aim here.  

    public int attack(String action){} // - Call any actions that are attacks. 

    public static Weapon Sword(String name, damage, List<AttackStrategy> standardActions, List<Actions> attacks){
        return new Weapon(name, damage,standardActions, attacks) ;
    }

}

Напад інтерфейсу та реалізація:

public interface AttackStrategy{
    void attack(Enemy enemy);
}

public class Shoot implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to shoot
    }
}

public class Strike implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to strike
    }
}

2
Можна зробити class Weapon { bool supportsReload(); void reload(); }. Клієнти перевірять, чи підтримується перед перезавантаженням. reloadвизначається договірно, щоб кинути iff !supportsReload(). Це дотримується класів, керованих LSP iff, дотримується протоколу, який я лише окреслив.
usr

3
Незалежно від того, залишаєте ви reload()порожнім або standardActionsне містить дії перезавантаження, це просто інший механізм. Принципової різниці немає. Можна зробити і те, і інше. => Ваше рішення є життєздатним (який був ваш питання).; Меч не повинен знати про перезавантаження, якщо Weapon містить пусту реалізацію за замовчуванням.
usr

27
Я написав низку статей, де досліджував різноманітні проблеми з різними методиками вирішення цієї проблеми. Висновок: не намагайтеся зафіксувати правила вашої гри в системі типів мови . Захоплюйте правила гри в об'єкти, які представляють і застосовують правила на рівні логіки гри, а не на рівні типової системи . Немає підстав вважати, що будь-яка система, яку ви використовуєте, є досить складною для відображення вашої логічної гри. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Ерік Ліпперт

2
@EricLippert - Дякуємо за ваше посилання. Я багато разів зустрічався з цим блогом, але деякі моменти, які я робив, я не дуже розумію, але це не ваша вина. Я самостійно навчаюся OOP і натрапив на принципів SOLID. Перший раз, коли я натрапив на ваш блог, я його зовсім не зрозумів, але я дізнався трохи більше і знову прочитав ваш блог, і повільно почав розуміти частини сказаного. Одного разу я повністю зрозумію все в цій серії. Сподіваюся: D

6
@SR "якщо він нічого не робить або кидає виняток, ви порушуєте" - я думаю, ви неправильно прочитали повідомлення з цієї статті. Проблема полягала не в тому, що setAltitude нічого не зробив, а в тому, що він не виконав умову "птах буде намальовано на заданій висоті". Якщо ви визначаєте післязастереження "перезавантаження" як "якщо було достатньо боєприпасів, зброя може напасти знову", тоді нічого не робити - це абсолютно правильна реалізація для зброї, яка не використовує боєприпаси.
Себастьян Редл

Відповіді:


16

LSP стурбований субтипізацією та поліморфізмом. Не всі коди насправді використовують ці функції, і в цьому випадку LSP не має значення. Два поширені випадки використання конструкцій мови успадкування, які не є випадком підтипу:

  • Спадкування, яке використовується для успадкування реалізації базового класу, але не його інтерфейсу. Майже у всіх випадках слід віддати перевагу складу. Такі мови, як Java, не можуть розділяти успадкування реалізації та інтерфейс, але, наприклад, C ++ має privateспадщину.

  • Спадкування, яке використовується для моделювання типу суми / об'єднання, наприклад: a Baseє CaseAабо CaseB. Базовий тип не оголошує жодного відповідного інтерфейсу. Щоб використовувати його екземпляри, ви повинні привести їх до правильного типу бетону. Кастинг можна зробити безпечно, і це не проблема. На жаль, багато мов OOP не в змозі обмежити підтипи базового класу лише призначеними підтипами. Якщо зовнішній код може створити a CaseC, тоді код передбачає, що a Baseможе бути лише a CaseAабо CaseBнеправильним. Скала може це зробити безпечно зі своєю case classконцепцією. У Java це можна моделювати, коли Baseабстрактний клас із приватним конструктором, а вкладені статичні класи потім успадковують від бази.

Деякі такі поняття, як концептуальна ієрархія об'єктів реального світу, дуже погано відображають об'єктно-орієнтовані моделі. Думки на кшталт «Пістолет - це зброя, а меч - зброя, тому у мене буде Weaponбазовий клас, від якого Gunі Swordуспадковуватись» вводяться в оману: реальне слово є - стосунки не передбачають таких відносин у нашій моделі. Одне пов'язане питання полягає в тому, що об'єкти можуть належати до декількох концептуальних ієрархій або можуть змінювати свою ієрархічну приналежність під час виконання, що більшість мов не може моделювати, оскільки успадкування, як правило, не є класом за об'єктом, а визначається під час проектування, а не під час виконання.

Розробляючи моделі OOP, ми не повинні думати про ієрархію або про те, як один клас "розширює" інший. Базовий клас - це не місце для визначення загальних частин кількох класів. Натомість подумайте, як будуть використовуватися ваші об’єкти, тобто яка поведінка потребує користувачів цих об’єктів.

Тут користувачі можуть знадобитися attack()зі зброєю, а може і з reload()ними. Якщо ми хочемо створити ієрархію типів, то обидва ці способи повинні бути в базовому типі, хоча зброя, що не завантажується, може ігнорувати цей метод і нічого не робити при виклику. Отже базовий клас містить не загальні частини, а комбінований інтерфейс усіх підкласів. Підкласи не відрізняються між собою інтерфейсом, а лише їх реалізацією цього інтерфейсу.

Не варто створювати ієрархію. Два типу Gunі Swordможуть бути абсолютно не пов'язані. У той час як в Gunбанку fire()і може тільки . Якщо вам потрібно керувати цими об'єктами поліморфно, ви можете використовувати шаблон адаптера для зйомки відповідних аспектів. У Java 8 це можливо досить зручно з функціональними інтерфейсами та лямбдами / посиланнями на метод. Наприклад , ви могли б мати стратегію , для якої ви поставку або .reload()Swordstrike()AttackmyGun::fire() -> mySword.strike()

Нарешті, іноді доцільно взагалі уникати будь-яких підкласів, але моделювати всі об’єкти за допомогою одного типу. Це особливо актуально в іграх, оскільки багато ігрових об'єктів не вписуються добре в будь-яку ієрархію і можуть мати багато різних можливостей. Наприклад, у рольовій грі може бути предмет, який є і квестовим предметом, і підсилює вашу статистику силою +2, коли вона оснащена, має 20% шансу ігнорувати будь-який отриманий збиток і забезпечує атаку в ближньому бою. А може, меч, який можна перезавантажити, тому що це * магія *. Хто знає, чого вимагає історія.

Замість того, щоб намагатися з'ясувати ієрархію класів для цього безладу, краще мати клас, який надає слоти для різних можливостей. Ці слоти можна змінити під час виконання. Кожен слот буде стратегією / зворотним викликом, як OnDamageReceivedабо Attack. З вашим зброєю, ми можемо мати MeleeAttack, RangedAttackі Reloadслоти. Ці слоти можуть бути порожніми; в цьому випадку об'єкт не забезпечує цю можливість. Щілини потім називається умовно: if (item.attack != null) item.attack.perform().


Начебто як СП у певному плані. Чому гніздо доводиться спорожнювати? Якщо словник не містить дії, просто не робіть нічого

@SR Чи порожній слот чи не існує, насправді не має значення, і це залежить від механізму, який використовується для реалізації цих слотів. Цю відповідь я написав з припущеннями про досить статичну мову, де слоти є полями екземплярів і завжди існують (тобто звичайний дизайн класу на Java). Якщо ви виберете більш динамічну модель, де слоти є записами у словнику (наприклад, використання HashMap на Java або звичайний об’єкт Python), то слоти не повинні існувати. Зауважте, що більш динамічні підходи відмовляються від великої безпеки типу, що зазвичай не бажано.
амон

Я згоден, що об'єкти реального світу не добре моделюються. Якщо я розумію вашу посаду, вашу приказку я можу використовувати шаблон стратегії?

2
@SR Так, стратегія в певній формі, ймовірно, є розумним підходом. Порівняйте також пов’язану модель типу об'єкта: gameprogrammingpatterns.com/type-object.html
amon

3

Оскільки стратегія щодо attackне є достатньою для ваших потреб. Звичайно, це дозволяє вам абстрагувати, які дії може виконувати предмет, але що відбувається, коли вам потрібно знати дальність зброї? Або ємність боєприпасів? Або який боєприпас потрібен? Ви повертаєтесь до downcasting, щоб досягти цього. А наявність такого рівня гнучкості зробить інтерфейс користувача трохи складнішим у впровадженні, оскільки йому потрібно мати аналогічну схему стратегії для роботи з усіма можливостями.

Все, що сказано, я не особливо згоден з відповідями на ваші інші запитання. Маючи swordУспадковувати від weaponявляєшся жахливим, наївним OO , який незмінно призводить до методів не-оп або типу перевірок валявся коду.

Але в корені справи жодне рішення не є помилковим . Ви можете використовувати обидва рішення, щоб створити функціонуючу гру, яку цікаво грати. Кожен з них має власний набір компромісів, як і будь-яке обране вами рішення.


Я думаю, що це ідеально. Я можу використовувати SP, але вони є компромісними, просто треба їх знати. Дивіться мою редакцію, що я маю на увазі.

1
Fwiw: меч має нескінченну боєприпаси: ви можете продовжувати використовувати його, не читаючи назавжди; перезавантаження нічого не робить, оскільки для початку ви маєте безмежне використання; діапазон одного / ближнього бою: це зброя ближнього бою. Неможливо продумати всі статистичні дані / дії таким чином, щоб вони працювали як для ближнього бою, так і для діапазону. Тим не менш, коли я дорослішаю, я все менше і менше використовую спадщину на користь інтерфейсів, конкуренції та будь-якої назви для використання одного Weaponкласу з екземпляром меча та гармати.
CAD97

Мечі Fwiw in Destiny 2 чомусь використовують боєприпаси!

@ CAD97 - Це такий тип мислення, який я бачив щодо цієї проблеми. Майте меч з нескінченним боєприпасом, тому не потрібно перезавантажувати. Це просто штовхає проблему навколо або приховує її. Що робити, якщо я введу гранату, що тоді? Гранати не мають боєприпасів і не стріляють, і не слід знати про такі методи.

1
Я з CAD97 з цього приводу. І створив би WeaponBuilderте, що могло б створювати мечі та гармати, складаючи зброю стратегій.
Кріс Волерт

3

Звичайно, це життєздатне рішення; це просто дуже погана ідея.

Проблема не полягає в тому, якщо у вас є цей єдиний екземпляр, де ви ставите перезавантаження в базовий клас. Проблема полягає в тому, що вам також потрібно поставити "гойдалку", "стріляти", "парирувати", "стукати", "відполірувати", "розібрати", "заточити" і "замінити наїли точкового кінця клубу" метод вашого базового класу.

Сенс LSP полягає в тому, що алгоритми верхнього рівня повинні працювати і мати сенс. Тож якщо у мене є такий код:

if (isEquipped(weapon)) {
   reload();
}

Тепер, якщо це викидає нереалізований виняток і приводить до краху вашої програми, це дуже погана ідея.

Якщо ваш код виглядає так,

if (canReload(weapon)) {
   reload();
}
else if (canSharpen(weapon)) {
  sharpen();
}
else if (canPollish(weapon)) {
  polish();
}

тоді ваш код може бути захаращений дуже специфічними властивостями, що не має нічого спільного з абстрактною ідеєю «зброї».

Однак якщо ви реалізовуєте шутер від першої особи, і вся ваша зброя може стріляти / перезавантажуватись, за винятком того, що один ніж тоді (у вашому конкретному контексті) має дуже сенс перезавантажувати ваш ніж, нічого не робити, оскільки це виняток і шанси низький рівень того, що ваш базовий клас захаращений певними властивостями.

Оновлення: Спробуйте подумати про абстрактний випадок / терміни. Наприклад, можливо, кожна зброя має акцію «підготувати», яка є перезарядкою гармат і невдяганням мечів.


Скажімо, у мене є внутрішній словник зброї, який містить дії для зброї, і коли користувач переходить у "Перезавантажити", він перевіряє словник, наприклад, зброяActions.containsKey (дія), якщо так, схопіть об'єкт, пов'язаний з ним, і зробіть це. Замість класу зброї з декількома, якщо заяви

Див. Редагування вище. Це те, що я мав на увазі, використовуючи SP

0

Очевидно, що це нормально, якщо ви не створюєте підклас з наміром замінити екземпляр базового класу, але якщо ви створюєте підклас, використовуючи базовий клас як зручне сховище функціональності.

Зараз це гарна ідея чи ні - це дуже дискусійне питання, але якщо ви ніколи не замінюєте підклас базовим класом, то те, що він не працює, не є проблемою. У вас можуть виникнути проблеми, але LSP не є проблемою в цьому випадку.


0

LSP хороший тим, що дозволяє коду виклику не турбуватися про те, як працює клас.

напр. Я можу зателефонувати на Weapon.Attack () на всю зброю, встановлену на моєму BattleMech, і не переживаю, що деякі з них можуть кинути виняток і розбити мою гру.

Тепер у вашому випадку ви хочете розширити базовий тип за допомогою нового функціоналу. Attack () не є проблемою, тому що клас Gun може відслідковувати боєприпаси та припиняти стрілянину, коли вона закінчується. Але Reload () - це щось нове, а не частина зброї.

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

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

Тоді ви більше не розширюєте клас для гармат або порушуєте LSP.

Але це проблематично довгостроково, тому що ви зобов'язані придумати більш особливі випадки, Gun.SafteyOn (), Sword.WipeOffBlood () тощо, і якщо ви помістите їх у Weapon, у вас є супер складний узагальнений базовий клас, який ви зберігаєте що потребує змін.

редагувати: чому шаблон стратегії поганий (тм)

Це не так, але врахуйте налаштування, продуктивність та загальний код.

У мене десь є конфігурація, яка підказує мені, що пістолет може перезавантажуватися. Коли я створюю копію зброї, я повинен прочитати цю конфігурацію і динамічно додати всі методи, перевірити, чи немає дублікатів імен тощо

Коли я називаю метод, я повинен переглядати цей список дій і виконувати відповідність рядків, щоб побачити, до якого дзвонити.

Коли я компілюю код і закликаю Weapon.Do ("atack") замість "атаки", я не отримаю помилку під час компіляції.

Це може бути підходящим рішенням для деяких проблем, скажімо, у вас є сотня зброї з різними комбінаціями випадкових методів, але ви втрачаєте багато переваг OO та сильного набору тексту. Це насправді нічого не економить від перенапруження


Я думаю, що SP може впоратися з усім цим (див. Редагування вище), пістолет мав би SafteyOn()і Swordмав би wipeOffBlood(). Кожна зброя не знає інших методів (і їх не повинно бути)

SP - це чудово, але еквівалентно зниженню рівня без безпеки типу. Напевно, я начебто відповідав на інше запитання, дозвольте мені оновити
Еван

2
Сама по собі схема стратегії не передбачає динамічного пошуку стратегії у списку чи словнику. Тобто обидва weapon.do("attack")і безпека типу weapon.attack.perform()можуть бути прикладами структури стратегії. Пошук стратегій за назвою необхідний лише під час налаштування об'єкта з конфігураційного файлу, хоча використання відображення було б однаково безпечним для типу.
амон

що в цій ситуації не буде працювати, оскільки є дві окремі дії атаки та перезавантаження, які потрібно прив’язати до певного вводу користувача
Ewan
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.