Czy kiedykolwiek można naruszać LSP?

10

Kontynuuję to pytanie , ale skupiam się z kodu na zasadzie.

Z mojego zrozumienia zasady podstawienia Liskowa (LSP), wszystkie metody w mojej klasie bazowej muszą być zaimplementowane w mojej podklasie i zgodnie z stroną, jeśli zastąpisz metodę w klasie bazowej i nie robi ona nic ani nie rzuca wyjątek, naruszasz zasadę.

Teraz mój problem można podsumować w następujący sposób: mam streszczenie Weapon classoraz dwie klasy Swordi Reloadable. Jeśli Reloadablezawiera konkretny method, zwany Reload(), musiałbym zejść na dół, aby uzyskać do niego dostęp method, i najlepiej byłoby tego uniknąć.

Potem pomyślałem o użyciu Strategy Pattern. W ten sposób każda broń była świadoma działań, które może wykonać, więc na przykład Reloadablebroń może oczywiście przeładować, ale Swordnie może, a nawet nie jest świadoma Reload class/method. Jak stwierdziłem w moim wpisie Przepełnienie stosu, nie muszę spuszczać i mogę zachować List<Weapon>kolekcję.

Na innym forum pierwsza odpowiedź sugerowała , że możesz Swordbyć świadomy Reload, po prostu nic nie rób. Ta sama odpowiedź została podana na stronie Przepełnienie stosu, do której odsyłam powyżej.

Nie do końca rozumiem dlaczego. Po co naruszać tę zasadę i pozwolić, by Miecz był tego świadomy Reload, i pozostawić to puste? Jak powiedziałem w moim wpisie Przepełnienie stosu, SP prawie rozwiązało moje problemy.

Dlaczego nie jest to realne rozwiązanie?

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) ;
    }

}

Interfejs ataku i implementacja:

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
    }
}

źródło
2
Można to zrobić class Weapon { bool supportsReload(); void reload(); }. Klienci sprawdziliby, czy są obsługiwani przed ponownym załadowaniem. reloadjest określona w umowie, aby rzucić IFF !supportsReload(). Przestrzeganie klas kierowanych przez LSP iff jest zgodne z protokołem, który właśnie przedstawiłem.
usr
3
To, czy pozostawisz reload()puste pole, czy standardActionsteż nie zawiera operacji przeładowania, to po prostu inny mechanizm. Nie ma zasadniczej różnicy. Możesz zrobić jedno i drugie. => Twoje rozwiązanie jest realne (jakie było twoje pytanie) .; Miecz nie musi wiedzieć o przeładowaniu, jeśli Broń zawiera pustą domyślną implementację.
usr
27
Napisałem serię artykułów eksplorujących różnorodne problemy z różnymi technikami rozwiązania tego problemu. Wniosek: nie próbuj uchwycić reguł gry w systemie typów języka . Uchwyć reguły gry w obiektach reprezentujących i egzekwujących reguły na poziomie logiki gry, a nie na poziomie systemu typów . Nie ma powodu, aby sądzić, że jakikolwiek system typów, którego używasz, jest wystarczająco zaawansowany, aby reprezentować logikę twojej gry. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Eric Lippert
2
@EricLippert - Dziękujemy za link. Zetknąłem się z tym blogiem wiele razy, ale niektóre z tych stwierdzeń nie do końca rozumiem, ale to nie twoja wina. Uczę się OOP na własną rękę i poznałem zasady SOLID. Kiedy po raz pierwszy natknąłem się na twojego bloga, w ogóle go nie rozumiałem, ale nauczyłem się trochę więcej i ponownie przeczytałem twojego bloga i powoli zacząłem rozumieć fragmenty tego, co zostało powiedziane. Pewnego dnia w pełni zrozumiem wszystko z tej serii. Mam nadzieję: D
6
@SR „jeśli nic nie robi lub zgłasza wyjątek, naruszasz zasady” - myślę, że źle odczytałeś wiadomość z tego artykułu. Problem nie polegał bezpośrednio na tym, że set nie zrobił nic, to, że nie spełnił warunku „ptak zostanie wylosowany na ustalonej wysokości”. Jeśli zdefiniujesz warunek „przeładowania” jako „jeśli dostępna była wystarczająca ilość amunicji, broń może zaatakować ponownie”, wówczas nie robienie niczego jest całkowicie poprawną implementacją dla broni, która nie używa amunicji.
Sebastian Redl

