Utrzymanie złożonego stanu w Haskell

Załóżmy, że budujesz dość dużą symulację w Haskell. Istnieje wiele różnych typów jednostek, których atrybuty aktualizują się w miarę postępu symulacji. Powiedzmy dla przykładu, że wasze byty nazywane są małpami, słoniami, niedźwiedziami itp..

Jaka jest Twoja preferowana metoda utrzymywania Stanów tych jednostek?

Pierwsze i najbardziej oczywiste podejście, o którym myślałem, było takie:

mainLoop :: [Monkey] -> [Elephant] -> [Bear] -> String
mainLoop monkeys elephants bears =
  let monkeys'   = updateMonkeys   monkeys
      elephants' = updateElephants elephants
      bears'     = updateBears     bears
  in
    if shouldExit monkeys elephants bears then "Done" else
      mainLoop monkeys' elephants' bears'

Jest już brzydka mając każdy typ bytu wyraźnie wymienione w sygnaturze funkcji mainLoop. Możesz sobie wyobrazić, jak byłoby okropnie, gdybyś miał, powiedzmy, 20 rodzajów Bytów. (20 Nie jest nieuzasadnione dla złożonych symulacji.) Uważam więc, że jest to niedopuszczalne podejście. Ale jego ratunkiem jest to, że funkcje takie jak updateMonkeys są bardzo wyraźne w tym, co robią: biorą listę małp i zwracają nową.

Więc następną myślą byłoby zwijanie wszystkiego w jedną wielką strukturę danych, która przechowuje cały stan, a tym samym czyszczenie podpis mainLoop:

mainLoop :: GameState -> String
mainLoop gs0 =
  let gs1 = updateMonkeys   gs0
      gs2 = updateElephants gs1
      gs3 = updateBears     gs2
  in
    if shouldExit gs0 then "Done" else
      mainLoop gs3

Niektórzy sugerują, że owijamy GameState w stan Monad i wywołujemy updateMonkeys itd. w do. W porządku. Niektórzy raczej sugerują, abyśmy wyczyścili go za pomocą kompozycji funkcji. Myślę, że też dobrze. (BTW, jestem nowicjuszem z Haskell, więc może się mylę co do niektórych z tego.)

Ale problem polega na tym, że funkcje takie jak updateMonkeys nie dają użytecznych informacji z ich podpisu typu. Nie możesz być pewien, co robią. Jasne, updateMonkeys jest nazwą opisową, ale to małe pocieszenie. Kiedy mijam obiekt Boga i mówię "proszę zaktualizuj mój globalny stan", czuję się jakbyśmy wrócili do świata imperatywnego. Wydaje się, że zmienne globalne pod inną nazwą: masz funkcję, która robi coś do stanu globalnego, nazywasz to i masz nadzieję na najlepsze. (Przypuszczam, że nadal unikasz problemów z współbieżnością, które byłyby obecne w globalnych zmiennych w imperatywnym programie. Ale Meh, współbieżność nie jest prawie jedyną rzeczą złą ze zmiennymi globalnymi.)

Kolejny problem jest taki: Załóżmy, że obiekty muszą wchodzić w interakcje. Na przykład mamy taką funkcję:

stomp :: Elephant -> Monkey -> (Elephant, Monkey)
stomp elephant monkey =
  (elongateEvilGrin elephant, decrementHealth monkey)

Powiedzmy, że to zostanie wywołane updateElephants, ponieważ tam sprawdzamy, czy któryś ze słoni nie jest w zasięgu małp. Jak elegancko propagować zmiany zarówno małp jak i słoni w tym scenariuszu? W naszym drugim przykładzie, updateElephants bierze i zwraca obiekt Boga, aby mógł spowodować obie zmiany. Ale to po prostu mętnieje wody dalej i wzmacnia mój punkt widzenia: z obiektem Boga, skutecznie mutujesz globalne zmienne. A jeśli nie używasz przedmiotu Boga, nie jestem pewien, jak propagujesz tego typu zmiany.

