Skip to content

folkertdev/elm-state

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

34 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Threading state through computation

This library provides ways to compose functions of the type s -> (a, s).

From time to time, you'll see a pattern like this in your code

(newValue,     newState)     = f state
(newerValue,   newerState)   = g newValue   newState
(newererValue, newererState) = h newerValue newerState

The above pattern is ugly and error-prone (because of typo's, for instance). It can be abstracted by creating a function that composes f and g, that is the output of f is the input to g.

    f :      s -> (a, s) 
    g : a -> s -> (a, s) 

This library implements the described composition and provides a bunch of helper functions for working with State. For a more in-depth explanation of how the implementation works, see the derivation.

This library is advanced and relatively abstract. If anything is unclear, please open an issue.

Working with values within State

There are three main functions for working with the wrapped state value:

  • State.get: aquire the state value.

    State.map2 (+) State.get State.get
        |> State.run 42
        -- == (84, 42)
  • State.put: set the state to a certain value.

    State.put 5
        |> State.run 3
        -- == ((), 5)

    Note that State.put discards the value currently stored in the state.

  • State.modify: apply a function to the state; a combination of get and set

    State.modify (\v -> v + 1)
        |> State.map (\_ -> "finished")
        |> State.run 42
        -- == ("finished", 43)

    Note that State.modify (because it combines get and put) discards the value currently stored in the state.

Extracting results from State

Remember, we really build a large s -> (a, s) function out of smaller components. To run this function, we need an initial state, and get a final state and a final value. The function State.run does just that.

run : s -> State s a -> (a, s)
run initialState (State f) = 
    f initialState

Structuring computation with andThen

The composition operator for functions wrapped in State is called andThen. It is the primary way to structure computations that involve State. When not used with care, this can lead to truly awful code. This is what the Haskellers at Facebook call a code tornado:

cycle : Int -> State (Array Bool) (Maybe Int)
cycle n =
    let
        mark : Int -> State (Array Bool) ()
        mark n =
            State.modify (Array.set n False)

        multiplesToMark length n =
            range (n * n) length n

    in
        State.map Array.length State.get
            |> andThen (\length ->  
                State.mapState mark (multiplesToMark length n)
                    |> andThen  (\_ -> 
                        State.map (toNextIndex n) State.get
                    )
            )

This problem can be solved by extracting subcomputations and giving them a descriptive name. Not only is the final composition (in the 'in' clause) very readable and understandable, the individual components are also nicely reusable.

cycle : Int -> State (Array Bool) (Maybe Int)
cycle n =
    let
        mark : Int -> State (Array Bool) ()
        mark n =
            State.modify (Array.set n False)

        multiplesToMark length n =
            range (n * n) length n

        getArrayLength =
            State.map Array.length State.get

        markMultiples length =
            -- mapState shares the Array Int between invocations of mark.
            State.mapState mark (multiplesToMark length n)

        setNextIndex _ =
            State.map (toNextIndex n) State.get
    in
        getArrayLength 
            |> andThen markMultiples 
            |> andThen setNextIndex

When using andThen, try to break up your computation into small, reusable bits and give them a descriptive name. A general Elm principle is that the shortest code is often not the best code. Don't take shortcuts with andThen in production code.

Tips

  • Prevent code tornadoes

  • Name subcomputations/functions appropriately

  • A bit more verbose is better than a bit shorter

  • Limit the amount of code that is "in State" to a minimum Try to keep functions pure and use the helper functions in this package to let the work on values "in State".

  • Limit the use of andThen in combination with State.state Instead of this

    State.get |> andThen (\value -> state (f value))

    write

    State.map f State.get

Use cases

By design, most functional programs don't use state, because it's quite cumbersome to work with. It's a good idea to see whether you can do without this library.

Sometimes though, there are very good reasons. This is mostly the case in traditionally imperative algorithms that use nonlocal mutable variables. There are a few other cases in standard Elm where the pattern in the (motivation)[#movation] pops up, the primary ones being working with random values and updating (child) components. This library is not made for the latter purposes, but its concepts transfer over.

Finally, there is a pattern from Haskell that uses State to hold configuration information (on its own called Read) and to store logging information (on its own called Write). This pattern hasn't really found its way to Elm, and it may not need to, because Elm solves its problems differently. In any case, experiment and see what works for you.

Caching function results

Some computations are resource-intensive and should preferably only be performed once. The classical example of this is the fibonacci sequence.

fib : Int -> Int 
fib n = 
    case n of 
        0 -> 1
        1 -> 1
        _ -> fib (n - 1) + fib (n - 2)

Every evaluation of fib (except the base cases 0 and 1) requires two more evaluations. Those evaluations each too require two more evaluations, making the time complexity of this function exponential. Furthermore, the two trees (fib (n - 1) and fib (n - 2)) overlap, doing the same calculations twice. These problems can we solved by caching already calculated values. This is where State comes in:

fib : Int -> Int
fib n =
    let
        initialState =
            Dict.fromList [ ( 0, 1 ), ( 1, 1 ) ]
    in
        State.finalValue initialState (fibHelper n)


fibHelper : Int -> State (Dict Int Int) Int
fibHelper n =
    let
        -- takes n as an argument to preserve laziness
        calculateStatefullFib : Int -> State (Dict Int Int) Int
        calculateStatefullFib n =
            State.map2 (+) (fibHelper (n - 1)) (fibHelper (n - 2))

        addNewValue : Int -> State (Dict Int Int) Int
        addNewValue solution =
            State.modify (Dict.insert n solution)
                |> State.map (\_ -> solution)

        modifyWhenNeeded : Dict Int Int -> State (Dict Int Int) Int
        modifyWhenNeeded cache =
            case Dict.get n cache of
                Just cachedSolution ->
                    state cachedSolution

                Nothing ->
                    calculateStatefullFib n
                        |> andThen addNewValue
    in
        State.get 
            |> andThen modifyWhenNeeded

Notice how, with the help of choosing descriptive names, this function reads like a step-by-step description of what happens. This is what makes using andThen so nice.

It is also possible to reuse the cache for multiple invocations of fibHelper, for example using State.traverse.

fibs : List Int -> List Int
fibs =
    let
        initialState =
            Dict.fromList [ ( 0, 1 ), ( 1, 1 ) ]
    in
        State.finalValue initialState << fibsHelper


fibsHelper : List Int -> State (Dict Int Int) (List Int)
fibsHelper =
    State.traverse fibHelper

Threading a Random seed

When working with random values, you have to update the seed after every computation. Note how this is very similar to the general pattern in the motivation section.

myRandomValues =
    let
        myGenerator = Random.int 0 10

        (a, newSeed1) = Random.step myGenerator seed
        (b, newSeed2) = Random.step myGenerator newSeed1
        (c, newSeed3) = Random.step myGenerator newSeed2
    in
        (a, b, c)

The Random.mapN functions do exactly what compose described above does. I advice to use the random-specific functions when working with random values.

myRandomValues =
    let
        myGenerator = Random.int 0 10
    in
        Random.map3 (,,) myGenerator myGenerator myGenerator
            |> (flip Random.step) seed
            |> Tuple.first

Recursively applying update

Don't use this library for this purpose, prefer Fresheyeball/elm-return.

The typical signature for update with TEA is

update : Msg -> Model -> (Model, Cmd Msg)

When we drop the first argument (partial function application) and swap the order of the types in the tuple, we get

update' : Model -> (Cmd Msg, Model) 

This is a function with the same type as the ones wrapped in State. The elm-effects library uses a pattern similar to this library to consecutively apply operations that generate side-effects to a value.

Elm-effects is a bit less powerful than this package (technically, it's just a Writer monad, not a State monad). If you need the more powerful functions then reevaluate, use this package and maybe contribute to elm-effects.

A derivation of how the composition of functions of the form s -> (a, s) works.

In the example from the beginning,

(newValue,     newState)     = f state
(newerValue,   newerState)   = g newValue   newState
(newererValue, newererState) = h newerValue newerState

f and g have the following types.

    f :      s -> (a, s) 
    g : a -> s -> (a, s) 

We want to compose these two functions, which means that the a and s returned by f serve as an input to g. This can be done as follows:

    composed : a -> s -> (s, a) 
    composed value state = 
        let 
            (newValue, newState) = f value state
        in 
            g newValue newState

Let's generalize this a little further

    compose :       (s -> (a, s))
            -> (a -> s -> (b, s))
            ->      (s -> (b, s))
    compose f g state = 
        let 
            (newValue, newState) = f state
        in 
            g newValue newState

The essence of this library is composing multiple functions of the type s -> (a, s) into one giant function of the same type. All the intermediate threading (result of f is input to g) is automatically taken care of. For better error messages, the s -> (a, s) function is wrapped in a type.

type State s a = 
    State (s -> (a, s))

Compose can then be written as

compose : State s a -> (a -> State s b) -> State s b
compose (State f) g = 
    let 
        helper : s -> (s, b)
        helper initialState = 
            let (newValue, newState) = f initialState

                -- unwrap the newly created state
                (State h) = g newValue
            in 
                h newState
    in 
        State helper 

Functions of the type (a -> Wrapper b) -> Wrapper a -> Wrapper b are called andThen in Elm (see Maybe.andThen and Random.andThen), but sometimes also referred to as bind or (>>=).

Problems

  • RangeError: Recursion limit exceeded: Older versions of this package would often throw this error at runtime. Most of these cases are now fixed, but if you run into this error, please report it.

About

Threading state through computations in elm

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages