199 lines
No EOL
9.3 KiB
Text
199 lines
No EOL
9.3 KiB
Text
/-!
|
|
# Readers
|
|
|
|
In the [previous section](monads.lean.md) you learned about the conceptual idea of monads. You learned
|
|
what they are, and saw how some common types like `IO` and `Option` work as monads. Now in this
|
|
section, you will be looking at some other useful monads. In particular, the `ReaderM` monad.
|
|
|
|
## How to do Global Variables in Lean?
|
|
|
|
In Lean, your code is generally "pure", meaning functions can only interact with the arguments
|
|
passed to them. This effectively means you cannot have global variables. You can have global
|
|
definitions, but these are fixed at compile time. If some user behavior might change them, you would have
|
|
to wrap them in the `IO` monad, which means they can't be used from pure code.
|
|
|
|
Consider this example. Here, you want to have an `Environment` containing different parameters as a
|
|
global variable. However, you want to load these parameters from the process environment variables,
|
|
which requires the `IO` monad.
|
|
-/
|
|
|
|
structure Environment where
|
|
path : String
|
|
home : String
|
|
user : String
|
|
deriving Repr
|
|
|
|
def getEnvDefault (name : String): IO String := do
|
|
let val? ← IO.getEnv name
|
|
pure <| match val? with
|
|
| none => ""
|
|
| some s => s
|
|
|
|
def loadEnv : IO Environment := do
|
|
let path ← getEnvDefault "PATH"
|
|
let home ← getEnvDefault "HOME"
|
|
let user ← getEnvDefault "USER"
|
|
pure { path, home, user }
|
|
|
|
def func1 (e : Environment) : Float :=
|
|
let l1 := e.path.length
|
|
let l2 := e.home.length * 2
|
|
let l3 := e.user.length * 3
|
|
(l1 + l2 + l3).toFloat * 2.1
|
|
|
|
def func2 (env : Environment) : Nat :=
|
|
2 + (func1 env).floor.toUInt32.toNat
|
|
|
|
def func3 (env : Environment) : String :=
|
|
"Result: " ++ (toString (func2 env))
|
|
|
|
def main : IO Unit := do
|
|
let env ← loadEnv
|
|
let str := func3 env
|
|
IO.println str
|
|
|
|
#eval main -- Result: 7538
|
|
|
|
/-!
|
|
The only function actually using the environment is func1. However func1 is a pure function. This
|
|
means it cannot directly call loadEnv, an impure function in the IO monad. This means the
|
|
environment has to be passed through as a variable to the other functions, just so they can
|
|
ultimately pass it to func1. In a language with global variables, you could save env as a global
|
|
value in main. Then func1 could access it directly. There would be no need to have it as a parameter
|
|
to func1, func2 and func3. In larger programs, these "pass-through" variables can cause a lot of
|
|
headaches.
|
|
|
|
## The Reader Solution
|
|
|
|
The `ReaderM` monad solves this problem. It effectively creates a global read-only value of a
|
|
specified type. All functions within the monad can "read" the type. Let's look at how the `ReaderM`
|
|
monad changes the shape of this code. Now the functions **no longer need** to be given the
|
|
`Environment` as an explicit parameter, as they can access it through the monad.
|
|
-/
|
|
|
|
def readerFunc1 : ReaderM Environment Float := do
|
|
let env ← read
|
|
let l1 := env.path.length
|
|
let l2 := env.home.length * 2
|
|
let l3 := env.user.length * 3
|
|
return (l1 + l2 + l3).toFloat * 2.1
|
|
|
|
def readerFunc2 : ReaderM Environment Nat :=
|
|
readerFunc1 >>= (fun x => return 2 + (x.floor.toUInt32.toNat))
|
|
|
|
def readerFunc3 : ReaderM Environment String := do
|
|
let x ← readerFunc2
|
|
return "Result: " ++ toString x
|
|
|
|
def main2 : IO Unit := do
|
|
let env ← loadEnv
|
|
let str := readerFunc3.run env
|
|
IO.println str
|
|
|
|
#eval main2 -- Result: 7538
|
|
/-!
|
|
The `ReaderM` monad provides a `run` method and it is the `ReaderM` run method that takes the initial
|
|
`Environment` context. So here you see `main2` loads the environment as before, and establishes
|
|
the `ReaderM` context by passing `env` to the `run` method.
|
|
|
|
> **Side note 1**: The `return` statement used above also needs some explanation. The `return`
|
|
statement in Lean is closely related to `pure`, but a little different. First the similarity is that
|
|
`return` and `pure` both lift a pure value up to the Monad type. But `return` is a keyword so you do
|
|
not need to parenthesize the expression like you do when using `pure`. (Note: you can avoid
|
|
parentheses when using `pure` by using the `<|` operator like we did above in the initial
|
|
`getEnvDefault` function). Furthermore, `return` can also cause an early `return` in a monadic
|
|
function similar to how it can in an imperative language while `pure` cannot.
|
|
|
|
> So technically if `return` is the last statement in a function it could be replaced with `pure <|`,
|
|
but one could argue that `return` is still a little easier for most folks to read, just so long as
|
|
you understand that `return` is doing more than other languages, it is also wrapping pure values in
|
|
the monadic container type.
|
|
|
|
> **Side note 2**: If the function `readerFunc3` also took some explicit arguments then you would have
|
|
to write `(readerFunc3 args).run env` and this is a bit ugly, so Lean provides an infix operator
|
|
`|>` that eliminates those parentheses so you can write `readerFunc3 args |>.run env` and then you can
|
|
chain multiple monadic actions like this `m1 args1 |>.run args2 |>.run args3` and this is the
|
|
recommended style. You will see this patten used heavily in Lean code.
|
|
|
|
The `let env ← read` expression in `readerFunc1` unwraps the environment from the `ReaderM` so we
|
|
can use it. Each type of monad might provide one or more extra functions like this, functions that
|
|
become available only when you are in the context of that monad.
|
|
|
|
Here the `readerFunc2` function uses the `bind` operator `>>=` just to show you that there are bind
|
|
operations happening here. The `readerFunc3` function uses the `do` notation you learned about in
|
|
[Monads](monads.lean.md) which hides that bind operation and can make the code look cleaner.
|
|
So the expression `let x ← readerFunc2` is also calling the `bind` function under the covers,
|
|
so that you can access the unwrapped value `x` needed for the `toString x` conversion.
|
|
|
|
The important difference here to the earlier code is that `readerFunc3` and `readerFunc2` no longer
|
|
have an **explicit** Environment input parameter that needs to be passed along all the way to
|
|
`readerFunc1`. Instead, the `ReaderM` monad is taking care of that for you, which gives you the
|
|
illusion of something like global context where the context is now available to all functions that use
|
|
the `ReaderM` monad.
|
|
|
|
The above code also introduces an important idea. Whenever you learn about a monad "X", there's
|
|
often (but not always) a `run` function to execute that monad, and sometimes some additional
|
|
functions like `read` that interact with the monad context.
|
|
|
|
You might be wondering, how does the context actually move through the `ReaderM` monad? How can you
|
|
add an input argument to a function by modifying its return type? There is a special command in
|
|
Lean that will show you the reduced types:
|
|
-/
|
|
#reduce ReaderM Environment String -- Environment → String
|
|
/-!
|
|
And you can see here that this type is actually a function! It's a function that takes an
|
|
`Environment` as input and returns a `String`.
|
|
|
|
Now, remember in Lean that a function that takes an argument of type `Nat` and returns a `String`
|
|
like `def f (a : Nat) : String` is the same as this function `def f : Nat → String`. These are
|
|
exactly equal as types. Well this is being used by the `ReaderM` Monad to add an input argument to
|
|
all the functions that use the `ReaderM` monad and this is why `main` is able to start things off by
|
|
simply passing that new input argument in `readerFunc3.run env`. So now that you know the implementation
|
|
details of the `ReaderM` monad you can see that what it is doing looks very much like the original
|
|
code we wrote at the beginning of this section, only it's taking a lot of the tedious work off your
|
|
plate and it is creating a nice clean separation between what your pure functions are doing, and the
|
|
global context idea that the `ReaderM` adds.
|
|
|
|
## withReader
|
|
|
|
One `ReaderM` function can call another with a modified version of the `ReaderM` context. You can
|
|
use the `withReader` function from the `MonadWithReader` type class to do this:
|
|
|
|
-/
|
|
def readerFunc3WithReader : ReaderM Environment String := do
|
|
let x ← withReader (λ env => { env with user := "new user" }) readerFunc2
|
|
return "Result: " ++ toString x
|
|
|
|
/-!
|
|
Here we changed the `user` in the `Environment` context to "new user" and then we passed that
|
|
modified context to `readerFunc2`.
|
|
|
|
So `withReader f m` executes monad `m` in the `ReaderM` context modified by `f`.
|
|
|
|
## Handy shortcut with (← e)
|
|
|
|
If you use the operator `←` in a let expression and the variable is only used once you can
|
|
eliminate the let expression and place the `←` operator in parentheses like this
|
|
call to loadEnv:
|
|
-/
|
|
def main3 : IO Unit := do
|
|
let str := readerFunc3 (← loadEnv)
|
|
IO.println str
|
|
/-!
|
|
|
|
## Conclusion
|
|
|
|
It might not seem like much has been accomplished with this `ReaderM Environment` monad, but you will
|
|
find that in larger code bases, with many different types of monads all composed together this
|
|
greatly cleans up the code. Monads provide a beautiful functional way of managing cross-cutting
|
|
concerns that would otherwise make your code very messy.
|
|
|
|
Having this control over the inherited `ReaderM` context via `withReader` is actually very useful
|
|
and something that is quite messy if you try and do this sort of thing with global variables, saving
|
|
the old value, setting the new one, calling the function, then restoring the old value, making sure
|
|
you do that in a try/finally block and so on. The `ReaderM` design pattern avoids that mess
|
|
entirely.
|
|
|
|
Now it's time to move on to [StateM Monad](states.lean.md) which is like a `ReaderM` that is
|
|
also updatable.
|
|
-/ |