Extending the guessing game with effects

In the previous chapter we implemented a simple guessing game. In this chapter we will extend the final program with to keep track of how many guesses the player has done and finally report it at the end.

First, this was the final program from the previous chapter.

let io @ { ? } = import! std.io
let random = import! std.random
let int = import! std.int
let string = import! std.string
let { Result } = import! std.result
let { Ordering, compare } = import! std.cmp

do _ = io.println "Guess a number between 1 and 100!"
do target_number = random.thread_rng.gen_int_range 1 101

let guess_number _ : () -> IO () =
    do line = io.read_line
    match int.parse (string.trim line) with
    | Err _ ->
        do _ = io.println "That is not a number!"
        guess_number ()
    | Ok guess ->
        match compare guess target_number with
        | EQ -> io.println "Correct!"
        | LT ->
            do _ = io.println "Wrong! Your guess is too low!"
            guess_number ()
        | GT ->
            do _ = io.println "Wrong! Your guess is too high!"
            guess_number ()

// Start the first guess
guess_number ()

To keep track of how many guesses the player has done we need to keep some state as we go through each guess. There are a few ways of doing this but the one we will look at here is using gluon's extensible effect system. This can be found under the std.effect module.

Extensible effects are a way to represent side effects in a composable way. In other words, it is possible to use multiple different effects easily, something which can otherwise be fairly difficult with the monadic representation of side effects that gluon employs.

Rewriting IO into Eff

Before we get into adding more effects though we first need to replace the IO monad with the Eff monad used to represent effects. Normally it is not possible to use a Monad directly when using effects and instead an entirely new "effect handler" needs to be written for the effect. However, Eff allows the use of ONE Monad instance among the various effects in an effect row with the Lift effect.

Lets see how we need to modify the code the guessing game to use Eff instead of IO, without adding any functionality.

let io @ { ? } = import! std.io
let random = import! std.random
let int = import! std.int
let string = import! std.string
let { Result } = import! std.result
let { Ordering, compare } = import! std.cmp

let { (<|) } = import! std.function
let effect @ { Eff, ? } = import! std.effect
let { Lift, run_lift, lift, ? } = import! std.effect.lift

let start _ : () -> Eff [| lift : Lift IO | r |] () =
    do _ = lift <| io.println "Guess a number between 1 and 100!"
    do target_number = lift <| random.thread_rng.gen_int_range 1 101

    let guess_number _ : () -> Eff [| lift : Lift IO | r |] () =
        do line = lift <| io.read_line
        match int.parse (string.trim line) with
        | Err _ ->
            do _ = lift <| io.println "That is not a number!"
            guess_number ()
        | Ok guess ->
            match compare guess target_number with
            | EQ -> lift <| io.println "Correct!"
            | LT ->
                do _ = lift <| io.println "Wrong! Your guess is too low!"
                guess_number ()
            | GT ->
                do _ = lift <| io.println "Wrong! Your guess is too high!"
                guess_number ()

    // Start the first guess
    guess_number ()

run_lift <| start ()

There is quite a bit going down here so lets break it down.

let { (<|) } = import! std.function
let effect @ { Eff, ? } = import! std.effect
let { Lift, run_lift, lift, ? } = import! std.effect.lift

Here we import a few new things. The first just imports the useful <| operator, it doesn't really do any thing on its own and just serves as a way to let us avoid parentheses in a few places. Essentially f <| a 123 "a" is the same as f (a 123 "a").

Next we import the Eff type and its implicit instances so we can use do with it.

Finally we import Lift and some functions to help us work with it. The lift function lets us "lift" a monadic action into the Eff monad. run_lift does the opposite, it takes the monadic action out of the Eff monad and lets us evaluate it.

let start _ : () -> Eff [| lift : Lift IO | r |] () =

Here we define our entrypoint and we can see the first use of the Eff type and a new bit of syntax which describes an "effect row".

Effect rows are described in a similar manner to records, except they use [| and |] to delimit instead of { and }. Rather than describing what fields a record holds they instead describe the effects used in this function. In the same way as records, effect rows also has an optional | r part which is what makes this "extensible". If we defined the row without the | r part then we would get an error when trying to use the effect in a place that allows more effects.

let eff_closed : Eff [| eff_1 : Eff1 |] () = ...

let eff_open : Eff [| eff_1 : Eff1 | r |] () = ...

let eff : Eff [| eff_1 : Eff1, eff_2 : Eff2 | r |] () =
    // Error, type mismatch `[| eff_1 : Eff1 |]` does not contain `eff_2 : Eff2`
    do _ = eff_closed
    // Ok, because the action is polymorphic/extensible
    do _ = eff_open
    // etc

Essentially the type variable says that the effect "may have more effects then the ones specified" which lets us only specify the effects that each individual function needs while still being able to use them transparently in a program that uses additional effects.

In this place we only use the lift : Lift IO effect but we will see how we can use this to specify multiple effects in a little bit.

    do _ = lift <| io.println "Guess a number between 1 and 100!"

Here we actually start running an action. Since io.println returns an IO () action and we need an Eff [| lift : Lift IO | r |] () action we use lift to convert into the correct type. Otherwise the code is exactly the same as before!

(The need for lift is likely to be removed in the future.)

The lines that follow are all the same as before, only they may have received the lift treatment as well. Except for the last line.

run_lift <| start ()

Here we use run_lift to go back from Eff [| lift : Lift IO | r |] () into IO () which the gluon interpreter can execute.

