Вступление

Мне встречалась фраза: "для многих знакомство с Haskell заканчивается на монадах". Монады действительно сложны для понимания, а самая непонятная, лично для меня, была монада State.

На простом примере, я хочу показать всю полезность монады State и еще большую полезность трансформера StateT.

Идея

  • Есть игровое поле

  • Пустые ячейки поля обозначены символом 'O'

  • Символом 'X' будет обозначен "герой", который сможет перемещаться по игровому полю вверх, вниз, влево, вправо.

Игровое поле

Начнем с определения игрового поля. Оно будет квадратным. В нем хранится информация о размере этого самого поля и позиция героя:

data GameField = GameField Int (Int, Int)

Для приведения игрового поля к строке, воспользуемся модулем Data.Array понадобится три функции:

listArray :: Ix i => (i, i) -> [e] -> Array i e
(//)      :: Ix i => Array i e -> [(i, e)] -> Array i e
elems     :: Array i e -> [e]

listArray принимает нижнюю и верхнюю границы индексов массива, список значений, а возвращает массив. Список значений может быть любой длины, лишь бы только элементов списка хватило для построения возвращаемого массива. Бесконечного списка точно хватит, repeat как раз и создает бесконечный список. И да вместо чисел можно использовать, например пары, они будут играть роль координат.

listArray ((1,1), (3,3)) (repeat 'O')
array ((1,1),(3,3)) [((1,1),'O'),((1,2),'O'),((1,3),'O')
                    ,((2,1),'O'),((2,2),'O'),((2,3),'O')
                    ,((3,1),'O'),((3,2),'O'),((3,3),'O')]

Оператор (//) принимает массив, список пар (индекс, значение). С помощью (//) можно будет помещать героя в заданные координаты

arr // [((2,2), 'X')]
array ((1,1),(3,3)) [((1,1),'O'),((1,2),'O'),((1,3),'O')
                    ,((2,1),'O'),((2,2),'X'),((2,3),'O')
                    ,((3,1),'O'),((3,2),'O'),((3,3),'O')]

elems возвращает список элементов массива (строка в нашем случае). Далее список разделим на список списков, который соберется функцией unlines в строку в задуманном виде. Сейчас файл GameField.hs выглядит так:

module GameField where

import           Data.Array

data GameField = GameField Int (Int, Int)

instance Show GameField where
  show (GameField s h) =
    unlines $ splitString $ elems $ (// [(h, 'X')]) $ listArray
      ((1, 1), (s, s))
      (repeat 'O')
   where
    splitString :: String -> [String]
    splitString ""  = []
    splitString str = let (l, rest) = splitAt s str in l : splitString rest

Осталось реализовать функцию для передвижения

move :: Char -> GameField -> GameField
move 'W' (GameField s (yH, xH)) = GameField s ((yH - 1) `max` 1, xH)
move 'A' (GameField s (yH, xH)) = GameField s (yH, (xH - 1) `max` 1)
move 'S' (GameField s (yH, xH)) = GameField s ((yH + 1) `min` s, xH)
move 'D' (GameField s (yH, xH)) = GameField s (yH, (xH + 1) `min` s)
move _   gf                     = gf

Записи типа

(yH - 1) max 1 
(yH + 1) min s

будут контролировать, чтобы герой не вышел за пределы поля

Игровое поле, как изменяемое состояние

Монады в Haskell это вычисления с побочным эффектом. Побочный эффект монады State - изменяемое состояние:

State s a, где s - какое-либо состояние, 
               a - значение, получаемое каким то образом из состояния

Определим функцию

heroMove :: Char -> State GameField ()
heroMove ch = modify (move ch)

она принимает символ, а возвращает монаду State, состоянием которой будет GameField (игровое поле), а возвращаемое значение (). Реализация проста: функция modify принимает функцию (s -> s), (GameField -> GameField) в нашем случае.

И наконец, определим функцию

pathMove :: String -> State GameField ()
pathMove = mapM_ heroMove

Эта функция будет обрабатывать строку, которая по сути будет путем, по которому пройдет наш герой. Реализована эта функция через

mapM_ :: (Foldable t, Monad m) => (a -> m b) -> t a -> m ()
-- а если без полиморфизма
mapM_ :: (Char -> State GameField ()) -> String -> State GameField ()

Все готово, чтобы написать простую интерактивную программу. Пользователь введет путь и получит результат. Полный листинг файла GameFieldState.hs:

module GameFieldState where

import           Control.Monad.State
import           GameField

heroMove :: Char -> State GameField ()
heroMove ch = modify (move ch)

pathMove :: String -> State GameField ()
pathMove = mapM_ heroMove

main :: IO ()
main = do
  let gf = GameField 3 (2, 2) -- создание игрового поля
  print gf                    -- вывод первоначального игрового поля
  path <- getLine             -- получение пользовательского ввода
  print $ execState (pathMove path) gf -- изменение и вывод игрового поля

Попробуем в ghci:

:l GameFieldState
main
OOO
OXO
OOO

WA
XOO
OOO
OOO

Введя строку WA, мы "загнали" героя в верхний левый угол.

StateT - еще больше интерактивности

Но что если хочется вводить не весь путь сразу, а управлять каждым шагом героя и видеть результат. Благодаря трансформерам монад это возможно.

Тип функции передвижения героя будет уже такой:

heroMove :: StateT GameField IO ()

Уже нет символа(Char) только

StateT s m a, s - также состояние
              a - также возвращаемое значение
              m - внутренняя монада

IO будет внутренней монадой. Файл GameFieldStateT.hs выглядит так

module GameFieldStateT where

import           Control.Monad.Trans
import           Control.Monad.Trans.State
import           GameField

heroMove :: StateT GameField IO ()
heroMove = do
  gf <- get           -- получение игрового поля
  lift $ print gf     -- вывод, lift позволяет производить вычисления в IO 
  ch <- lift getChar  -- получить символ от пользователя
  lift $ putStrLn ""  -- новая строка в терминале
  modify $ move ch    -- уже знакомая modify и move ch
  heroMove            -- рекурсивный вызов, нет покоя герою

main :: IO ()
main = evalStateT heroMove (GameField 4 (2, 2)) -- вычисления в StateT

Испытаем в ghci:

:l GameFieldStateT
main
OOOO
OXOO
OOOO
OOOO

d
OOOO
OXOO
OOOO
OOOO

D
OOOO
OOXO
OOOO
OOOO

S
OOOO
OOOO
OOXO
OOOO

Заключение

В свое время, лично меня очень пугала монада State. Тогда мне очень пригодился бы пример ее использования. Ведь когда видишь, как что-то непонятное применяется на практике, становится проще понять это самое непонятное. Надеюсь данная статья кому-то поможет.

Комментарии (1)


  1. ivanrt
    23.10.2022 14:52
    +4

    Можно обойтись одним lift. Для меня как раз lift был самым непонятным. В do-notation теряется логика как состояние передается из стоки в строку. Мне долго приходилось переваривать что в каждой строке незримо передается state из предыдущей, get его копирует, а lift удерживает передавая внутреннюю манаду функции которой она нужна. Красивый язык, но сложный для понимания из-за высоких абстракций.