Chcete číst

Defunctionalization in Elm

aneb stories from the war

It was not so long ago that I opened HackerNews and saw the title The Best Refactoring You've Never Heard Of. The refactoring in question was "defunctionalization", which really was a term I never heard of, even though some of the given examples looked familiar. The talk gave some formless blob in my brain's idea space a name ("reified it", hello Rich Hickey!), and all around it was a very cool talk. (Go watch it!)


Fast forward a few days. I was fighting an ugly piece of code in our Elm codebase - one I wrote, again, not that long ago. Yeah, not my proudest moment.

Me reacting to my code.

As you can see, a lightbulb went off in my head. This sounds like THAT thing! There was nothing left to do than to try it.


The problem itself was simple to understand, but hairy in details.

We have a top-level Store.

type alias Store =
    { categories : WebData (Dict CategoryId Category)
    , questions : WebData (Dict QuestionId Question)
    , bookmarks : WebData (Dict BookmarkId Bookmark)
    , savedQueries : WebData (Dict SavedQueryId SavedQuery)
    -- ... you get the idea
    }

We also have our own Msg type in the Store module and update-like functions for fetching stuff.

fetchQuestions : Config msg -> Flags -> Store -> ( Store, Cmd msg )
fetchQuestions config flags store =
    fetch_
        { get = .questions
        , set = \val store_ -> { store_ | questions = val }
        , onSuccess = QuestionsFetched
        , request = API.getQuestions
        }
        config
        flags
        store

-- one such function for each field in the Store record

Now, many of our pages need multiple such entities fetched. So we do something similar to elm-fetch, and define what route needs which entities.

fetchForRoute : Route -> Cmd msg
fetchForRoute route =
    case route of
        Router.CrosstabBuilder CrosstabBuilder.List ->
            Store.fetchXBProjects

        Router.CrosstabBuilder (CrosstabBuilder.Detail _) ->
            Store.fetchMany
                [ Store.fetchQuestions
                , Store.fetchCategories
                , Store.fetchAudiences
                , Store.fetchAudienceFolders
                , Store.fetchXBProjects
                ]

        -- ...

As you can see, we use a fetchMany function which allows us to compose these together. It's essentially a List.foldl over our (Store, Cmd msg) type, with the catch that the fetch functions need a bunch of other stuff in parameters.

fetchMany : List (Config msg -> Flags -> Store -> ( Store, Cmd msg )) -> Config msg -> Flags -> Store -> ( Store, Cmd msg )
fetchMany list config flags store =
    List.foldl
        (\fetchAction ( currentStore, currentCmd ) ->
            let
                ( newStore, newCmd ) =
                    fetchAction config flags currentStore
            in
            ( newStore
            , Cmd.batch [ currentCmd, newCmd ]
            )
        )
        ( store, Cmd.none )
        list

See that type signature? This is where we'll start encountering problems. Due to various constraints -- notably our app being both standalone (all those pages living in one Elm app) and embedded in a legacy JS shell -- we have to pass the pages a knowledge of how to do that fetchMany action.

type alias Config msg =
    { msg : Msg msg -> msg
    , ajaxError : Error -> msg
    , navigateTo : Route -> msg
    , openAlert : String -> String -> msg
    , refreshAudiences : msg
    , fetchMany : List (Store.Config Msg -> Flags -> Store.Store -> ( Store.Store, Cmd Msg )) -> msg
    , disabledExportsAlert : msg
    }

To not get into too many details, we have to do this in one more level because of our Elm-in-JS-shell architecture, and that's where it starts getting really ugly. I don't want you to read and understand the following code, I just want you to agree that it's really ugly. Please?

type Msg
    = FetchMany (List (Store.Config Msg -> Flags -> Store.Store -> ( Store.Store, Cmd Msg )))
    -- | ...