Odpowiedzi:

16

LSP jest zaniepokojony podtypami i polimorfizmem. Nie cały kod faktycznie korzysta z tych funkcji, w takim przypadku LSP jest nieistotny. Dwa typowe przypadki użycia konstrukcji języka dziedziczenia, które nie są przypadkami podtypów, to:

  • Dziedziczenie używane do dziedziczenia implementacji klasy podstawowej, ale nie jej interfejsu. W prawie wszystkich przypadkach preferowana jest kompozycja. Języki takie jak Java nie mogą oddzielić dziedziczenia implementacji i interfejsu, ale np. C ++ ma privatedziedziczenie.

  • Dziedziczenie używane do modelowania typu sumy / unii, np .: a Basejest albo CaseAalbo CaseB. Typ podstawowy nie deklaruje żadnego odpowiedniego interfejsu. Aby użyć jego instancji, musisz rzucić je na właściwy typ betonu. Odlewanie można wykonać bezpiecznie i nie stanowi to problemu. Niestety wiele języków OOP nie jest w stanie ograniczyć podtypów klas podstawowych tylko do podtypów zamierzonych. Jeśli kod zewnętrzny może utworzyć a CaseC, to kod przy założeniu, że a Basemoże być tylko a CaseAlub CaseBjest niepoprawny. Scala może to zrobić bezpiecznie dzięki swojej case classkoncepcji. W Javie można to modelować, gdy Basejest to klasa abstrakcyjna z prywatnym konstruktorem, a zagnieżdżone klasy statyczne dziedziczą następnie z bazy.

Niektóre koncepcje, takie jak hierarchie pojęciowe rzeczywistych obiektów, bardzo źle odwzorowują się na modele obiektowe. Myśli takie jak „Pistolet jest bronią, a miecz jest bronią, dlatego będę miał Weaponklasę podstawową, z której Guni Swordodziedziczę”, są mylące: prawdziwe słowa to - relacje nie sugerują takiego związku w naszym modelu. Jednym pokrewnym problemem jest to, że obiekty mogą należeć do wielu hierarchii pojęciowych lub mogą zmieniać przynależność do hierarchii w czasie wykonywania, czego większość języków nie może modelować, ponieważ dziedziczenie jest zwykle dla klasy nie dla obiektu i zdefiniowane w czasie projektowania, a nie w czasie wykonywania.

Projektując modele OOP, nie powinniśmy myśleć o hierarchii ani o tym, jak jedna klasa „rozszerza” inną. Klasa podstawowa nie jest miejscem do wyróżnienia wspólnych części wielu klas. Zamiast tego zastanów się, w jaki sposób będą używane Twoje obiekty, tj. Jakiego zachowania potrzebują użytkownicy tych obiektów.

Tutaj użytkownicy mogą potrzebować attack()broni, a może i reload()oni. Jeśli mamy stworzyć hierarchię typów, wówczas obie te metody muszą być typu podstawowego, chociaż broń nieładowalna może zignorować tę metodę i nie robić nic, gdy zostanie wywołana. Zatem klasa podstawowa nie zawiera wspólnych części, ale połączony interfejs wszystkich podklas. Podklasy nie różnią się interfejsem, ale jedynie implementacją tego interfejsu.

