Obejście zasad w Czarodziejach i Wojownikach


9

W tej serii postów na blogu Eric Lippert opisuje problem z projektowaniem obiektowym, wykorzystując jako przykład kreatorów i wojowników:

abstract class Weapon { }
sealed class Staff : Weapon { }
sealed class Sword : Weapon { }

abstract class Player 
{ 
  public Weapon Weapon { get; set; }
}
sealed class Wizard : Player { }
sealed class Warrior : Player { }

a następnie dodaje kilka zasad:

  • Wojownik może używać tylko miecza.
  • Czarodziej może używać tylko laski.

Następnie demonstruje problemy, na które napotykasz, gdy próbujesz egzekwować te reguły za pomocą systemu typu C # (np. Czyniąc Wizardklasę odpowiedzialną za upewnienie się, że czarodziej może korzystać tylko z personelu). Naruszasz Zasadę zastępowania Liskowa, ryzykujesz wyjątki w czasie wykonywania lub kończysz się trudnym do rozszerzenia kodem.

Rozwiązanie, które wymyślił, polega na tym, że klasa Player nie dokonuje weryfikacji. Służy wyłącznie do śledzenia stanu. Następnie zamiast dać graczowi broń:

player.Weapon = new Sword();

stan jest modyfikowany przez Commands i zgodnie z Rules:

... tworzymy Commandobiekt o nazwie, Wieldktóry przyjmuje dwa obiekty stanu gry, a Playeri a Weapon. Kiedy użytkownik wydaje polecenie systemowi „ten czarodziej powinien dzierżyć ten miecz”, wówczas polecenie to jest oceniane w kontekście zestawu Rules, który tworzy sekwencję Effects. Mamy taki, Rulektóry mówi, że kiedy gracz próbuje użyć broni, efektem jest to, że istniejąca broń, jeśli taka istnieje, zostaje upuszczona, a nowa broń staje się bronią gracza. Mamy kolejną zasadę, która wzmacnia pierwszą zasadę, która mówi, że efekty pierwszej reguły nie mają zastosowania, gdy czarodziej próbuje użyć miecza.

Zasadniczo podoba mi się ten pomysł, ale mam obawy, jak można go wykorzystać w praktyce.

Wydaje się, że nic nie stoi na przeszkodzie, aby deweloper omijał Commandsand Rule, po prostu ustawiając Weaponna Player. WeaponNieruchomość musi być dostępny przez Wieldkomendę, więc nie może być wykonana private set.

Więc, co ma zapobiec deweloper z tej operacji? Czy muszą tylko pamiętać, żeby tego nie robić?