Co robić? Z pewnością wiele programów musi zarządzać złożonym stanem, więc zgaduję, że istnieją pewne dobrze znane podejścia do tego problemu. Dla porównania, oto jak mogę rozwiązać problem w świecie OOP. Będzie Monkey, Elephant, itd. obiektów. Pewnie mam metody klasowe, żeby szukać wszystkich żywych zwierząt. Może mógłbyś poszukać po lokalizacji, ID, cokolwiek. Dzięki strukturom danych leżącym u podstaw funkcji wyszukiwania, pozostaną przydzielone na stercie. (Zakładam GC lub liczenie referencji.) Ich zmienne członkowskie będą mutowane przez cały czas. Każda metoda z każdej klasy byłaby w stanie zmutować każde żywe zwierzę z każdej innej klasy. Np. Elephant może mieć metodę stomp, która nie jest to jednak możliwe w przypadku, gdy nie jest to możliwe.]} Podobnie, w erlangu lub innym projekcie zorientowanym na aktora, można rozwiązać te problemy dość elegancko: każdy aktor zachowuje swoją własną pętlę, a tym samym swój własny stan, więc nigdy nie potrzebujesz przedmiotu Boga. Przekazywanie wiadomości pozwala jednemu obiektowi na wywołanie zmian w innych obiektach bez przenoszenia kilku rzeczy na stosie połączeń. Ale słyszałem, że aktorzy w Haskell są marszczy brwi.
Author: Michael Currie, 2013-03-18

2 answers

Odpowiedzią jest functional reactive programming (FRP). Jest to hybryda dwóch stylów kodowania: zarządzania stanem komponentu i wartości zależnych od czasu. Ponieważ FRP to właściwie cała rodzina wzorców projektowych, chcę być bardziej konkretny: polecam Netwire.

Podstawowa idea jest bardzo prosta: piszesz wiele małych, samodzielnych elementów, z których każdy ma swój własny stan lokalny. Jest to praktycznie równoważne wartościom zależnym od czasu, ponieważ za każdym razem zapytanie takiego komponentu może otrzymać inną odpowiedź i spowodować aktualizację stanu lokalnego. Następnie łączysz te komponenty, tworząc swój rzeczywisty program.

Choć brzmi to skomplikowanie i nieefektywnie, to w rzeczywistości jest to tylko bardzo cienka warstwa wokół zwykłych funkcji. Schemat projektowy zaimplementowany przez Netwire jest inspirowany AFRP (Arrowized Functional Reactive Programming). Jest chyba na tyle Inna, że zasługuje na własną nazwę (WFRP?). Możesz przeczytać tutorial .

W każdym przypadku następuje małe demo. Twoje klocki budulcowe to druty:

myWire :: WireP A B

Pomyśl o tym jako o składniku. Jest to zmienna w czasie wartość typu B , która zależy od zmiennej w czasie wartości typu A , na przykład cząstka w symulatorze:

particle :: WireP [Particle] Particle

Zależy od listy cząstek (na przykład wszystkich obecnie istniejących cząstek) i sama jest cząstką. Użyjmy predefiniowanego przewodu (z uproszczonym "type": "content"]}

time :: WireP a Time

Jest to zmienna w czasie wartość typu Time (= Double ). Cóż, sam czas (zaczynając od 0 liczonego od momentu uruchomienia sieci przewodowej). Ponieważ nie zależy od innej zmiennej w czasie wartości, możesz podać ją, co chcesz, stąd polimorficzny typ wejścia. Istnieją również stałe przewody (wartości zmienne w czasie, które nie zmieniają się w czasie):

pure 15 :: Wire a Integer

-- or even:
15 :: Wire a Integer

Aby podłączyć dwa przewody wystarczy użyć kategorycznego skład:

integral_ 3 . 15

Daje to zegar z 15-krotną prędkością czasu rzeczywistego (Całka 15 w czasie), zaczynając od 3 (stała całkowania). Dzięki różnym instancjom klasy przewody są bardzo poręczne do łączenia. Możesz używać zwykłych operatorów, a także stylu aplikacyjnego lub stylu strzałek. Chcesz zegar, który zaczyna się od 10 i jest dwukrotnie szybszy w czasie rzeczywistym?

10 + 2*time

Chcemy cząstki, która zaczyna się i (0, 0) z (0, 0) prędkością i przyspiesza z (2, 1) na sekundę na drugi?

integral_ (0, 0) . integral_ (0, 0) . pure (2, 1)

Chcesz wyświetlić statystyki, gdy użytkownik naciśnie spację?

stats . keyDown Spacebar <|> "stats currently disabled"

To tylko mały ułamek tego, co Netwire może dla Ciebie zrobić.

 29
Author: ertes,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2013-03-18 01:47:46

Wiem, że to stary temat. Ale stoję teraz w obliczu tego samego problemu, próbując wdrożyć ćwiczenie szyfru szyfru kolejowego z exercism.io. to jest dość rozczarowujące, aby zobaczyć tak powszechny problem mając tak słabą uwagę w Haskell. Nie biorę tego, że aby zrobić coś tak prostego, jak utrzymanie stanu, muszę nauczyć się FRP. Tak więc kontynuowałem googlowanie i znalazłem rozwiązanie wyglądające bardziej prosto-stan monad: https://en.wikibooks.org/wiki/Haskell/Understanding_monads/State

 1
Author: alehro,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-12-28 17:32:47