Jak programowanie funkcjonalne radzi sobie z sytuacją, w której ten sam obiekt jest przywoływany z wielu miejsc?

10

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, healthaby odzwierciedlić tę zmianę - i właśnie to robię teraz. Jednak powoduje to, że Monsterklasa 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 Monsterobiektu, zachowując go identycznym ze starym, z wyjątkiem tego jednego pola; a metoda suffer_hitzwróciłaby ten nowy obiekt zamiast modyfikować stary na miejscu. Następnie podobnie skopiowałbym Battlefieldobiekt, zachowując wszystkie pola bez zmian, z wyjątkiem tego potwora.

Obejmuje to co najmniej 2 trudności:

  1. 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.
  2. 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 Teamkaż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.

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 Statemonadzie. 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 Statemonadzie; 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ą?

gaazkam
źródło
6
Aby nadać temu trochę perspektywy, funkcjonalne niezmienne programy są głównie przeznaczone do przetwarzania danych wymagających współbieżności. Innymi słowy, programy przetwarzające dane wejściowe za pomocą zestawu równań lub procesów, które dają wynik wyjściowy. Niezmienność pomaga w tym scenariuszu z kilku powodów: gwarantuje się, że wartości odczytywane przez wiele wątków nie zmienią się w trakcie ich życia, co znacznie upraszcza możliwość przetwarzania danych w sposób niezablokowany oraz uzasadnienie działania algorytmu.
Robert Harvey
8
Brudny mały sekret dotyczący niezmienności funkcjonalnej i programowania gier polega na tym, że te dwie rzeczy są ze sobą w pewnym stopniu niezgodne. Zasadniczo próbujesz modelować dynamiczny, ciągle zmieniający się system przy użyciu statycznej, nieruchomej struktury danych.
Robert Harvey
2
Nie traktuj mutowalności wobec niezmienności jako dogmatu religijnego. Są sytuacje, w których każda jest lepsza od drugiej, niezmienność nie zawsze jest lepsza, np. Napisanie zestawu narzędzi GUI z niezmiennymi typami danych będzie absolutnym koszmarem.
whatsisname
1
To pytanie specyficzne dla C # i jego odpowiedzi obejmują zagadnienie płyty kotłowej, wynikające głównie z potrzeby stworzenia nieznacznie zmodyfikowanych (zaktualizowanych) klonów istniejącego niezmiennego obiektu.
rwong
2
Kluczowym spostrzeżeniem jest to, że potwór w tej grze jest uważany za byt. Również wynik każdej bitwy (składający się z numeru sekwencji bitwy, identyfikatorów jednostek potworów, stanów potworów przed bitwą i po bitwie) jest uważany za stan w pewnym punkcie czasowym (lub kroku czasowym). W ten sposób gracze ( Team) mogą odzyskać wynik bitwy, a tym samym stany potworów, za pomocą krotki (numer bitwy, identyfikator istoty potwora).
rwong

Odpowiedzi:

19

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:

Podstawowy sposób, w jaki działa nasz tryb wieloosobowy, polega na tym, że wszyscy klienci symulują stan gry, a jedynie odbierają i wysyłają dane wejściowe gracza (nazywane Akcjami wejściowymi). Główną odpowiedzialnością serwera jest proxy Akcji Wejściowych i upewnienie się, że wszyscy klienci wykonują te same akcje w tym samym tiku.

Ponieważ serwer musi rozstrzygać, kiedy akcje są wykonywane, akcja gracza przesuwa się w następujący sposób: Akcja gracza -> Klient gry -> Sieć -> Serwer -> Sieć -> Klient gry. Oznacza to, że każda akcja gracza jest wykonywana tylko wtedy, gdy wykona ona podróż w obie strony przez sieć. Sprawiłoby to, że gra wydawałaby się bardzo opóźniona, dlatego ukrywanie opóźnień było mechanizmem dodanym w grze prawie od momentu wprowadzenia trybu wieloosobowego. Ukrywanie opóźnień polega na symulacji wkładu gracza, bez uwzględnienia działań innych graczy i bez arbitrażu serwera.

W Factorio mamy stan gry, jest to pełny stan mapy, gracz, uprawnienia, wszystko. Jest symulowany deterministycznie na wszystkich klientach na podstawie działań otrzymanych z serwera. Jest to święte i jeśli kiedykolwiek różni się od serwera lub innego klienta, następuje desynchronizacja.

Oprócz stanu gry mamy stan opóźnienia. Zawiera mały podzbiór stanu głównego. Opóźnienie nie jest święte i po prostu reprezentuje, jak naszym zdaniem będzie wyglądał stan gry w przyszłości na podstawie działań wejściowych wykonanych przez gracza.

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:

  1. przetwarzaj dane wejściowe i generuj odpowiednie zdarzenia
  2. generować zdarzenia wewnętrzne (np. w wyniku kolizji obiektów itp.)
  3. stosuj wydarzenia do niezmiennych niezmiennych potworów, aby generować nowe potwory do następnego tyknięcia - najczęściej kopiując stary niezmieniony stan, jeśli to możliwe, ale tworząc nowe obiekty stanu w razie potrzeby.
  4. renderuj i powtarzaj dla następnego tiku.

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.

SusanW
źródło
2
Aby uzyskać dodatkowe informacje, zobacz Sourcing zdarzeń i CQRS . Jest to podobna dziedzina problemowa: modelowanie zmian stanu w systemie rozproszonym jako serii niezmiennych zdarzeń w czasie.
Aaron M. Eshbach,
@ AaronM.Eshbach to jest to! Czy masz coś przeciwko, jeśli w odpowiedzi podam Twój komentarz / cytaty? To sprawia, że ​​brzmi bardziej autorytatywnie. Dzięki!
SusanW
Oczywiście, że nie, proszę.
Aaron M. Eshbach,
3

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ń

p1 - send m1 to battlefield
p2 - send m2 to battlefield
m1 - attacks m2 (2 dam)
m2 - attacks m1 (10 dam)
p1 - retreats m1

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

Ewan
źródło