How can I merge effect interpreters when using a library like freer-simple?

Viewed 203

I am playing around with freer-simple and trying to work out how to combine effects.

I have an algebra to represent a simple file system and user invoked failure as follows:

data FileSystem r where
  ReadFile :: Path a File -> FileSystem String
  WriteFile :: Path a File -> String -> FileSystem ()

readFile :: Member FileSystem effs => Path a File -> Eff effs String
readFile = send . ReadFile

writeFile :: Member FileSystem effs => Path a File -> String -> Eff effs ()
writeFile pth = send . WriteFile pth

data AppError r where
  Ensure :: Bool -> String -> AppError ()
  Fail :: String -> AppError ()

ensure :: Member AppError effs => Bool -> String -> Eff effs ()
ensure condition message = send $ Ensure condition message

fail :: Member AppError effs =>  String -> Eff effs ()
fail = send . Fail

And an "application" in a function called interactor as follows:

data TestItem = Item {
  pre :: String,
  post :: String,
  path :: Path Abs File
}

data RunConfig = RunConfig {
  environment :: String,
  depth :: Integer,
  path :: Path Abs File
}

type FileSys r = (Member FileSystem r)
type AppFailure r = (Member AppError r)

interactor :: TestItem -> RunConfig -> (AppFailure r, FileSys r) => Eff r ApState
interactor item runConfig = do
                              let fullFilePath = path (runConfig :: RunConfig)
                              writeFile fullFilePath $ pre item  <> post item
                              fail "random error ~ its a glitch"
                              txt <- readFile [absfile|C:\Vids\SystemDesign\Wrong.txt|]
                              pure $ ApState fullFilePath txt

At this stage I am only interested in dumb "documentation" interpreters that log the steps, I don't even care what failing would do in terms of control flow:

fileSystemDocInterpreter :: FileSystem ~> Eff '[Writer [String], effs]
fileSystemDocInterpreter = 
     let
        mockContents = "Mock File Contents"
      in
        \case
          ReadFile path -> tell ["readFile: " <> show path] $> mockContents
          WriteFile path str -> tell ["write file: " <>
                                        show path <>
                                        "\nContents:\n" <>
                                        str]

errorDocInterpreter :: AppError ~> Eff '[Writer [String]]
errorDocInterpreter = \case
                    Ensure condition errMsg -> tell [condition ? "Ensure Check Passed" $
                      "Ensure Check Failed ~ " <>  errMsg]
                    Fail errMsg -> tell ["Failure ~ " <>  errMsg]

The combined interpreter is as follows:

type FileSys r = (Member FileSystem r)
type AppFailure r = (Member AppError r)

executeDocumented :: forall a. Eff '[FileSystem, AppError] a -> ((a, [String]), [String])
executeDocumented app = run $ runWriter 
                            $ reinterpret errorDocInterpreter 
                            $ runWriter 
                            $ reinterpret fileSystemDocInterpreter app

When I run this with sample configs I get something like the following:

((ApState {
            filePath = "C:\\Vids\\SystemDesign\\VidList.txt", 
            fileText = "Mock File Contents"
          },
          ["write file: \"C:\\\\Vids\\\\SystemDesign\\\\VidList.txt\
                        "\nContents: I do a test the test runs",
          "readFile: \"C:\\\\Vids\\\\SystemDesign\\\\Wrong.txt\""]
         ),
         ["Failure ~ random error ~ its a glitch"]
 )

I have a couple of questions about the interpreters above:

  1. The order this to compile I had to make the types as follows:

    fileSystemDocInterpreter :: FileSystem ~> Eff '[Writer [String], effs] 
    
    errorDocInterpreter :: AppError ~> Eff '[Writer [String]]
    

    and call errorDocInterpreter after fileSystemDocInterpreter because the fileSystemDocInterpreter has trailing effs and errorDocInterpreter doesn't.

    Is there a way to change the type signatures or invoke these so it wouldn't matter which was needed first by the parent interpreter?

  2. Both fileSystemDocInterpreter and errorDocInterpreter use the Writer [String] effect. Is there a way to combine these so runWriter is only called once so the failure and file system messages appear in one log?

2 Answers

The documentation for the Eff type states that

Normally, a concrete list of effects is not used to parameterize Eff. Instead, the Member or Members constraints are used to express constraints on the list of effects without coupling a computation to a concrete list of effects.

