Czytam i słyszę, że ludzie (również na tej stronie) rutynowo chwalą paradygmat programowania funkcjonalnego, podkreślając, jak dobrze jest mieć wszystko niezmienne. W szczególności ludzie proponują to podejście nawet w tradycyjnie imperatywnych językach OO, takich jak C #, Java lub C ++, nie tylko w czysto funkcjonalnych językach, takich jak Haskell, które wymuszają to na programistach.
Trudno mi to zrozumieć, ponieważ uważam, że zmienność i skutki uboczne ... są wygodne. Biorąc jednak pod uwagę to, jak ludzie obecnie potępiają skutki uboczne i uważają je za dobrą praktykę, aby się ich pozbyć wszędzie, gdzie to możliwe, uważam, że jeśli chcę być kompetentnym programistą, muszę zacząć swoją przygodę w celu lepszego zrozumienia paradygmatu ... Stąd moje Q.
Jednym z miejsc, w którym znajduję problemy z paradygmatem funkcjonalnym, jest to, że obiekt jest naturalnie przywoływany z wielu miejsc. Opiszę to na dwóch przykładach.
Pierwszym przykładem będzie moja gra w C #, którą próbuję zrobić w wolnym czasie . To turowa gra internetowa, w której obaj gracze mają zespoły 4 potworów i mogą wysłać potwora ze swojej drużyny na pole bitwy, gdzie zmierzy się z potworem wysłanym przez przeciwnika. Gracze mogą również przywoływać potwory z pola bitwy i zastępować ich innymi potworami ze swojej drużyny (podobnie jak Pokemon).
W tym ustawieniu do jednego potwora można oczywiście przypisać co najmniej z dwóch miejsc: drużyny gracza i pola bitwy, które odnoszą się do dwóch „aktywnych” potworów.
Rozważmy teraz sytuację, w której jeden potwór zostanie trafiony i straci 20 punktów zdrowia. W nawiasie paradygmatu imperatywnego modyfikuję pole tego potwora, health
aby odzwierciedlić tę zmianę - i właśnie to robię teraz. Jednak powoduje to, że Monster
klasa jest zmienna, a powiązane funkcje (metody) nieczyste, co, jak sądzę, jest obecnie uważane za złą praktykę.
Mimo że wyraziłem zgodę na posiadanie kodu tej gry w stanie mniej niż idealnym, aby mieć jakiekolwiek nadzieje na jej ukończenie w pewnym momencie przyszłości, chciałbym wiedzieć i zrozumieć, jak to powinno być napisane poprawnie. Dlatego: Jeśli jest to wada projektowa, jak to naprawić?
W stylu funkcjonalnym, jak rozumiem, zamiast tego zrobiłbym kopię tego Monster
obiektu, zachowując go identycznym ze starym, z wyjątkiem tego jednego pola; a metoda suffer_hit
zwróciłaby ten nowy obiekt zamiast modyfikować stary na miejscu. Następnie podobnie skopiowałbym Battlefield
obiekt, zachowując wszystkie pola bez zmian, z wyjątkiem tego potwora.
Obejmuje to co najmniej 2 trudności:
- Hierarchia może być znacznie głębsza niż ten uproszczony przykład po prostu
Battlefield
->Monster
. Musiałbym wykonać takie kopiowanie wszystkich pól oprócz jednego i zwrócenie nowego obiektu aż do tej hierarchii. Byłby to kod płyty kotłowej, co wydaje mi się irytujące, zwłaszcza, że programowanie funkcjonalne ma zmniejszyć płytę kotła. - Znacznie poważniejszym problemem jest jednak to, że doprowadziłoby to do braku synchronizacji danych . Aktywny potwór na polu zmniejszyłby swoje zdrowie; jednak ten sam potwór, o którym mówi jego kontrolujący gracz
Team
, nie zrobiłby tego. Gdybym zamiast tego przyjął imperatywny styl, każda modyfikacja danych byłaby natychmiast widoczna ze wszystkich innych miejsc kodu, a w takich przypadkach jak ten uważam, że jest to naprawdę wygodne - ale sposób, w jaki otrzymuję rzeczy, jest dokładnie tym , co mówią ludzie źle z imperatywnym stylem!- Teraz można zająć się tym problemem, podróżując do
Team
każdego następnego ataku. To dodatkowa praca. Co jednak, jeśli do potwora można później nagle odwoływać się z jeszcze większej liczby miejsc? Co się stanie, jeśli przyjdę z umiejętnością, która na przykład pozwala potworowi skupić się na innym potworze, który niekoniecznie jest na polu (właściwie rozważam taką umiejętność)? Czy na pewno pamiętam, aby po każdym ataku odbyć podróż do skupionych potworów? Wydaje się, że to bomba zegarowa, która wybuchnie, gdy kod stanie się bardziej złożony, więc myślę, że nie jest to rozwiązanie.
- Teraz można zająć się tym problemem, podróżując do
Pomysł na lepsze rozwiązanie pochodzi z mojego drugiego przykładu, kiedy napotkałem ten sam problem. W środowisku akademickim powiedziano nam, aby w Haskell napisać tłumacza własnego języka. (W ten sposób musiałem zacząć rozumieć, czym jest FP). Problem pojawił się, gdy wdrażałem zamknięcia. Po raz kolejny ten sam zakres może być teraz przywoływany z wielu miejsc: poprzez zmienną, która utrzymuje ten zakres oraz jako zakres nadrzędny dowolnych zagnieżdżonych zakresów! Oczywiście, jeśli wprowadzono zmianę w tym zakresie za pomocą któregokolwiek z odniesień do niego wskazujących, zmiana ta musi być widoczna również we wszystkich innych odniesieniach.
Rozwiązaniem, które przyjąłem, było przypisanie każdemu zakresowi identyfikatora i utrzymanie centralnego słownika wszystkich zakresów w State
monadzie. Teraz zmienne będą miały tylko identyfikator zakresu, do którego były zobowiązane, a nie sam zakres, a zakresy zagnieżdżone również będą miały identyfikator swojego zakresu nadrzędnego.
Wydaje mi się, że w mojej walce z potworami można by zastosować to samo podejście ... Pola i drużyny nie odnoszą się do potworów; zamiast tego przechowują identyfikatory potworów zapisane w centralnym słowniku potworów.
Jednak po raz kolejny widzę problem z tym podejściem, który uniemożliwia mi zaakceptowanie go bez wahania jako rozwiązania problemu:
To po raz kolejny źródło kodu typu „kocioł”. To sprawia, że jednowierszowe są koniecznie 3-liniowe: to, co poprzednio było modyfikacją pojedynczego pola w jednym wierszu, teraz wymaga (a) Pobieranie obiektu ze słownika centralnego (b) Wprowadzanie zmiany (c) Zapisywanie nowego obiektu do centralnego słownika. Ponadto przechowywanie identyfikatorów obiektów i centralnych słowników zamiast odwołań zwiększa złożoność. Ponieważ FP jest reklamowane w celu zmniejszenia złożoności i poprawiania kodu, oznacza to, że robię to źle.
Chciałem też napisać o drugim problemie, który wydaje się znacznie poważniejszy: takie podejście wprowadza wycieki pamięci . Obiekty, które są nieosiągalne, będą zwykle śmieciami. Jednak obiektów przechowywanych w centralnym słowniku nie można zbierać, nawet jeśli żaden osiągalny obiekt nie odwołuje się do tego konkretnego identyfikatora. I chociaż teoretycznie ostrożne programowanie może uniknąć wycieków pamięci (możemy zadbać o ręczne usunięcie każdego obiektu ze słownika centralnego, gdy nie jest już potrzebny), jest to podatne na błędy i FP jest reklamowane w celu zwiększenia poprawności programów, więc po raz kolejny może to nie być właściwą drogą.
Dowiedziałem się jednak z czasem, że wydaje się to raczej rozwiązanym problemem. Java zapewnia, WeakHashMap
że można by rozwiązać ten problem. C # zapewnia podobną funkcję - ConditionalWeakTable
- chociaż zgodnie z dokumentacją ma być używana przez kompilatory. A w Haskell mamy System.Mem.Weak .
Czy przechowywanie takich słowników jest właściwym funkcjonalnym rozwiązaniem tego problemu, czy też jest prostsze, którego nie widzę? Wyobrażam sobie, że liczba takich słowników może łatwo wzrosnąć i źle; więc jeśli te słowniki mają być również niezmienne, może to oznaczać wiele przekazywania parametrów lub, w językach obsługujących to, obliczenia monadyczne, ponieważ słowniki byłyby przechowywane w monadach (ale ponownie czytam to w czysto funkcjonalnym języki jak najmniej kodu powinny być monadyczne, podczas gdy to rozwiązanie słownikowe umieściłoby prawie cały kod w State
monadzie; co ponownie budzi wątpliwości, czy jest to właściwe rozwiązanie.)
Po namyśle myślę, że dodałbym jeszcze jedno pytanie: Co zyskujemy dzięki konstruowaniu takich słowników? To, co jest złe w programowaniu imperatywnym, jest zdaniem wielu ekspertów tym, że zmiany w niektórych obiektach przenoszą się na inne fragmenty kodu. Aby rozwiązać ten problem, obiekty powinny być niezmienne - właśnie z tego powodu, jeśli dobrze rozumiem, wprowadzone w nich zmiany nie powinny być widoczne gdzie indziej. Ale teraz martwię się o inne fragmenty kodu działające na nieaktualnych danych, więc wymyślam centralne słowniki, aby ... po raz kolejny zmiany w niektórych fragmentach kodu rozprzestrzeniały się na inne fragmenty kodu! Czy nie wracamy zatem do imperatywnego stylu ze wszystkimi jego domniemanymi wadami, ale z dodatkową złożonością?
źródło
Team
) mogą odzyskać wynik bitwy, a tym samym stany potworów, za pomocą krotki (numer bitwy, identyfikator istoty potwora).Odpowiedzi:
Jak programowanie funkcjonalne obsługuje obiekt, do którego odwołuje się wiele miejsc? Zachęca Cię do ponownego odwiedzenia swojego modelu!
Aby wyjaśnić ... przyjrzyjmy się czasami, jak pisane są gry sieciowe - z centralną kopią stanu gry „złotego źródła” oraz zestawem zdarzeń przychodzących klientów, które aktualizują ten stan, a następnie przesyłają z powrotem do innych klientów .
Możesz przeczytać o zabawie, jaką zespół Factorio sprawił, że zachowywał się dobrze w niektórych sytuacjach; oto krótki przegląd ich modelu:
Kluczową sprawą jest to, że stan każdego obiektu jest niezmienny dla określonego tiku w linii czasu . Wszystko w globalnym stanie wieloosobowym musi ostatecznie zejść w deterministyczną rzeczywistość.
I - to może być klucz do twojego pytania. Stan każdego bytu jest niezmienny dla danego tiku, a Ty śledzisz zdarzenia przejścia, które z czasem powodują powstawanie nowych wystąpień.
Jeśli się nad tym zastanowić, kolejka zdarzeń przychodzących z serwera musi mieć dostęp do centralnego katalogu encji, aby mógł zastosować swoje zdarzenia.
W końcu proste, jednoliniowe metody mutatora, których nie chcesz komplikować, są proste, ponieważ tak naprawdę nie modelujesz dokładnie czasu. W końcu, jeśli kondycja może się zmienić w środku pętli przetwarzania, to wcześniejsze jednostki w tym tiku zobaczą starą wartość, a później zmienioną. Ostrożne zarządzanie tym oznacza co najmniej różnicowanie prądu (niezmiennego) i następnego (w budowie), które są tak naprawdę tylko dwoma tyknięciami w wielkiej linii czasu tyknięć!
Tak więc, jako ogólny przewodnik, rozważ rozbicie stanu potwora na kilka małych obiektów, które dotyczą, powiedzmy, lokalizacji / prędkości / fizyki, zdrowia / obrażeń, zasobów. Skonstruuj zdarzenie, aby opisać każdą mutację, która może się zdarzyć, i uruchom główną pętlę jako:
Czy jakoś tak. Zastanawiam się, „jak sprawiłbym, żeby to rozpowszechniono?” ogólnie rzecz biorąc, jest całkiem dobrym ćwiczeniem umysłowym dla lepszego zrozumienia, kiedy nie jestem pewien, gdzie żyją i jak powinny się rozwijać.
Dzięki notatce @ AaronM.Eshbach podkreślającej, że jest to domena problemowa podobna do Sourcingu zdarzeń i wzorca CQRS , w którym modelujesz zmiany stanu w systemie rozproszonym jako serię niezmiennych zdarzeń w czasie . W tym przypadku najprawdopodobniej próbujemy oczyścić złożoną aplikację bazy danych, segregując (jak sama nazwa wskazuje!) Obsługę komendy mutator od systemu zapytań / widoku. Oczywiście bardziej złożony, ale bardziej elastyczny.
źródło
Nadal jesteś w połowie w obozie rozkazującym. Zamiast myśleć o jednym obiekcie naraz, pomyśl o swojej grze w kontekście historii rozgrywek lub wydarzeń
itp
Możesz obliczyć stan gry w dowolnym momencie, łącząc działania razem, aby stworzyć niezmienny obiekt stanu. Każda gra jest funkcją, która pobiera obiekt stanu i zwraca nowy obiekt stanu
źródło