Running Effects
Functorial lifts and costates give us all the tools to describe effects in the category of lenses. Programming with pure functions is extremely helpful in writing correct software as it dramatically reduces the combinatorial space, which in turn reduces the opportunity for bugs to appear. But at some point, a program needs to interact with the outside world, and this interaction is modelled with an effect.
Functorial lifts explain how to introduce an effect in the fibre of a container which we can then use as a costate to introduce an effectful function.
-- an effectful program can be seen as a costate lens with an IO lift
-- since it's isomorphic to a function (m : program.message) -> IO (program.response m)
exampleEffectfulLens : Costate (IO • program)But sometimes we want to place an effect in the forward part of a lens, for example a program that reads a file from the filesystem and passes along to the rest of the program. We can imagine a lens interacting with the fileSystem and the terminal that reads files in the forward part, and prints files in the backward.
covering
readPrint : String :- IO () =%> IO (Either FileError String) :- String
readPrint = readFile <! const (putStrLn {io = IO})And if this lens was to be turned into a costate it would have the completely
appropriate type String -> IO (), but turning it into such costate is barrier
to using this approach. To do so would require coming up with a lens
Costate (IO (Either FileError String) :- String), which implies defining a
function IO (Either FileError String) -> String. But because the argument
is wrapped in IO, we cannot extract it from the IO monad, the only
thing we could do is also wrap the output in IO and use a lens of type
IO (Either FileError String) :- IO String. But this is very unsatisfying
because now the backward part of readPrint is inaccurately defined since
it’s requires a function IO String -> IO () but in reality we do not require
the input to be in IO.
This kind of changes is to be expected from effectful programs, once inserted they tend to pollute the rest of the program, sometimes needlessly.
In this section we are going to see how to introduce effect such that we can
carefully control where and when they occur and ensure their scope is only
as big as necessary. We are going to drive the development of effectful
lenses by implementing a simple but highly effectful program, tee.
The tee program
tee is a command line utility taking some input on standard-input
and a destination file and performing two actions at once: printing and
writing to the file.
The name tee refers to the pronounciation of the letter T which represents
the flow of information that the utility performs.
STDIN >───────•────────>STDOUT
│
│
│
│
V
Filesystem
This programm is full of side-effects, reading from STDIN, reading command-line arguments, writing to the filesystem, there is almost nothing pure about this program, so how do we write it using lenses? What even is its API?
Because this program takes no input in argument, its messages are trivial, because
its output is only a side-effect, its output is also trivial. The API of this
program is therefore Unit :- Unit the tensor unit, written I.
Running this program however results in a definition of type IO () or () -> IO ()
which is the continuation represented by the container IO • I
Effects in this setting are represented by delegating the effect to an outside
process via the sequence product. For example, the effectful printing interface
takes a string and returns unit : String -> IO (). As a container we can see
this as the term IO • (String :- Unit). We can separate the role of IO from
the interface of the function and write
PrintLn : Container
PrintLn = String :- UnitBecause our program Tee reads from the command line, and that’s also an effectful
operation, we reproduce the same strategy by identifying what “reading the command-line
arguments” means as an API. Because it’s implemented with a function
getArgs : IO (List String) we see this as a container Unit :- List String and wrap
the fibre in IO for its handler.
GetArgs : Container
GetArgs = Unit :- List StringThe API for writing to a file takes a string in argument and does not return anything, except
errors, ignoring effects, the API is therefore String :- Unit
record WriteFileOp where
constructor MkWrite
filename : String
content : String
WriteFile : Container
WriteFile = (WriteFileOp :- Unit)Finally, we need to read STDIN, this purely effectful program takes no argument and produces
a string delimited by an eof token.
STDIN : Container
STDIN = Unit :- StringWe now have all the pieces to write out tee program using containers by describing the effects
it performs. First we need to read the command-line arguments to find out to what file we are going to
write the output. We also need to read the standard input to collect the data to write. Once both of those
actions are done, we need to write the content collected to a file with the name given in argument and also
to standard-output.
Tee : I =%> (GetArgs ⊗ STDIN) ▷ (PrintLn ⊗ All.List WriteFile)Notice the careful mix of tensor and sequencing, indicating some operations can happen concurrently
but others need to happen in sequence. The implementation of this lens performs the data shuffling one
would expect by using the command-line argument as file-name for the write operation and duplicating the
data from STDIN to feed it to both PrintLn and the write operation.
Tee = state (MkEx (() && ())
$ \case ((_ :: files) && content) => content && map (\file => MkWrite file content) files
(_ && content) => content && [])The program is written but is currently purely symbolic, we cannot execute Tee since there is no way
actually extract the information from the operating system, for this we need IO and error handling.
To implement a lens with side effects, we need to provide handlers for each of their advertised effect.
In our case we need a handler for GetArgs STDIN PrintLn and WriteFile. Those handlers
are going to work in FSIO = IO . Either FileError a IO Monad capable of throwing filesystem errors. The
implementation is routine as they merely use the functions exposed by idris’s filesystem library.
FSIO : Type -> Type
FSIO = EitherT FileError IO
handleSTDIN : Costate (FSIO • STDIN)
handleSTDIN = costate $ const (lift readLines)
handlePrintLn : Costate (FSIO • PrintLn)
handlePrintLn = costate $ lift . putStrLn
parseN : (n : Nat) -> List a -> Maybe (Vect n a)
parseN Z [] = Just []
parseN (S n) (x :: xs) = (x ::) <$> parseN n xs
parseN _ _ = Nothing
handleGetArgs : Costate (FSIO • GetArgs)
handleGetArgs = costate $ \_ => getArgs
handleWriteFile : Costate (FSIO • WriteFile)
handleWriteFile = costate $ \(MkWrite name content) => MkEitherT $ writeFile name contentWe’ve seen before that any lens a => b can be “run” if we provide a costate b => I. To run
effectful programs, the same intuition holds but needs to be adapted to take the effects into account.
In particular, we need to explain what happens when we have multiple effects that are sequenced and also run in a monad , such lenses have type and need to result in a lens .
Theorem
A lens can be run with effects if we provide costates and and results in a costate .
The proof is given by this sequence of operations involving the fact that m • _ is a functor and there
is a map δ : m • (a ▷ b) => m • (a ▷ m • b):
run : {m : Type -> Type} ->
{a, b, c : Container} -> Monad m =>
Costate (m • b) -> Costate (m • c) -> a =%> (b ▷ c) -> Costate (m • a)
run handleB handleC lens =
CalcWith {leq = (=%>), x = m • a, y = I} $
|~ m • a
<~ m • (b ▷ c)
...(fmap {m} lens) -- map our lens into one with effects
<~ m • (b ▷ m • c)
...(δ) -- distribute the effect
<~ m • (b ▷ I)
...(fmap {m} (identity b ~▷~ handleC)) -- handle the nested effect
<~ m • b
...(fmap {m} (unitR1 {a = b})) -- remove the extra unit
<~ I
...(handleB) -- handle the remaining effectWe apply this principle here to our Tee lens to run it and turn it into a costate () -> IO () which
is equivalent to IO (), the signature for a main function.
covering
main : IO ()
main = eitherT
printLn -- print errors
pure -- handle values in IO normally
$ runCostate teeProgram () -- run the `() -> IO ()` program
where
-- handling the command line input and stdin input
handleInputs : Costate (FSIO • (GetArgs ⊗ STDIN))
handleInputs = CalcWith $
|~ FSIO • (GetArgs ⊗ STDIN)
<~ (FSIO • GetArgs) ⊗ (FSIO • STDIN)
...(distribTensor {m = FSIO, a = GetArgs, b = STDIN})
<~ I ⊗ I
...(handleGetArgs ~⊗~ handleSTDIN)
<~ I
...(unitR {a = I})
-- Handling the printing and the writing at once
handleFile : Costate (FSIO • (PrintLn ⊗ All.List WriteFile))
handleFile = CalcWith {leq = (=%>)} $
|~ FSIO • (PrintLn ⊗ All.List WriteFile)
<~ FSIO • PrintLn ⊗ FSIO • All.List WriteFile
...(distribTensor {m = FSIO, a = PrintLn, b = All.List WriteFile})
-- distribute the effect across ⊗
<~ FSIO • PrintLn ⊗ All.List (FSIO • WriteFile)
...(identity (FSIO • PrintLn) ~⊗~ pushTensor {m = FSIO, b = WriteFile})
<~ I ⊗ All.List I
...(handlePrintLn ~⊗~ mapList handleWriteFile)
-- Handle printing and writing the file concurrently
<~ I ⊗ I
...(identity I ~⊗~ elimI)
<~ I
...(unitR {a = I})
-- The program after applying all handlers
teeProgram : Costate (FSIO • I)
teeProgram =
(run {a = I, b = (GetArgs ⊗ STDIN), c = PrintLn ⊗ All.List WriteFile}
handleInputs handleFile Tee)