Nie jest konieczne tworzenie hierarchii. Dwa typy Guni Swordmogą być całkowicie niezwiązane. Natomiast Gunpuszki fire()i tylko może . Jeśli potrzebujesz zarządzać tymi obiektami polimorficznie, możesz użyć Wzorca adaptera, aby uchwycić odpowiednie aspekty. W Javie 8 jest to możliwe dość wygodnie dzięki interfejsom funkcjonalnym i referencjom lambdas / metody. Np. Możesz mieć strategię, którą dostarczasz lub .reload()Swordstrike()AttackmyGun::fire() -> mySword.strike()

Wreszcie czasami rozsądne jest unikanie jakichkolwiek podklas, ale modelowanie wszystkich obiektów za pomocą jednego typu. Jest to szczególnie istotne w grach, ponieważ wiele obiektów nie pasuje do żadnej hierarchii i może mieć wiele różnych możliwości. Np. W grze fabularnej może znajdować się przedmiot, który jest jednocześnie przedmiotem misji, wzmacnia statystyki o siłę +2, jeśli jest na wyposażeniu, ma 20% szans na zignorowanie otrzymanych obrażeń i zapewnia atak w zwarciu. A może miecz do ponownego załadowania, ponieważ jest to * magia *. Kto wie, czego wymaga ta historia.

Zamiast próbować ustalić hierarchię klas dla tego bałaganu, lepiej mieć klasę, która zapewnia miejsca dla różnych możliwości. Te miejsca można zmienić w czasie wykonywania. Każde miejsce będzie strategią / wywołaniem zwrotnym, takim jak OnDamageReceivedlub Attack. Ze swoimi broni, możemy mieć MeleeAttack, RangedAttacki Reloadszczeliny. Te gniazda mogą być puste, w którym to przypadku obiekt nie zapewnia takiej możliwości. Szczeliny są wówczas nazywane warunkowo: if (item.attack != null) item.attack.perform().

amon
źródło
W pewien sposób przypomina SP. Dlaczego automat musi się opróżnić? Jeśli słownik nie zawiera akcji, po prostu nic nie rób
@SR To, czy miejsce jest puste, czy nie istnieje, tak naprawdę nie ma znaczenia i zależy od mechanizmu zastosowanego do wdrożenia tych miejsc. Napisałem tę odpowiedź przy założeniu dość statycznego języka, w którym gniazda są polami instancji i zawsze istnieją (tj. Normalne projektowanie klas w Javie). Jeśli wybierzesz bardziej dynamiczny model, w którym sloty są wpisami w słowniku (np. Używając HashMap w Javie lub zwykłego obiektu Python), to nie muszą one istnieć. Zauważ, że bardziej dynamiczne podejścia dają wiele bezpieczeństwa typu, co zwykle nie jest pożądane.
amon
Zgadzam się, że obiekty świata rzeczywistego nie modelują dobrze. Jeśli rozumiem twój post, mówisz, że mogę użyć Wzorca Strategii?
2
@SR Tak, wzorzec strategii w jakiejś formie jest prawdopodobnie rozsądnym podejściem. Porównaj również powiązany wzorzec typu obiektu: gameprogrammingpatterns.com/type-object.html
amon
3

Ponieważ posiadanie strategii attacknie jest wystarczające dla twoich potrzeb. Jasne, pozwala ci to wyrejestrować, jakie działania może wykonać przedmiot, ale co się stanie, gdy będziesz musiał znać zasięg broni? Czy pojemność amunicji? Lub jakiego rodzaju amunicji potrzeba? Wróciłeś do downcastingu, żeby się na to zdobyć. Posiadanie tego poziomu elastyczności sprawi, że interfejs użytkownika będzie nieco trudniejszy do wdrożenia, ponieważ będzie musiał mieć podobny wzór strategii, aby poradzić sobie ze wszystkimi możliwościami.

Mimo wszystko nie zgadzam się szczególnie z odpowiedziami na pozostałe pytania. Mając swordDziedzicz weaponjest przerażające, naiwny OO które niezmiennie prowadzi do metod no-op lub typu kontroli porozrzucane kodu.

