Навигация и изменение AST, построенных на монаде Free в Haskell

Я пытаюсь структурировать AST, используя свободную монаду, основываясь на некоторой полезной литературе, которую я прочитал в Интернете.

У меня есть несколько вопросов о работе с такими AST на практике, которые я свел к следующему примеру.

Предположим, мой язык допускает следующие команды:

{-# LANGUAGE DeriveFunctor #-}

data Command next
  = DisplayChar Char next
  | DisplayString String next
  | Repeat Int (Free Command ()) next
  | Done
  deriving (Eq, Show, Functor)

и я определяю шаблон Free monad вручную:

displayChar :: Char -> Free Command ()
displayChar ch = liftF (DisplayChar ch ())

displayString :: String -> Free Command ()
displayString str = liftF (DisplayString str ())

repeat :: Int -> Free Command () -> Free Command ()
repeat times block = liftF (Repeat times block ())

done :: Free Command r
done = liftF Done

что позволяет мне указывать программы, подобные следующим:

prog :: Free Command r
prog =
  do displayChar 'A'
     displayString "abc"

     repeat 5 $
       displayChar 'Z'

     displayChar '\n'
     done

Теперь я хотел бы выполнить свою программу, которая кажется достаточно простой.

execute :: Free Command r -> IO ()
execute (Free (DisplayChar ch next)) = putChar ch >> execute next
execute (Free (DisplayString str next)) = putStr str >> execute next
execute (Free (Repeat n block next)) = forM_ [1 .. n] (\_ -> execute block) >> execute next
execute (Free Done) = return ()
execute (Pure r) = return ()

и

λ> execute prog
AabcZZZZZ

Хорошо. Это все хорошо, но теперь я хочу узнать кое-что о своем AST и выполнить в нем преобразования. Думайте об оптимизации в компиляторе.

Вот простой пример: если блок Repeat содержит только DisplayChar команд, то я хотел бы заменить все это соответствующим DisplayString. Другими словами, я хотел бы преобразовать repeat 2 (displayChar 'A' >> displayChar 'B') в displayString "ABAB".

Вот моя попытка:

optimize c@(Free (Repeat n block next)) =
  if all isJust charsToDisplay then
    let chars = catMaybes charsToDisplay
    in
      displayString (concat $ replicate n chars) >> optimize next
  else
    c >> optimize next
  where
    charsToDisplay = project getDisplayChar block
optimize (Free (DisplayChar ch next)) = displayChar ch >> optimize next
optimize (Free (DisplayString str next)) = displayString str >> optimize next
optimize (Free Done) = done
optimize c@(Pure r) = c

getDisplayChar (Free (DisplayChar ch _)) = Just ch
getDisplayChar _ = Nothing

project :: (Free Command a -> Maybe u) -> Free Command a -> [Maybe u]
project f = maybes
  where
    maybes (Pure a) = []
    maybes c@(Free cmd) =
      let build next = f c : maybes next
      in
        case cmd of
          DisplayChar _ next -> build next
          DisplayString _ next -> build next
          Repeat _ _ next -> build next
          Done -> []

Наблюдение за AST в GHCI показывает, что это работает правильно, и действительно

λ> optimize $ repeat 3 (displayChar 'A' >> displayChar 'B')
Free (DisplayString "ABABAB" (Pure ()))


λ> execute . optimize $ prog
AabcZZZZZ
λ> execute prog
AabcZZZZZ 

Но я недоволен. На мой взгляд, этот код повторяется. Я должен определить, как пройти через мой AST каждый раз, когда я хочу его изучить, или определить функции, такие как мой project, которые дают мне представление о нем. Я должен сделать то же самое, когда я хочу изменить дерево.

Итак, мой вопрос: это мой единственный вариант? Могу ли я сопоставлять шаблоны в своем AST, не имея дело с тоннами вложенности? Могу ли я пройти по дереву последовательным и общим способом (может быть, Zippers, Traversable или что-то еще)? Какие подходы обычно используются здесь?

Весь файл ниже:

{-# LANGUAGE DeriveFunctor #-}

module Main where

import Prelude hiding (repeat)

import Control.Monad.Free

import Control.Monad (forM_)
import Data.Maybe (catMaybes, isJust)

main :: IO ()
main = execute prog

prog :: Free Command r
prog =
  do displayChar 'A'
     displayString "abc"

     repeat 5 $
       displayChar 'Z'

     displayChar '\n'
     done

optimize c@(Free (Repeat n block next)) =
  if all isJust charsToDisplay then
    let chars = catMaybes charsToDisplay
    in
      displayString (concat $ replicate n chars) >> optimize next
  else
    c >> optimize next
  where
    charsToDisplay = project getDisplayChar block
optimize (Free (DisplayChar ch next)) = displayChar ch >> optimize next
optimize (Free (DisplayString str next)) = displayString str >> optimize next
optimize (Free Done) = done
optimize c@(Pure r) = c

getDisplayChar (Free (DisplayChar ch _)) = Just ch
getDisplayChar _ = Nothing

project :: (Free Command a -> Maybe u) -> Free Command a -> [Maybe u]
project f = maybes
  where
    maybes (Pure a) = []
    maybes c@(Free cmd) =
      let build next = f c : maybes next
      in
        case cmd of
          DisplayChar _ next -> build next
          DisplayString _ next -> build next
          Repeat _ _ next -> build next
          Done -> []

execute :: Free Command r -> IO ()
execute (Free (DisplayChar ch next)) = putChar ch >> execute next
execute (Free (DisplayString str next)) = putStr str >> execute next
execute (Free (Repeat n block next)) = forM_ [1 .. n] (\_ -> execute block) >> execute next
execute (Free Done) = return ()
execute (Pure r) = return ()

data Command next
  = DisplayChar Char next
  | DisplayString String next
  | Repeat Int (Free Command ()) next
  | Done
  deriving (Eq, Show, Functor)

displayChar :: Char -> Free Command ()
displayChar ch = liftF (DisplayChar ch ())

displayString :: String -> Free Command ()
displayString str = liftF (DisplayString str ())

repeat :: Int -> Free Command () -> Free Command ()
repeat times block = liftF (Repeat times block ())

done :: Free Command r
done = liftF Done

person jhaberku    schedule 11.06.2014    source источник
comment
Посмотрите униплату или униплату внутри объектива. Я могу собрать быстрый пример любого из них, если хотите.   -  person benjumanji    schedule 12.06.2014
comment
Пример в uniplate был бы замечательным! Особенно в контексте моего исходного примера.   -  person jhaberku    schedule 12.06.2014
comment
iterM может быть вашим друг здесь.   -  person fho    schedule 13.06.2014


Ответы (4)


Вот мой взгляд на использование syb (как упоминалось на Reddit):

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DeriveDataTypeable #-}

module Main where

import Prelude hiding (repeat)

import Data.Data

import Control.Monad (forM_)

import Control.Monad.Free
import Control.Monad.Free.TH

import Data.Generics (everywhere, mkT)

data CommandF next = DisplayChar Char next
                   | DisplayString String next
                   | Repeat Int (Free CommandF ()) next
                   | Done
  deriving (Eq, Show, Functor, Data, Typeable)

makeFree ''CommandF

type Command = Free CommandF

execute :: Command () -> IO ()
execute = iterM handle
  where
    handle = \case
        DisplayChar ch next -> putChar ch >> next
        DisplayString str next -> putStr str >> next
        Repeat n block next -> forM_ [1 .. n] (\_ -> execute block) >> next
        Done -> return ()

optimize :: Command () -> Command ()
optimize = optimize' . optimize'
  where
    optimize' = everywhere (mkT inner)

    inner :: Command () -> Command ()
    -- char + char becomes string
    inner (Free (DisplayChar c1 (Free (DisplayChar c2 next)))) = do
        displayString [c1, c2]
        next

    -- char + string becomes string
    inner (Free (DisplayChar c (Free (DisplayString s next)))) = do
        displayString $ c : s
        next

    -- string + string becomes string
    inner (Free (DisplayString s1 (Free (DisplayString s2 next)))) = do
        displayString $ s1 ++ s2
        next

    -- Loop unrolling
    inner f@(Free (Repeat n block next)) | n < 5 = forM_ [1 .. n] (\_ -> block) >> next
                                         | otherwise = f

    inner a = a

prog :: Command ()
prog = do
    displayChar 'a'
    displayChar 'b'
    repeat 1 $ displayChar 'c' >> displayString "def"
    displayChar 'g'
    displayChar 'h'
    repeat 10 $ do
        displayChar 'i'
        displayChar 'j'
        displayString "klm"
    repeat 3 $ displayChar 'n'

main :: IO ()
main = do
    putStrLn "Original program:"
    print prog
    putStrLn "Evaluation of original program:"
    execute prog
    putStrLn "\n"

    let opt = optimize prog
    putStrLn "Optimized program:"
    print opt
    putStrLn "Evaluation of optimized program:"
    execute opt
    putStrLn ""

Выход:

$ cabal exec runhaskell ast.hs
Original program:
Free (DisplayChar 'a' (Free (DisplayChar 'b' (Free (Repeat 1 (Free (DisplayChar 'c' (Free (DisplayString "def" (Pure ()))))) (Free (DisplayChar 'g' (Free (DisplayChar 'h' (Free (Repeat 10 (Free (DisplayChar 'i' (Free (DisplayChar 'j' (Free (DisplayString "klm" (Pure ()))))))) (Free (Repeat 3 (Free (DisplayChar 'n' (Pure ()))) (Pure ()))))))))))))))
Evaluation of original program:
abcdefghijklmijklmijklmijklmijklmijklmijklmijklmijklmijklmnnn

Optimized program:
Free (DisplayString "abcdefgh" (Free (Repeat 10 (Free (DisplayString "ijklm" (Pure ()))) (Free (DisplayString "nnn" (Pure ()))))))
Evaluation of optimized program:
abcdefghijklmijklmijklmijklmijklmijklmijklmijklmijklmijklmnnn

Можно избавиться от *Free*, используя Pattern Synonyms GHC 7.8, но по какой-то причине приведенный выше код работает только с GHC 7.6, экземпляром Data Бесплатно отсутствует. Следует изучить это...

person Nicolas Trangez    schedule 12.06.2014
comment
Отсутствующие экземпляры часто вызваны несоответствием версий пакетов. - person Cubic; 12.06.2014
comment
Это отличный пример использования SYB по отношению к моему примеру. Я знаю, что это не так широко, как другие ответы, но, на мой взгляд, это наиболее доступно. Спасибо! - person jhaberku; 12.06.2014

Если ваша проблема связана с шаблоном, вы не сможете обойти ее, если будете использовать Free! Вы всегда будете застрять с дополнительным конструктором на каждом уровне.

Но с другой стороны, если вы используете Free, у вас есть очень простой способ обобщить рекурсию по вашей структуре данных. Можно написать все это с нуля, но я использовал пакет recursion-schemes:

import Data.Functor.Foldable 

data (:+:) f g a = L (f a) | R (g a) deriving (Functor, Eq, Ord, Show)

type instance Base (Free f a) = f :+: Const a 
instance (Functor f) => Foldable (Free f a) where 
  project (Free f) = L f 
  project (Pure a) = R (Const a)
instance Functor f => Unfoldable (Free f a) where 
  embed (L f) = Free f
  embed (R (Const a)) = Pure a 
instance Functor f => Unfoldable (Free f a) where 
  embed (L f) = Free f
  embed (R (Const a)) = Pure a 

Если вы не знакомы с этим (прочитайте документацию), но в основном все, что вам нужно знать, это то, что project берет некоторые данные, такие как Free f a, и "расщепляет" их на один уровень, создавая что-то вроде (f :+: Const a) (Free f a). Теперь вы предоставили обычным функциям, таким как fmap, Data.Foldable.foldMap и т. д., доступ к структуре ваших данных, поскольку аргументом функтора является поддерево.

Выполнение очень простое, хотя и не намного более лаконичное:

execute :: Free Command r -> IO ()
execute = cata go where 
  go (L (DisplayChar ch next)) = putChar ch >> next
  go (L (DisplayString str next)) = putStr str >> next
  go (L (Repeat n block next)) = forM_ [1 .. n] (const $ execute block) >> next
  go (L Done) = return ()
  go (R _) = return ()

Однако упрощение становится намного проще. Мы можем определить упрощение для всех типов данных, которые имеют экземпляры Foldable и Unfoldable:

reduce :: (Foldable t, Functor (Base t), Unfoldable t) => (t -> Maybe t) -> t -> t 
reduce rule x = let y = embed $ fmap (reduce rule) $ project x in 
  case rule y of 
    Nothing -> y
    Just y' -> y' 

Правило упрощения должно упростить только один уровень AST (а именно, самый верхний уровень). Затем, если упрощение может применяться к подструктуре, оно будет выполняться и там. Обратите внимание, что приведенный выше reduce работает снизу вверх; у вас также может быть сокращение сверху вниз:

reduceTD :: (Foldable t, Functor (Base t), Unfoldable t) => (t -> Maybe t) -> t -> t 
reduceTD rule x = embed $ fmap (reduceTD rule) $ project y
  where y = case rule x of 
              Nothing -> x 
              Just x' -> x' 

Правило упрощения вашего примера можно записать очень просто:

getChrs :: (Command :+: Const ()) (Maybe String) -> Maybe String 
getChrs (L (DisplayChar c n)) = liftA (c:) n
getChrs (L Done) = Just []
getChrs (R _) = Just []
getChrs _ = Nothing 

optimize (Free (Repeat n dc next)) = do 
  chrs <- cata getChrs dc
  return $ Free $ DisplayString (concat $ map (replicate n) chrs) next
optimize _ = Nothing

Из-за того, как вы определили свой тип данных, у вас нет доступа ко второму аргументу Repeat, поэтому для таких вещей, как repeat' 5 (repeat' 3 (displayChar 'Z')) >> done, внутренний repeat не может быть упрощен. Если это ситуация, с которой вы ожидаете иметь дело, вы либо меняете свой тип данных и принимаете гораздо больше шаблонов, либо пишете исключение:

reduceCmd rule (Free (Repeat n c r)) = 
let x = Free (Repeat n (reduceCmd rule c) (reduceCmd rule r)) in 
    case rule x of
      Nothing -> x
      Just x' -> x' 
reduceCmd rule x = embed $ fmap (reduceCmd rule) $ project x 

Использование recursion-schemes или подобного, вероятно, сделает ваш код более легко расширяемым. Но это ни в коем случае не нужно:

execute = iterM go where 
  go (DisplayChar ch next) = putChar ch >> next
  go (DisplayString str next) = putStr str >> next
  go (Repeat n block next) = forM_ [1 .. n] (const $ execute block) >> next
  go Done = return ()

getChrs не может получить доступ к Pure, а ваши программы будут иметь вид Free Command (), так что прежде чем применять его, вы должны заменить () на Maybe String.

getChrs :: Command (Maybe String) -> Maybe String
getChrs (DisplayChar c n) = liftA (c:) n
getChrs (DisplayString s n) = liftA (s++) n 
getChrs Done = Just []
getChrs _ = Nothing 

optimize :: Free Command a -> Maybe (Free Command a)
optimize (Free (Repeat n dc next)) = do 
  chrs <- iter getChrs $ fmap (const $ Just []) dc
  return $ Free $ DisplayString (concat $ map (replicate n) chrs) next
optimize _ = Nothing

Обратите внимание, что reduce почти такой же, как и раньше, за исключением двух вещей: project и embed заменены сопоставлением с образцом на Free и Free соответственно; а для Pure нужен отдельный чехол. Это должно сказать вам, что Foldable и Unfoldable обобщают вещи, которые "похожи" на Free.

reduce
  :: Functor f =>
     (Free f a -> Maybe (Free f a)) -> Free f a -> Free f a

reduce rule (Free x) = let y = Free $ fmap (reduce rule) $ x in 
  case rule y of 
    Nothing -> y
    Just y' -> y' 
reduce rule a@(Pure _) = case rule a of 
                           Nothing -> a
                           Just  b -> b 

Аналогично модифицируются все остальные функции.

person user2407038    schedule 11.06.2014
comment
Это фантастический пример, и я ценю, что вы написали его. С одной стороны, это отвечает на мой вопрос в широком смысле, что я и искал. С другой стороны, это трудно понять, потому что я нашел документацию для recursion-schemes почти непроницаемой. Я должен буду оглянуться на это несколько раз. Если бы я только мог выбрать более одного ответа... - person jhaberku; 12.06.2014
comment
Если хотите, есть несколько хорошие руководства по концепции в целом. Но его можно было бы переписать так, чтобы он работал без него и всегда работал с Free. - person user2407038; 13.06.2014

Пожалуйста, не думайте о молниях, траверсах, SYB или линзах, пока вы не воспользуетесь преимуществами стандартных функций Free. Ваши execute, optimize и project — это просто стандартные бесплатные схемы рекурсии монад, которые уже доступны в пакете:

optimize :: Free Command a -> Free Command a
optimize = iterM $ \f -> case f of
  c@(Repeat n block next) ->
    let charsToDisplay = project getDisplayChar block in
    if all isJust charsToDisplay then
      let chars = catMaybes charsToDisplay in
      displayString (concat $ replicate n chars) >> next
    else
      liftF c >> next
  DisplayChar ch next -> displayChar ch >> next
  DisplayString str next -> displayString str >> next
  Done -> done

getDisplayChar :: Command t -> Maybe Char
getDisplayChar (DisplayChar ch _) = Just ch
getDisplayChar _ = Nothing

project' :: (Command [u] -> u) -> Free Command [u] -> [u]
project' f = iter $ \c -> f c : case c of
  DisplayChar _ next -> next
  DisplayString _ next -> next
  Repeat _ _ next -> next
  Done -> []

project :: (Command [u] -> u) -> Free Command a -> [u]
project f = project' f . fmap (const [])

execute :: Free Command () -> IO ()
execute = iterM $ \f -> case f of
  DisplayChar ch next -> putChar ch >> next
  DisplayString str next -> putStr str >> next
  Repeat n block next -> forM_ [1 .. n] (\_ -> execute block) >> next
  Done -> return ()

Поскольку каждый из ваших компонентов имеет не более одного продолжения, вы, вероятно, сможете найти умный способ избавиться и от всех этих >> next.

person Tom Ellis    schedule 12.06.2014
comment
Спасибо за демонстрацию некоторых функций пакета free! Иногда видение конкретных примеров имеет решающее значение, когда все, что видно, — это сигнатуры типов. Действительно, код, который я разместил, определенно улучшен здесь. - person jhaberku; 12.06.2014

Вы, конечно, можете сделать это проще. Еще предстоит проделать некоторую работу, потому что он не будет выполнять полную оптимизацию при первом проходе, но после двух проходов он полностью оптимизирует вашу программу-пример. Я оставлю это упражнение на ваше усмотрение, но в противном случае вы можете сделать это очень просто, сопоставив шаблоны с оптимизациями, которые вы хотите сделать. Это все еще немного повторяется, но устраняет многие сложности, которые у вас были:

optimize (Free (Repeat n block next)) = optimize (replicateM n block >> next)
optimize (Free (DisplayChar ch1 (Free (DisplayChar ch2 next)))) = optimize (displayString [ch1, ch2] >> next)
optimize (Free (DisplayChar ch (Free (DisplayString str next)))) = optimize (displayString (ch:str) >> next)
optimize (Free (DisplayString s1 (Free (DisplayString s2 next)))) = optimize (displayString (s1 ++ s2) >> next)
optimize (Free (DisplayString s (Free (DisplayChar ch next)))) = optimize (displayString (s ++ [ch]) >> next)
optimize (Free (DisplayChar   ch next)) = displayChar ch >> optimize next
optimize (Free (DisplayString str next)) = displayString str >> optimize next
optimize (Free Done) = done
optimize c@(Pure r) = c

Все, что я сделал, это сопоставление с образцом для repeat n (displayChar c), displayChar c1 >> displayChar c2, displayChar c >> displayString s, displayString s >> displayChar c и displayString s1 >> displayString s2. Есть и другие оптимизации, которые можно выполнить, но это было довольно просто и не зависело от сканирования чего-либо еще, просто итеративно перешагивая рекурсивную оптимизацию AST.

person bheklilr    schedule 11.06.2014
comment
Я ценю, что вы решили исходный пример более эффективно, но целью этого вопроса является не конкретный алгоритм. Вместо этого я пытаюсь узнать, как эти типы операций (запрос и изменение AST) выполняются с помощью монады Free на практике и в общем. - person jhaberku; 12.06.2014