Adding the State effect

Now that we have the program written with Eff we can use this to easily add some state to track how many guesses the player has done!

We do this using the State effect which lets us hold a value in the Eff monad which we can read and write to.

let io @ { ? } = import! std.io
let random = import! std.random
let int = import! std.int
let string = import! std.string
let { Result } = import! std.result
let { Ordering, compare } = import! std.cmp

let { (<|) } = import! std.function
let effect @ { Eff, ? } = import! std.effect
let { Lift, run_lift, lift, ? } = import! std.effect.lift
let { State, eval_state, ? } = import! std.effect.state

let start _ : () -> Eff [| lift : Lift IO, state : State Int | r |] () =
    do _ = lift <| io.println "Guess a number between 1 and 100!"
    do target_number = lift <| random.thread_rng.gen_int_range 1 101

    let guess_number _ : () -> Eff [| lift : Lift IO, state : State Int | r |] () =
        do line = lift <| io.read_line
        match int.parse (string.trim line) with
        | Err _ ->
            do _ = lift <| io.println "That is not a number!"
            guess_number ()
        | Ok guess ->
            match compare guess target_number with
            | EQ -> lift <| io.println "Correct!"
            | LT ->
                do _ = lift <| io.println "Wrong! Your guess is too low!"
                guess_number ()
            | GT ->
                do _ = lift <| io.println "Wrong! Your guess is too high!"
                guess_number ()

    // Start the first guess
    guess_number ()

run_lift <| eval_state 0 <| start ()

There are only two new things here, first we add state : State Int to the effect row to indicate that we want to use a state consisting of a single integer.

The second addition is to add eval_state 0 before the run_lift call. This initializes the state with 0 and then evaluates the effect.

Notably we haven't actually used the state in any way except initializing it so lets actually use it. To work with the state we have the basic get, put and modify actions.'

let io @ { ? } = import! std.io
let random = import! std.random
let int = import! std.int
let string = import! std.string
let { Result } = import! std.result
let { Ordering, compare } = import! std.cmp

let { (<|) } = import! std.function
let effect @ { Eff, ? } = import! std.effect
let { Lift, run_lift, lift, ? } = import! std.effect.lift
let { State, eval_state, modify, get, ? } = import! std.effect.state

let start _ : () -> Eff [| lift : Lift IO, state : State Int | r |] () =
    do _ = lift <| io.println "Guess a number between 1 and 100!"
    do target_number = lift <| random.thread_rng.gen_int_range 1 101

    let guess_number _ : () -> Eff [| lift : Lift IO, state : State Int | r |] () =
        do line = lift <| io.read_line
        match int.parse (string.trim line) with
        | Err _ ->
            do _ = lift <| io.println "That is not a number!"
            guess_number ()
        | Ok guess ->
            // Increment the guess counter by one
            do _ = modify ((+) 1)
            match compare guess target_number with
            | EQ ->
                // Retrieve the guess so we can output it
                do guesses = get
                lift <| io.println ("Correct! You got it in " ++ show guesses ++ " guesses!")
            | LT ->
                do _ = lift <| io.println "Wrong! Your guess is too low!"
                guess_number ()
            | GT ->
                do _ = lift <| io.println "Wrong! Your guess is too high!"
                guess_number ()

    // Start the first guess
    guess_number ()

run_lift <| eval_state 0 <| start ()

We only needed to add two things here, first we add one on every guess using modify then we use get to retrieve the final amount when the player succeeded.

And that is all we had to do to add the guess tracking. The strength of extensible effects is precisely that there is very little work needed to add additional side effects to the program, despite the program as a whole still being entirely pure!

Just to show the strength of effects one last time we add the Error effect to move the error handling outside of the main logic.

let io @ { ? } = import! std.io
let random = import! std.random
let int = import! std.int
let string = import! std.string
let { Result, map_err } = import! std.result
let { Ordering, compare } = import! std.cmp

let { (<|) } = import! std.function
let effect @ { Eff, ? } = import! std.effect
let { Lift, run_lift, lift, ? } = import! std.effect.lift
let { State, eval_state, modify, get, ? } = import! std.effect.state
let { Error, ok_or_throw, run_error, catch, ? } = import! std.effect.error


type GuessError =
    | NotANumber

let start _ : () -> Eff [| lift : Lift IO, state : State Int, error : Error GuessError | r |] () =
    do _ = lift <| io.println "Guess a number between 1 and 100!"
    do target_number = lift <| random.thread_rng.gen_int_range 1 101

    let guess_number _ : () -> Eff [| lift : Lift IO, state : State Int, error : Error GuessError | r |] () =
        do line = lift <| io.read_line
        do guess =
            ok_or_throw 
                <| map_err (\_ -> NotANumber)
                <| int.parse
                <| string.trim line
        do _ = modify ((+) 1)
        match compare guess target_number with
        | EQ ->
            do guesses = get
            lift <| io.println ("Correct! You got it in " ++ show guesses ++ " guesses!")
        | LT ->
            do _ = lift <| io.println "Wrong! Your guess is too low!"
            guess_number ()
        | GT ->
            do _ = lift <| io.println "Wrong! Your guess is too high!"
            guess_number ()

    // Start the first guess
    catch (guess_number ()) <| \err ->
        match err with
        | NotANumber -> 
            do _ = lift <| io.println "That is not a number!"
            guess_number ()

run_lift <| run_error <| eval_state 0 <| start ()