Ale u podstaw sprawy żadne rozwiązanie nie jest złe . Oba rozwiązania można wykorzystać do stworzenia przyjemnej gry. Każdy z nich ma własny zestaw kompromisów, tak jak każde wybrane rozwiązanie.

Telastyn
źródło
Myślę, że to jest idealne. Mogę używać SP, ale są to kompromisy, po prostu trzeba o nich wiedzieć. Zobacz moją edycję, co mam na myśli.
1
Fwiw: miecz ma nieskończoną amunicję: możesz go używać bez czytania na zawsze; reload nic nie robi, ponieważ na początku masz nieskończone użycie; zasięg jednego / walki wręcz: jest to broń do walki w zwarciu. Nie jest niemożliwe myśleć o wszystkich statystykach / działaniach w sposób, który działa zarówno w walce wręcz, jak i dystansowej. Jednak w miarę, jak się starzeję, coraz mniej dziedziczę na korzyść interfejsów, konkurencji i jakiejkolwiek innej nazwy używa pojedynczej Weaponklasy z instancją miecza i pistoletu.
97 CAD
Miecze Fwiw w Destiny 2 z jakiegoś powodu używają amunicji!
@ CAD97 - To jest sposób myślenia na temat tego problemu. Miej miecz z nieskończoną amunicją, aby nie przeładowywać. To po prostu przesuwa problem lub ukrywa go. Co jeśli wprowadzę granat, co wtedy? Granaty nie mają amunicji ani strzelania i nie powinny być świadome takich metod.
1
W tej kwestii jestem z CAD97. I stworzyłby taki, WeaponBuilderktóry mógłby budować miecze i pistolety, komponując broń strategiczną.
Chris Wohlert,
3

Oczywiście jest to realne rozwiązanie; to po prostu bardzo zły pomysł.

Problem nie polega na tym, że masz tę jedną instancję, w której przeładowujesz klasę podstawową. Problem polega na tym, że trzeba również umieścić „huśtawka”, „strzelać”, „parować”, „pukać”, „polerować”, „demontować”, „wyostrzać” i „wymieniać gwoździe spiczastego końca pałki” metoda w klasie bazowej.

Chodzi o to, że algorytmy najwyższego poziomu muszą działać i mieć sens. Więc jeśli mam taki kod:

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

Teraz, jeśli spowoduje to niezaimplementowany wyjątek i spowoduje awarię programu, to bardzo zły pomysł.

Jeśli twój kod wygląda tak,

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

wtedy twój kod może być zaśmiecony bardzo specyficznymi właściwościami, które nie mają nic wspólnego z abstrakcyjną ideą „broni”.

Jeśli jednak wdrażasz strzelankę FPS, a cała twoja broń może strzelać / przeładowywać, z wyjątkiem tego, że jeden nóż to (w twoim konkretnym kontekście) bardzo sensowne jest, aby przeładowanie noża nic nie robiło, ponieważ jest to wyjątek i szanse zaśmiecanie klasy podstawowej określonymi właściwościami jest niskie.

Aktualizacja: spróbuj pomyśleć o abstrakcyjnym przypadku / terminach. Na przykład, być może każda broń ma akcję „przygotowania”, która polega na przeładowaniu broni i nieosłonięciu mieczy.

Batavia
źródło
Załóżmy, że mam wewnętrzny słownik broni, który przechowuje akcje dla broni, a gdy użytkownik przejdzie w „Przeładuj”, sprawdza słownik, np. WeaponActions.containsKey (akcja), jeśli tak, to chwyć powiązany z nim obiekt i wykonaj to. Zamiast klasy broni z wieloma instrukcjami if
Zobacz edycję powyżej. Właśnie to miałem na myśli, używając SP
0

Oczywiście jest OK, jeśli nie tworzysz podklasy z zamiarem zastąpienia instancji klasy podstawowej, ale jeśli tworzysz podklasę, używając klasy podstawowej jako wygodnego repozytorium funkcjonalności.