crosstabBuilderConfig : CrosstabBuilderComponent.Config Msg
crosstabBuilderConfig =
    { msg = CrosstabBuilderMsg
    , ajaxError = AjaxError
    , navigateTo = NavigateTo
    , openAlert = OpenAlert
    , refreshAudiences = RefreshAudiences
    , fetchMany =
        \list ->
            let
                innerConfig : Store.Config (CrosstabBuilderComponent.Msg Msg)
                innerConfig =
                    Store.configure
                        { msg = CrosstabBuilderComponent.OuterMsg << StoreMsg
                        , err = \store error -> CrosstabBuilderComponent.OuterMsg <| AjaxError store error
                        }

                changeFn :
                    (Store.Config (CrosstabBuilderComponent.Msg Msg) -> Flags -> Store.Store -> ( Store.Store, Cmd (CrosstabBuilderComponent.Msg Msg) ))
                    -> (Store.Config Msg -> Flags -> Store.Store -> ( Store.Store, Cmd Msg ))
                changeFn fn =
                    \_ flags store ->
                        fn innerConfig flags store
                            |> Glue.map CrosstabBuilderMsg
            in
            FetchMany (List.map changeFn list)
    , disabledExportsAlert = DisabledExportsAlert
    }

Bleh. All this was essentially Msg mapping to appease the type system, and it got very complicated very fast.

Getting out of this mess

At the time of writing, I didn't see a way out of this. I knew it was ugly but it was the best I could do. But now, with the knowledge of defunctionalization, could I try to apply that here?

Let's see:

type FetchAction
    = FetchCategories
    | FetchQuestions
    | FetchBookmarks
    | FetchSavedQueries
    -- | ...


fetch : FetchAction -> Config msg -> Flags -> Store -> ( Store, Cmd msg )
fetch =
    -- exercise for the reader
    Debug.todo "Store.fetch"


fetchMany : List FetchAction -> Config msg -> Flags -> Store -> ( Store, Cmd msg )
fetchMany =
    -- exercise for the reader
    Debug.todo "Store.fetchMany"

What I've done above is replaced all those fetchQuestions-like functions with a single fetch one, which looks very much like update now. Also, FetchAction appeared! This is the crux of defunctionalization - we have replaced functions with data and a way to interpret that data later. In essence, nothing changed, but we'll be allowed to have nicer types and get rid of that horrible boilerplate!

And now, because the fetchMany type annotation no longer contains any parameterized msg types, it simplifies all types that touch it to the point where we don't need to Cmd.map the Msg at all! That horrible piece of code becomes:

type Msg
    = FetchMany (List Store.FetchAction)
    -- | ...


crosstabBuilderConfig : CrosstabBuilderComponent.Config Msg
crosstabBuilderConfig =
    { msg = CrosstabBuilderMsg
    , ajaxError = AjaxError
    , navigateTo = NavigateTo
    , openAlert = OpenAlert
    , refreshAudiences = RefreshAudiences
    , fetchMany = FetchMany
    , disabledExportsAlert = DisabledExportsAlert
    }

And so, we had a happy ending (click to enlarge):

Happy ending


Conclusion

To recap, we were making our lives unnecessarily hard by

  • using parameterized msg types
  • sending functions that used them around in Msgs
  • and then had to create Rube Goldberg machines to Cmd.map the Msg types around correctly.

Rube Goldberg machine

To get out of this mess, we defunctionalized by

  • creating a datatype for all the various functions we needed to pass (FetchAction)
  • creating a function that used them (fetch : FetchAction -> ...)
  • passed the datatype around instead of the functions
  • applied the new function with the datatype instead of running the old, now nonexistent functions directly

Defunctionalization has other benefits than just simplifying type signatures. For example it allows you to serialize the action and send it over the wire if you need to!


I encourage you to watch the talk about defunctionalization if you haven't yet. It has more examples of defunctionalization in practice - which is always great for cementing a new concept in your head. Also sorry for probably a bit rushed blogpost - I was just excited by this simplification of our code and wanted to share it, without sharing too many unnecessary details. Hopefully I've managed to do that!

Napište komentář