Можно ли когда-нибудь нарушать LSP?


10

Я занимаюсь этим вопросом , но переключаюсь с кода на принцип.

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

Теперь моя проблема может быть подытожена так: у меня есть реферат Weapon class, два класса Swordи Reloadable. Если Reloadableсодержит конкретный method, вызываемый Reload(), я должен был бы понизить доступ к нему method, и, в идеале, вы бы хотели этого избежать.

Затем я подумал об использовании Strategy Pattern. Таким образом, каждое оружие было осведомлено только о действиях, которые оно может выполнить, так что, например, Reloadableоружие может перезаряжаться, но Swordне может и даже не знает о Reload class/method. Как я уже говорил в своем сообщении о переполнении стека, мне не нужно унывать, и я могу поддерживать List<Weapon>коллекцию.

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

Я не до конца понимаю почему. Зачем нарушать принцип и позволять Мечу быть в курсе Reloadи оставлять его пустым? Как я сказал в своем посте переполнения стека, 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определяется контрактом бросить тогда и только тогда !supportsReload(). Это придерживается LSP, если классы вождения придерживаются протокола, который я только что обрисовал.
USR

3
Оставить ли поле reload()пустым или standardActionsнет действия перезагрузки - это просто другой механизм. Там нет принципиальной разницы. Вы можете сделать оба. => Ваше решение является жизнеспособным (это был ваш вопрос) .; Sword не нужно знать о перезагрузке, если Weapon содержит пустую реализацию по умолчанию.
USR

27
Я написал серию статей, исследующих различные проблемы с различными методами для решения этой проблемы. Вывод: не пытайтесь запечатлеть правила вашей игры в системе типов языка . Соберите правила игры в объектах, которые представляют и обеспечивают соблюдение правил на уровне игровой логики, а не на уровне системы типов . Нет оснований полагать, что какая бы система типов вы ни использовали, она достаточно сложна, чтобы представить вашу игровую логику. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Эрик Липперт,

2
@EricLippert - Спасибо за вашу ссылку. Я сталкивался с этим блогом очень много раз, но некоторые моменты я не совсем понял, но это не ваша вина. Я изучаю ООП самостоятельно и столкнулся с принципами SOLID. В первый раз, когда я наткнулся на твой блог, я его совсем не понял, но я узнал немного больше и прочел твой блог снова и медленно начал понимать части того, что говорилось. Однажды я полностью пойму все в этой серии. Надеюсь: D

6
@SR «если он ничего не делает или выдает исключение, вы нарушаете» - я думаю, вы неправильно прочитали сообщение из этой статьи. Проблема была не в том, что setAltitude ничего не делала, а в том, что он не выполнил постусловие «птица будет нарисована на заданной высоте». Если вы определяете постусловие «перезарядка» как «если было достаточно боеприпасов, оружие может снова атаковать», то бездействие - совершенно правильная реализация для оружия, которое не использует боеприпасы.
Себастьян Редл

Ответы:


16

LSP обеспокоен подтипом и полиморфизмом. Не весь код на самом деле использует эти функции, и в этом случае LSP не имеет значения. Два распространенных варианта использования конструкций языка наследования, которые не относятся к подтипам:

  • Наследование используется для наследования реализации базового класса, но не его интерфейса. Почти во всех случаях состав должен быть предпочтительным. Такие языки, как Java, не могут разделять наследование реализации и интерфейса, но, например, C ++ имеет privateнаследование.

  • Наследование, используемое для моделирования типа / объединения суммы, например: a Base- либо, CaseAлибо CaseB. Базовый тип не объявляет какой-либо соответствующий интерфейс. Чтобы использовать его экземпляры, вы должны привести их к правильному конкретному типу. Кастинг может быть выполнен безопасно, и это не проблема. К сожалению, многие языки ООП не могут ограничивать подтипы базового класса только предполагаемыми подтипами. Если внешний код может создать a CaseC, то код, предполагающий, что a Baseможет быть только CaseAили CaseBневерен. Scala может сделать это безопасно благодаря своей case classконцепции. В Java это можно смоделировать, когда Baseабстрактный класс имеет закрытый конструктор, а вложенные статические классы наследуются от базы.

Некоторые концепции, такие как концептуальные иерархии объектов реального мира, очень плохо отображаются в объектно-ориентированных моделях. Такие мысли, как «Оружие - это оружие, а меч - это оружие, поэтому у меня будет Weaponбазовый класс, от которого Gunи Swordунаследуют», вводят в заблуждение: настоящее слово «отношения» не подразумевает такие отношения в нашей модели. Одна связанная проблема заключается в том, что объекты могут принадлежать нескольким концептуальным иерархиям или могут изменять свою иерархическую принадлежность во время выполнения, что большинство языков не могут моделировать, поскольку наследование обычно для каждого класса, а не для объекта, и определяется во время разработки, а не во время выполнения.