To, czy jest to dobry pomysł, czy nie, jest bardzo dyskusyjne, ale jeśli nigdy nie zastąpisz podklasy klasą podstawową, to fakt, że nie działa, nie stanowi problemu. Możesz mieć problemy, ale LSP nie jest problemem w tym przypadku.

gnasher729
źródło
0

LSP jest dobry, ponieważ pozwala kodowi wywołującemu nie martwić się działaniem klasy.

na przykład. Mogę wywołać Weapon.Attack () na wszystkich broniach zamontowanych na moim BattleMechu i nie martw się, że niektóre z nich mogą rzucić wyjątek i zawiesić grę.

Teraz w twoim przypadku chcesz rozszerzyć swój typ podstawowy o nową funkcjonalność. Attack () nie stanowi problemu, ponieważ klasa Gun może śledzić swoją amunicję i przestać strzelać, gdy skończy się. Ale Reload () jest czymś nowym i nie jest częścią bycia bronią.

Najłatwiejszym rozwiązaniem jest spowolnienie, nie sądzę, że musisz zbytnio martwić się o wydajność, nie będziesz robił tego w każdej klatce.

Alternatywnie możesz ponownie ocenić swoją architekturę i wziąć pod uwagę, że w skrócie wszystkie bronie można przeładowywać, a niektóre bronie po prostu nigdy nie wymagają przeładowywania.

Zatem nie przedłużasz już klasy broni i nie naruszasz LSP.

Ale jest to problematyczne w dłuższej perspektywie, ponieważ musisz wymyślić więcej specjalnych przypadków, Gun.SafteyOn (), Sword.WipeOffBlood () itp., A jeśli umieścisz je wszystkie w Weapon, masz bardzo skomplikowaną uogólnioną klasę podstawową, którą trzymasz muszę się zmienić.

edycja: dlaczego wzorzec strategii jest zły (tm)

Nie jest, ale weź pod uwagę konfigurację, wydajność i ogólny kod.

Muszę gdzieś mieć konfigurację, która mówi mi, że broń może przeładować. Kiedy tworzę instancję broni, muszę przeczytać tę konfigurację i dynamicznie dodać wszystkie metody, sprawdzić, czy nie ma duplikatów nazw itp.

Kiedy wywołuję metodę, muszę przeglądać listę akcji i dopasowywać ciąg znaków, aby zobaczyć, które wywołać.

Kiedy kompiluję kod i wywołuję Weapon.Do („atack”) zamiast „atak”, nie pojawia się błąd przy kompilacji.

Może to być odpowiednie rozwiązanie niektórych problemów, powiedzmy, że masz setki broni, wszystkie z różnymi kombinacjami metod losowych, ale tracisz wiele korzyści z OO i silnego pisania. To tak naprawdę nie oszczędza niczego przy downcastingu

Ewan
źródło
Myślę, że SP poradzi sobie z tym wszystkim (patrz edycja powyżej), broń miałaby SafteyOn()i Swordmiałaby wipeOffBlood(). Każda broń nie zna innych metod (i nie powinny być)
SP jest w porządku, ale jest równoważny z downcastingiem bez bezpieczeństwa typu. Chyba odpowiadałem na inne pytanie, pozwólcie, że zaktualizuję
Ewan,
2
Sam wzorzec strategii nie oznacza dynamicznego wyszukiwania strategii na liście lub w słowniku. Oznacza to, że zarówno weapon.do("attack")typ, jak i typ weapon.attack.perform()mogą być przykładami strategii. Wyszukiwanie strategii według nazwy jest konieczne tylko podczas konfigurowania obiektu z pliku konfiguracyjnego, chociaż użycie odbicia byłoby równie bezpieczne dla typu.
amon
które nie będą działać w tej sytuacji, ponieważ istnieją dwie osobne akcje: atak i przeładowanie, które należy powiązać z danymi wejściowymi użytkownika
Ewan