haskellfree-monad

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


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?


Solution

  • 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.)