2
Nie sądzę, że to pytanie jest specyficzne dla języka (C #), ponieważ tak naprawdę jest to pytanie dotyczące projektowania OOP. Proszę rozważyć usunięcie tagu C #.
Maybe_Factor

1
Tag @maybe_factor c # jest w porządku, ponieważ opublikowany kod to c #.
CodingYoshi,

Dlaczego nie zapytasz bezpośrednio @EricLippert? Wydaje się, że pojawia się tutaj na tej stronie od czasu do czasu.
Doc Brown,

@Maybe_Factor - wahałem się nad znacznikiem C #, ale postanowiłem go zachować na wypadek, gdyby istniało rozwiązanie specyficzne dla języka.
Ben L

1
@DocBrown - zamieściłem to pytanie na jego blogu (co prawda kilka dni temu; nie czekałem tak długo na odpowiedź). Czy jest sposób na zwrócenie mu uwagi na moje pytanie?
Ben L

Odpowiedzi:


9

Cały argument, do którego prowadzi seria postów na blogu, znajduje się w części piątej :

Nie mamy powodu, aby sądzić, że system typu C # został zaprojektowany tak, aby miał wystarczającą ogólność, aby zakodować zasady Dungeons & Dragons, więc dlaczego w ogóle próbujemy?

Rozwiązaliśmy problem „gdzie idzie kod, który wyraża reguły systemu?” Dotyczy obiektów reprezentujących reguły systemu, a nie obiektów reprezentujących stan gry; troska o obiekty państwowe polega na tym, aby ich stan był spójny, a nie w ocenie reguł gry.

Broń, postacie, potwory i inne przedmioty w grze nie są odpowiedzialne za sprawdzanie, co mogą, a czego nie mogą zrobić. Odpowiedzialny za to jest system reguł. CommandObiekt nie robi nic z obiektami gry albo. To po prostu próba zrobienia z nimi czegoś. System reguł sprawdza następnie, czy polecenie jest możliwe, a kiedy to jest, system reguł wykonuje polecenie, wywołując odpowiednie metody na obiektach gry.

Jeśli deweloper chce stworzyć drugi system reguł, który robi rzeczy z postaciami i bronią, na które pierwszy system reguł nie zezwala, mogą to zrobić, ponieważ w C # nie możesz (bez nieprzyjemnych hacków refleksyjnych) dowiedzieć się, skąd pochodzi wywołanie metody od.

Obejściem, które może działać w niektórych sytuacjach, jest umieszczenie obiektów gry (lub ich interfejsów) w jednym zestawie z silnikiem reguł i oznaczenie dowolnej metody mutatora jako internal. Wszelkie systemy wymagające dostępu tylko do odczytu do obiektów gry byłyby w innym zestawie, co oznacza, że ​​miałyby dostęp tylko do publicmetod. Pozostawia to lukę w obiektach gry, które nazywają się metodami wewnętrznymi. Ale zrobienie tego byłoby oczywistym zapachem kodu, ponieważ zgodziliście się, że klasy obiektów gry powinny być głupimi posiadaczami stanów.


4

Oczywistym problemem oryginalnego kodu jest to, że wykonuje modelowanie danych zamiast modelowania obiektowego . Należy pamiętać, że absolutnie nie ma wzmianki o rzeczywistych wymaganiach biznesowych w powiązanym artykule!

Zacznę od próby uzyskania rzeczywistych wymagań funkcjonalnych. Na przykład: „Każdy gracz może atakować innych graczy, ...”. Tutaj:

interface Player {
    void Attack(Player enemy);
}

„Gracze mogą władać bronią użytą w ataku, czarodzieje mogą posługiwać się laską, wojownikami mieczem”

public class Wizard: Player {
    ...
    public void Wield(Staff weapon) { ... }
    ...
}
public class Warrior: Player {
    ...
    public void Wield(Sword sword) { ... }
    ...
}

„Każda broń zadaje obrażenia zaatakowanemu wrogowi”. Ok, teraz musimy mieć wspólny interfejs dla broni:

interface Weapon {
    void dealDamageTo(Player enemy);
}

I tak dalej ... Dlaczego nie ma go Wield()w Player? Ponieważ nie było wymogu, aby każdy gracz mógł używać dowolnej broni.

Mogę sobie wyobrazić, że byłby wymóg mówiący: „Każdy Playermoże próbować władać dowolnym Weapon”. Byłoby to jednak coś zupełnie innego. Modelowałbym to może w ten sposób:

interface Player {
    void Attack(Player enemy);
    void TryWielding(Weapon weapon); // Throws UnwieldableException
}

Podsumowanie: modeluj wymagania i tylko wymagania. Nie rób modelowania danych, to nie jest modelowanie oo.


1
Czy czytałeś serial? Może chcesz powiedzieć autorowi tej serii, aby nie modelował danych, ale wymagania. Wymagania, które masz w swojej odpowiedzi, to wymyślone wymagania, a NIE wymagania, które autor miał podczas budowania kompilatora C #.
CodingYoshi,

2
Eric Lippert szczegółowo opisuje problem techniczny w tej serii, co jest w porządku. To pytanie dotyczy jednak rzeczywistego problemu, a nie funkcji języka C #. Chodzi mi o to, że w prawdziwych projektach powinniśmy podążać za wymaganiami biznesowymi (dla których podałem wymyślone przykłady, tak), a nie zakładać relacji i właściwości. W ten sposób otrzymujesz model, który pasuje. Co było kolejką.
Robert Bräutigam

To pierwsza myśl, którą przeczytałem podczas tej serii. Autor wymyślił jakieś abstrakcje, nigdy nie oceniając ich dalej, po prostu pozostając przy nich. Próbuję mechanicznie rozwiązać problem, raz za razem. Zamiast myśleć o domenie i abstrakcjach, które są przydatne, najwyraźniej powinno być pierwsze. Moje głosowanie.
Vadim Samokhin

To jest poprawna odpowiedź. Artykuł wyraża sprzeczne wymagania (jeden z wymagań mówi, że gracz może posługiwać się [dowolną] bronią, podczas gdy inne wymagania mówią, że tak nie jest.), A następnie szczegółowo opisuje, jak trudno jest poprawnie wyrazić konflikt przez system. Jedyną prawidłową odpowiedzią jest usunięcie konfliktu. W takim przypadku oznacza to usunięcie wymogu, że gracz może władać dowolną bronią.
Daniel T.

2

Jednym ze sposobów byłoby przekazanie Wieldpolecenia do Player. Następnie gracz wykonuje Wieldpolecenie, które sprawdza odpowiednie reguły i zwraca Weapon, z którym Playernastępnie ustawia własne pole broni. W ten sposób pole broni może mieć prywatnego setera i można je ustawić tylko poprzez przekazanie Wieldpolecenia graczowi.


W rzeczywistości to nie rozwiązuje problemu. Deweloper, który wykonuje obiekt polecenia, może przekazać dowolną broń, a gracz ją ustawi. Przeczytaj serię, ponieważ problem jest trudniejszy niż myślisz. Właściwie stworzył tę serię, ponieważ natrafił na ten problem projektowy podczas opracowywania kompilatora Roslyn C #.
Kodowanie Yoshi

2

Nic nie stoi na przeszkodzie, aby programista to zrobił. Właściwie Eric Lippert wypróbował wiele różnych technik, ale wszystkie miały słabości. Taki był sens tej serii, że powstrzymanie programisty od zrobienia tego nie jest łatwe, a wszystko, czego próbował, miało wady. W końcu zdecydował, że należy użyć Commandobiektu z regułami.

Za pomocą reguł możesz ustawić Weaponwłaściwość a Wizardna Swordale, ale kiedy poprosisz o to, Wizardaby władać bronią (Miecz) i zaatakować ją, nie będzie to miało żadnego skutku, a zatem nie zmieni żadnego stanu. Jak mówi poniżej:

Mamy kolejną zasadę, która wzmacnia pierwszą zasadę, która mówi, że efekty pierwszej reguły nie mają zastosowania, gdy czarodziej próbuje użyć miecza. Skutkami tej sytuacji są „wydać smutny dźwięk puzonu, użytkownik traci akcję w tej turze, żaden stan gry nie jest mutowany

Innymi słowy, nie możemy egzekwować takiej reguły poprzez typerelacje, które próbował na wiele różnych sposobów, ale albo jej się to nie podobało, albo nie działało. Zatem jedyne, co powiedział, że możemy zrobić, to zrobić coś z tym w czasie wykonywania. Zgłoszenie wyjątku nie było dobre, ponieważ nie uważa go za wyjątek.

W końcu zdecydował się na powyższe rozwiązanie. To rozwiązanie zasadniczo mówi, że możesz ustawić dowolną broń, ale jeśli ją oddasz, jeśli nie jest to właściwa broń, byłaby zasadniczo bezużyteczna. Ale nie zostanie zgłoszony żaden wyjątek.

Myślę, że to dobre rozwiązanie. Chociaż w niektórych przypadkach wybrałbym też wzór try-set.


This solution basically says you can set any weapon but when you yield it, if not the right weapon, it would be essentially useless.Nie udało mi się go znaleźć w tej serii. Czy mógłbyś wskazać mi, gdzie proponowane jest to rozwiązanie?
Vadim Samokhin

@zapadlo Mówi to pośrednio. Skopiowałem tę część mojej odpowiedzi i zacytowałem ją. Oto znowu. W cytacie mówi: kiedy czarodziej próbuje władać mieczem. Jak czarodziej może władać mieczem, jeśli miecz nie został ustawiony? To musiało być ustawione. Następnie, jeśli czarodziej włada mieczem, efektem tej sytuacji jest „wydać smutny dźwięk puzonu, użytkownik traci akcję w tej turze
KodowanieYoshi

Hmm, myślę, że posługiwanie się mieczami w zasadzie oznacza, że ​​należy je ustawić, nie? Czytając ten akapit, interpretuję go jako skutek pierwszej reguły that the existing weapon, if there is one, is dropped and the new weapon becomes the player’s weapon. Chociaż druga zasada, that strengthens the first rule, that says that the first rule’s effects do not apply when a wizard tries to wield a sword.więc myślę, że istnieje reguła sprawdzająca, czy broń jest mieczem, więc nie może być władana przez czarodzieja, więc nie jest ustawiona. Zamiast tego zabrzmi smutny puzon.
Vadim Samokhin

Moim zdaniem byłoby dziwne, że jakaś zasada dowodzenia została naruszona. To jak rozmycie z powodu problemu. Po co radzić sobie z tym problemem po naruszeniu reguły i ustawieniu kreatora w nieprawidłowy stan? Czarodziej nie może mieć miecza, ale tak jest! Dlaczego nie pozwolić na to?
Vadim Samokhin

Zgadzam się z @Zapadlo, jak interpretować Wieldtutaj. Myślę, że to nieco myląca nazwa polecenia. Coś takiego ChangeWeaponbyłoby bardziej dokładne. Sądzę, że możesz mieć inny model, w którym możesz ustawić dowolną broń, ale kiedy ją oddasz, jeśli nie jest to właściwa broń, byłaby zasadniczo bezużyteczna . Brzmi interesująco, ale nie sądzę, że tak opisuje Eric Lippert.
Ben L

2

Pierwszym odrzuconym rozwiązaniem autora było przedstawienie reguł według systemu typów. System typów jest oceniany podczas kompilacji. Jeśli odłączysz reguły od systemu typów, nie będą one już sprawdzane przez kompilator, więc nic nie stoi na przeszkodzie, aby programista popełnił błąd sam w sobie.

Ale ten problem napotyka każdy element logiki / modelowania, który nie jest sprawdzany przez kompilator, a ogólną odpowiedzią na to pytanie jest testowanie (jednostkowe). Dlatego zaproponowane przez autora rozwiązanie wymaga silnej uprzęży testowej, aby obejść błędy programistów. Aby podkreślić, że potrzebna jest silna wiązka testowa dla błędów wykrywanych tylko w czasie wykonywania, zapoznaj się z tym artykułem autorstwa Bruce'a Eckela, który stanowi argument, że należy wymienić mocne pisanie na silniejsze testowanie w dynamicznych językach.

Podsumowując, jedyną rzeczą, która może zapobiec popełnianiu błędów przez programistów, jest zestaw testów (jednostkowych) sprawdzających, czy przestrzegane są wszystkie reguły.


Masz używać interfejsu API, a interfejs API ma zapewnić przeprowadzenie testów jednostkowych lub przyjęcie takiego założenia? Całe wyzwanie polega na modelowaniu, więc model nie pęka, nawet jeśli programista używający modelu jest nieostrożny.
Kodowanie Yoshi

1
Chodziło mi o to, że nic nie stoi na przeszkodzie, aby deweloper popełnił błędy. W proponowanym rozwiązaniu reguły są odłączane od danych, więc jeśli nie utworzysz własnych kontroli, nic nie stoi na przeszkodzie, abyś używał obiektów danych bez zastosowania reguł.
larsbe

1

Mogłem tu pominąć subtelność, ale nie jestem pewien, czy problem dotyczy systemu czcionek. Może to konwencja w języku C #.

Na przykład możesz uczynić ten typ całkowicie bezpiecznym, Weaponchroniąc seter Player. Następnie dodać setSword(Sword)i setStaff(Staff)na Warriori Wizardodpowiednio, że wezwanie chroniony seter.

W ten sposób relacja Player/ Weaponjest sprawdzana statycznie, a kod, który go nie obchodzi, może po prostu użyć a, Playeraby uzyskać Weapon.


Eric Lippert nie chciał rzucać wyjątków. Czy czytałeś serial? Rozwiązanie musi spełniać wymagania, a wymagania te są wyraźnie określone w serii.
Kodowanie Yoshi

@CodingYoshi Dlaczego miałoby to rzucać wyjątek? Jest to typ bezpieczny, tzn. Sprawdzalny w czasie kompilacji.
Alex

Niestety nie mogłem zmienić mojego komentarza, gdy zdałem sobie sprawę, że nie rzucasz wyjątków. W ten sposób złamałeś dziedzictwo. Zobacz problem, który autor próbował rozwiązać, polegający na tym, że nie możesz po prostu dodać metody takiej jak Ty, ponieważ teraz typów nie można traktować polimorficznie.
CodingYoshi,

@CodingYoshi Warunkiem polimorficznym jest to, że gracz ma broń. W tym schemacie gracz rzeczywiście ma broń. Żadne dziedzictwo nie jest zerwane. To rozwiązanie będzie się kompilować tylko wtedy, gdy poprawnie zastosujesz reguły.
Alex

@CodingYoshi Teraz to nie znaczy, że nie możesz napisać kodu, który wymagałby sprawdzenia środowiska wykonawczego, np. Jeśli spróbujesz dodać a Weapondo Player. Ale nie ma systemu typów, w którym nie znasz konkretnych typów w czasie kompilacji, które mogłyby działać na tych konkretnych typach w czasie kompilacji. Zgodnie z definicją. Ten schemat oznacza, że ​​tylko ten przypadek musi być rozpatrywany w czasie wykonywania, ponieważ jest on w rzeczywistości lepszy niż jakikolwiek schemat Erica.
Alex

0

Co więc powstrzymuje programistę przed zrobieniem tego? Czy muszą tylko pamiętać, żeby tego nie robić?

To pytanie jest w istocie takie samo w przypadku dość świętej wojny o nazwie „ gdzie umieścić walidację ” (najprawdopodobniej również ddd).

Zanim odpowiesz na to pytanie, zadaj sobie pytanie: jaki jest charakter zasad, których chcesz przestrzegać? Czy są wyryte w kamieniu i definiują byt? Czy złamanie tych zasad powoduje, że jednostka przestaje być tym, czym jest? Jeśli tak, wraz z utrzymaniem tych zasad w sprawdzaniu poprawności poleceń , umieść je również w encji. Jeśli więc programista zapomni o sprawdzeniu poprawności polecenia, twoje podmioty nie będą w niepoprawnym stanie.

Jeśli nie - cóż, z natury rzeczy oznacza to, że reguły te są specyficzne dla poleceń i nie powinny znajdować się w jednostkach domeny. Naruszenie tych reguł skutkuje więc działaniami, które nie powinny były mieć miejsca, ale nie były w stanie nieprawidłowego modelu.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.