Создание результата кусочно из вычислений с учетом состояния, с хорошей эргономикой - PullRequest
3 голосов
/ 17 января 2020

Я хотел бы написать функцию

step :: State S O

, где O - это тип записи:

data O = MkO{ out1 :: Int, out2 :: Maybe Int, out3 :: Maybe Bool }

Суть в том, что я хотел бы собрать свой O вывод кусочно. Под этим я подразумеваю, что в разных местах по определению step я тут же узнаю, что, например, out2 должно быть Just 3, но я не знаю, как не запутано, что out1 и out3 должно быть. Кроме того, существует естественное значение по умолчанию для out1, которое можно вычислить из конечного состояния; но все еще должна быть возможность переопределить его в step.

И, что самое важное, я хочу это "развернуть", чтобы пользователи могли предоставлять свои собственные типы S и O и я даю им остальное.

Мой текущий подход - обернуть все в WriterT (HKD O Last), используя Higgledy , автоматизированный способ создания типа HKD O Last, который isomorphi c to

data OLast = MkOLast{ out1' :: Last Int, out2' :: Last (Maybe Int), out3' :: Last (Maybe String) }

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

step = do
   MkOLast{..} <- execWriterT step'
   s <- get
   return O
       { out1 = fromMaybe (defaultOut1 s) $ getLast out1'
       , out2 =  getLast out2'
       , out3 = fromMaybe False $ getLast out3'
       }

step' = do
    ...
    tell mempty{ out2' = pure $ Just 42 }
    ...
    tell mempty{ out1' = pure 3 }

Это код, с которым я мог бы жить.

Проблема в том, что я могу сделать это только морально . В практике , то, что я должен написать, является довольно запутанным кодом, потому что HKD O Last Хиггли выставляет поля записи как линзы, поэтому реальный код в конечном итоге выглядит примерно так:

step = do
   oLast <- execWriterT step'
   s <- get
   let def = defaultOut s
   return $ runIdentity . construct $ bzipWith (\i -> maybe i Identity . getLast) (deconstruct def) oLast 

step' = do
    ...
    tell $ set (field @"out2") (pure $ Just 42) mempty
    ... 
    tell $ set (field @"out3") (pure 3) mempty

Первая бородавка в step, которую мы можем спрятать за функцией:

update :: (Generic a, Construct Identity a, FunctorB (HKD a), ProductBC (HKD a)) => a -> HKD a Last -> a
update initial edits = runIdentity . construct $ bzipWith (\i -> maybe i Identity . getLast) (deconstruct initial) edits

, чтобы мы могли «развернуть» это как

runStep
  :: (Generic o, Construct Identity o, FunctorB (HKD o), ProductBC (HKD o))
  => (s -> o) -> WriterT (HKD o Last) (State s) () -> State s o
runStep mkDef step = do
    let updates = execWriterT step s
    def <- gets mkDef
    return $ update def updates

Но что меня беспокоит, так это места, где частичное выходы записываются. Пока что лучшее, что я смог придумать, - это использовать OverloadedLabels для предоставления #out2 в качестве возможного синтаксиса:

instance (HasField' field (HKD a f) (f b), Applicative f) => IsLabel field (b -> Endo (HKD a f)) where
    fromLabel x = Endo $ field @field .~ pure x

output :: (Monoid (HKD o Last)) => Endo (HKD o Last) -> WriterT (HKD o Last) (State s) ()
output f = tell $ appEndo f mempty

, что позволяет конечным пользователям писать step' как

step' = do
    ...
    output $ #out2 (Just 42)
    ...
    output $ #out3 3 

но это все еще немного громоздко; кроме того, он использует довольно много тяжелой техники за кулисами. Особенно с учетом того, что мой вариант использования таков, что все внутренние компоненты библиотеки необходимо объяснять поэтапно.

Итак, я ищу улучшения в следующих областях:

  • Более простая внутренняя реализация
  • Более хороший API для конечных пользователей
  • Я был бы счастлив с совершенно другим подходом также из первых принципов, если пользователю не требуется определять свои собственные OLast рядом с O ...

Ответы [ 2 ]

1 голос
/ 05 февраля 2020

Следующее не очень удовлетворительное решение, потому что оно все еще сложно и ошибки типа ужасны c, но оно пытается достичь двух вещей:

  • Любая попытка "завершить" «построение записи без указания всех обязательных полей приводит к ошибке типа.

  • » существует естественное значение по умолчанию для out1, которое может быть вычислено из конечного состояния; но все еще должна быть возможность переопределить его "

Решение устраняет монаду State. Вместо этого есть расширяемая запись, к которой постепенно добавляются новые поля - следовательно, изменяется ее тип - до тех пор, пока она не станет «полной».

Мы используем красно-черная запись , sop-core (эти для HKD-подобной функциональности) и трансформаторы (для Reader монад) пакетов.

Некоторые необходимые импортные данные:

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# OPTIONS_GHC -Wno-partial-type-signatures #-}
import           Data.RBR (Record,unit,FromRecord(fromRecord),ToRecord,RecordCode,
                           Productlike,fromNP,toNP,ProductlikeSubset,projectSubset,
                           FromList,
                           Insertable,Insert,insert) -- from "red-black-record"
import           Data.SOP (I(I),unI,NP,All,Top) -- from "sop-core"
import           Data.SOP.NP (sequence_NP)
import           Data.Function (fix)
import           Control.Monad.Trans.Reader (Reader,runReader,reader)
import qualified GHC.Generics

Datatype-generi c механизм:

specify :: forall k v t r. Insertable k v t 
        => v -> Record (Reader r) t -> Record (Reader r) (Insert k v t)
specify v = insert @k @v @t (reader (const v))


close :: forall r subset subsetflat whole . _ => Record (Reader r) whole -> r
close = fixRecord @r @subsetflat . projectSubset @subset @whole @subsetflat
  where
    fixRecord 
        :: forall r flat. (FromRecord r, Productlike '[] (RecordCode r) flat, All Top flat)
        => Record (Reader r) (RecordCode r)
        -> r
    fixRecord = unI . fixHelper I
    fixHelper 
        :: forall r flat f g. _
        => (NP f flat -> g (NP (Reader r) flat))
        -> Record f (RecordCode r)
        -> g r 
    fixHelper adapt r = do
        let moveFunctionOutside np = runReader . sequence_NP $ np
            record2record np = fromRecord . fromNP <$> moveFunctionOutside np
        fix . record2record <$> adapt (toNP r)

specify добавляет поле к расширяемой HKD-подобной записи, где каждое поле фактически является функцией от завершенной записи, к типу поля в завершенная запись. Он вставляет поле как постоянную функцию. Он также может переопределять существующие поля по умолчанию.

close принимает расширяемую запись, созданную с помощью specify, и "связывает узел", возвращая завершенную не-HKD запись.

Вот код, который должно быть написано для каждой конкретной записи:

data O = MkO { out1 :: Int, out2 :: Maybe Int, out3 :: Maybe Bool } 
         deriving (GHC.Generics.Generic, Show)
instance FromRecord O
instance ToRecord O

type ODefaults = FromList '[ '("out1",Int) ]

odefaults :: Record (Reader O) ODefaults
odefaults =
      insert @"out1" (reader $ \r -> case out2 r of
                                       Just i -> succ i
                                       Nothing -> 0)
    $ unit

В odefaults мы указываем переопределяемые значения по умолчанию для некоторых полей, которые рассчитываются путем проверки «завершенной» записи (это работает, потому что мы позже t ie узел с close.)

Запускаем все на работу:

example1 :: O
example1 = 
      close
    . specify @"out3" (Just False)
    . specify @"out2" (Just 0)
    $ odefaults

example2override :: O
example2override = 
      close
    . specify @"out1" (12 :: Int)
    . specify @"out3" (Just False)
    . specify @"out2" (Just 0)
    $ odefaults

main :: IO ()
main = 
    do print $ example1
       print $ example2override
-- result:
-- MkO {out1 = 1, out2 = Just 0, out3 = Just False}
-- MkO {out1 = 12, out2 = Just 0, out3 = Just False}
0 голосов
/ 06 апреля 2020

Вот что я сейчас использую для этого: в основном та же методика на основе Барби из моего первоначального вопроса, но с использованием barbies-th и lens для создания полевых линз с правильными именами.

Я собираюсь проиллюстрируем это на примере. Предположим, я хочу получить этот результат:

data CPUOut = CPUOut
    { inputNeeded :: Bool
    , ...
    }
  1. Создать Barb ie для CPUOut, используя barbies-th, добавить префикс _ к именам полей и использовать lens makeLenses Макрос TH для генерации полевых методов доступа:
declareBareB [d|
data CPUOut = CPUOut
   { _inputNeeded :: Bool
   , ...
   } |]
makeLenses ''CPUState
Записать update st, он работает с частичными значениями, которые заключены в оболочку Barbie newtype:
type Raw b = b Bare Identity
type Partial b = Barbie (b Covered) Last

update 
    :: (BareB b, ApplicativeB (b Covered)) 
    => Raw b -> Partial b -> Raw b
update initials edits = 
    bstrip $ bzipWith update1 (bcover initials) (getBarbie edits)
  where
    update1 :: Identity a -> Last a -> Identity a
    update1 initial edit = maybe initial Identity (getLast edit)
Роль оболочки Barbie заключается в том, что Barbie b f имеет экземпляр Monoid, если только все поля b f сами являются моноидами. Это именно тот случай для Partial CPUOut, так что именно это мы собираемся собрать в нашем WriterT:
type CPU = WriterT (Partial CPUOut) (State CPUState)
Напишите обобщенный c комбинатор назначения вывода. Это то, что делает его более подходящим, чем в первоначальном вопросе, потому что Setter' правильно названы линзами доступа к полю, а не перегруженными метками:
(.:=) 
    :: (Applicative f, MonadWriter (Barbie b f) m) 
    => Setter' (b f) (f a) -> a -> m ()
fd .:= x = scribe (iso getBarbie Barbie . fd) (pure x)
Пример использования:
startInput :: CPU ()
startInput = do
    inputNeeded .:= True
    phase .= WaitInput
...