Therefore, in order to maximize flexibility we could change the signatures of fileSystemDocInterpreter and errorDocInterpreter to:

fileSystemDocInterpreter :: Member (Writer [String]) effs => FileSystem ~> Eff effs

errorDocInterpreter :: Member (Writer [String]) effs => AppError ~> Eff effs

We don't really care where the Writer [String] is on the type-level list, of if there are any more effects on the list. We just need Writer [String] to be there. This change takes care of (1).

As for (2), we could define executeDocumented as follows:

executeDocumented :: forall a. Eff '[FileSystem, AppError, Writer [String]] a 
                  -> (a, [String])
executeDocumented app = run $ runWriter
                            $ interpret errorDocInterpreter
                            $ interpret fileSystemDocInterpreter
                            $ app

Here we are making use in the interpreter of the flexibility we gained when defining the computations. We put a Writer [String] at the end of the list, and the two interprets send FileSystem and AppErrors effects to the writer. No need to have separate Writer [String] layers! (That said, if at other occasion we have two effects of the same type at the front of the list, we can use subsume to remove the duplication.)

I've tried to restore a source code to watch how it works

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MonoLocalBinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE Rank2Types #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Main where

import Data.Monoid
import Data.Functor
import Data.List
import Data.String
import Control.Natural (type (~>))
import Control.Monad.Freer.Writer (Writer, tell,runWriter)
import Control.Monad.Freer
  (
    Eff
  , LastMember
  , Member
  , interpret
  , interpretM
  , send
  , run
  , runM
  )

data FileSystem r where
  ReadFile :: FilePath  -> FileSystem String
  WriteFile :: FilePath -> String -> FileSystem ()

readFile :: Member FileSystem effs => FilePath -> Eff effs String
readFile = send . ReadFile

writeFile :: Member FileSystem effs => FilePath -> String -> Eff effs ()
writeFile pth = send . WriteFile pth

data AppError r where
  Ensure :: Bool -> String -> AppError ()
  Fail :: String -> AppError ()

ensure :: Member AppError effs => Bool -> String -> Eff effs ()
ensure condition message = send $ Ensure condition message

fail :: Member AppError effs =>  String -> Eff effs ()
fail = send . Fail

data ApState = ApState {filePath::String,fileText::String} deriving Show

data TestItem = Item {
  pre :: String,
  post :: String,
  pathTI :: FilePath
}

data RunConfig = RunConfig {
  environment :: String,
  depth :: Integer,
  pathRC :: FilePath
}

type FileSys r = (Member FileSystem r)
type AppFailure r = (Member AppError r)

interactor :: TestItem -> RunConfig -> (AppFailure r, FileSys r) => Eff r ApState
interactor item runConfig = do
                              let fullFilePath = pathRC (runConfig :: RunConfig)
                              Main.writeFile fullFilePath $ pre item  <> post item
                              Main.fail "random error ~ its a glitch"
                              txt <- Main.readFile "C:\\Vids\\SystemDesign\\Wrong.txt"
                              pure $ ApState fullFilePath txt

fileSystemDocInterpreter :: Member (Writer [String]) effs => FileSystem ~> Eff effs
fileSystemDocInterpreter =
     let
        mockContents::String = "Mock File Contents"
      in
        \case
          ReadFile path -> tell ["readFile: " <> show path] $> mockContents
          WriteFile path str -> tell ["write file: " <>
                                        show path <>
                                        "\nContents:\n" <>
                                        str]

errorDocInterpreter :: Member (Writer [String]) effs => AppError ~> Eff effs
errorDocInterpreter = \case
       Ensure condition errMsg -> tell [if condition then "Ensure Check Passed" else ("Ensure Check Failed ~ " <>  errMsg) ]
       Fail errMsg -> tell ["Failure ~ " <>  errMsg]

executeDocumented :: forall a. Eff '[FileSystem, AppError, Writer [String]] a
                  -> (a, [String])
executeDocumented app = run $ runWriter
                            $ interpret errorDocInterpreter
                            $ interpret fileSystemDocInterpreter
                            $ app

main :: IO ()
main = do
   let ti = Item {pre="", post ="", pathTI =""}
   let rc = RunConfig {environment ="", depth =1, pathRC ="C:\\Vids\\SystemDesign\\VidList.txt"}
   let (apst,messages) = executeDocumented $ interactor ti rc
   putStrLn $ show apst
   mapM_ (\x->putStrLn x) messages
   putStrLn "_"
Related