При разработке моделей ООП мы не должны думать об иерархии или о том, как один класс «расширяет» другой. Базовый класс - это не место, чтобы выделять общие части нескольких классов. Вместо этого подумайте о том, как будут использоваться ваши объекты, то есть о том, какое поведение нужно пользователям этих объектов.

Здесь пользователям может понадобиться 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().


Вроде как SP в некотором смысле. Почему слот должен опустошаться? Если в словаре нет действия, просто ничего не делайте

@SR Является ли слот пустым или не существует, на самом деле не имеет значения, и зависит от механизма, используемого для реализации этих слотов. Я написал этот ответ с допущениями довольно статического языка, где слоты являются полями экземпляра и всегда существуют (то есть нормальный дизайн классов в Java). Если выбрать более динамичную модель, в которой слоты являются записями в словаре (например, с использованием HashMap в Java или обычного объекта Python), то слоты не должны существовать. Обратите внимание, что более динамичные подходы дают большую безопасность типов, что обычно нежелательно.
Амон

Я согласен, что объекты реального мира плохо моделируются. Если я понимаю ваш пост, вы говорите, что я могу использовать шаблон стратегии?

2
@SR Да, шаблон стратегии в некоторой форме, вероятно, разумный подход. Сравните также связанный шаблон объекта типа: gameprogrammingpatterns.com/type-object.html
amon

3

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

На все сказанное я не особо согласен с ответами на другие ваши вопросы. Имея swordНаследовать от weaponявляешься ужасающим, наивным OO , который неизменно приводит к методам не-оп или типа проверкам валялся кода.

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


Я думаю, что это идеально. Я могу использовать SP, но они являются компромиссами, просто нужно знать о них. Смотрите мои изменения, что я имею в виду.

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

Fwiw в Мечах Судьбы 2 использует боеприпасы по некоторым причинам!

@ CAD97 - Это тип мышления, который я видел относительно этой проблемы. Имейте меч с бесконечными боеприпасами, чтобы не перезаряжать. Это просто подталкивает проблему или скрывает ее. Что если я введу гранату, что тогда? Гранаты не имеют патронов и не стреляют, и не должны знать о таких методах.

1
Я с CAD97 на этом. И создал бы, WeaponBuilderкоторый мог бы строить мечи и оружие, составляя оружие стратегий.
Крис Волерт

3

Конечно, это жизнеспособное решение; это просто очень плохая идея.

Проблема не в том, есть ли у вас этот единственный экземпляр, в который вы помещаете reload в свой базовый класс. Проблема в том, что вам также нужно поставить «качели», «стрелять», «парировать», «стучать», «полировать», «разбирать», «точить» и «заменять гвозди заостренного конца клюшки» метод в вашем базовом классе.

Смысл LSP в том, что ваши алгоритмы верхнего уровня должны работать и иметь смысл. Так что, если у меня есть такой код:

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

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

Если ваш код выглядит так,

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

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

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

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


Допустим, у меня есть внутренний словарь оружия, который содержит действия для оружия, и когда пользователь переходит в «Перезагрузить», он проверяет словарь, например, WeapActions.containsKey (действие), если так, захватывает объект, связанный с ним, и делает Это. Вместо класса оружия с несколькими операторами if

Смотрите редактирование выше. Это то, что я имел в виду при использовании SP

0

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

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


0

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

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

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

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

В качестве альтернативы вы можете пересмотреть свою архитектуру и считать, что в абстрактном виде все Оружие перезаряжается, а некоторые виды оружия просто не нуждаются в перезарядке.

Тогда вы больше не продлеваете класс оружия и не нарушаете LSP.

Но это проблематично в долгосрочной перспективе, потому что вы обязаны придумать больше особых случаев, Gun.SafteyOn (), Sword.WipeOffBlood () и т. Д., И если вы поместите их все в Weapon, то у вас будет очень сложный обобщенный базовый класс, который вы сохраните приходится менять.

редактировать: почему шаблон стратегии является плохим (тм)

Это не так, но рассмотрим настройку, производительность и общий код.

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

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

Когда я компилирую код и вызываю Weapon.Do («атака») вместо «атаки», я не получаю ошибку при компиляции.

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


Я думаю, что SP может справиться со всем этим (см. Выше), оружие будет SafteyOn()и Swordбудет иметь wipeOffBlood(). Каждое оружие не знает о других методах (и они не должны быть)

SP - это хорошо, но это эквивалентно снижению производительности без безопасности типов. Я думаю, что я как бы отвечал на другой вопрос, позвольте мне обновить
Ewan

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

это не сработает в этой ситуации, так как есть два отдельных действия: атака и перезагрузка, которые необходимо привязать к некоторому пользовательскому вводу
Ewan
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.