How to write terser Elm code? Elm/Python comparison

Hello,

I just finished programming a little function in Elm.
You have nested dictionaries representing dates. A first level in the dictionary is for years, second level for months, third level for days, and then in each day dictionnary, you have names (strings) with counts.
Like so:

{
    2021: {
        8: {
            31: {"Jeremy": 1}
        },
        9: {
            7: {"Jane": 5},
            10: {"Sandra": 5},
            12: {"Michel": 2, "Pedro": 2}
        }
    }
}

I wrote a function to increase a count associated to one of the names in the dictionary.
Here’s the function written in Elm:

module Achievements exposing (Achievements)

import Dict exposing (Dict)


type alias Achievements =
    Dict Int (Dict Int (Dict Int (Dict String Int)))


increaseCount : Int -> Int -> Int -> Int -> String -> Achievements -> Achievements
increaseCount increment year month day name achievements =
    case Dict.get year achievements of
        Nothing ->
            Dict.insert
                year
                (Dict.fromList
                    [ ( month
                      , Dict.fromList
                            [ ( day
                              , Dict.fromList
                                    [ ( name, increment ) ]
                              )
                            ]
                      )
                    ]
                )
                achievements

        Just yearData ->
            case Dict.get month yearData of
                Nothing ->
                    Dict.insert
                        year
                        (Dict.insert
                            month
                            (Dict.fromList
                                [ ( day
                                  , Dict.fromList
                                        [ ( name, increment ) ]
                                  )
                                ]
                            )
                            yearData
                        )
                        achievements

                Just monthData ->
                    case Dict.get day monthData of
                        Nothing ->
                            Dict.insert
                                year
                                (Dict.insert
                                    month
                                    (Dict.insert
                                        day
                                        (Dict.fromList
                                            [ ( name, increment ) ]
                                        )
                                        monthData
                                    )
                                    yearData
                                )
                                achievements

                        Just dayData ->
                            case Dict.get name dayData of
                                Nothing ->
                                    Dict.insert
                                        year
                                        (Dict.insert
                                            month
                                            (Dict.insert
                                                day
                                                (Dict.insert
                                                    name
                                                    increment
                                                    dayData
                                                )
                                                monthData
                                            )
                                            yearData
                                        )
                                        achievements

                                Just n ->
                                    Dict.insert
                                        year
                                        (Dict.insert
                                            month
                                            (Dict.insert
                                                day
                                                (Dict.insert
                                                    name
                                                    (n + increment)
                                                    dayData
                                                )
                                                monthData
                                            )
                                            yearData
                                        )
                                        achievements

That’s 101 lines of code.

In Python I would obtain the same functionality with those 11 lines:

class Achievements:
    def __init__(self):
        self._dct = {}

    def increase_count(self, increment, year, month, day, name):
        self._dct \
            .setdefault(year, {}) \
            .setdefault(month, {}) \
            .setdefault(day, {})  \
            .setdefault(name, 0)
        self._dct[year][month][day][name] += increment

I realize the Python version doesn’t come with type safety and so an Elm version would necessarily be longer just to deal with the types. It’s also not stateless, which could be an inconvenient in some cases. But how can I make the Elm version terser and more readable?

I think Dict.update will help here.

Also, do you really need a dict inside a dict inside a dict? If not, I’d try something like Dict ( Date, String ) Int where Dict comes from AssocList and Date comes from Date. That sounds much easier to update than nested dicts.

mapOrDefault : b -> (a -> b) -> comparable -> Dict comparable a -> Dict comparable b 
mapOrDefault defaultValue fun key dict =
    Dict.update
        key
        (\maybeVal ->
            case maybeVal of
                Nothing -> Just defaultValue
                Just a -> Just (fn a)
        )
        dict


increaseCount : Int -> Int -> Int -> Int -> String -> Achievements -> Achievements
increaseCount amount year month day name achievements =
    mapOrDefault
        Dict.empty
        (\yearData ->
            mapOrDefault
                Dict.empty
                (\monthData ->
                    mapOrDefault
                        Dict.empty
                        (\dayData ->
                            mapOrDefault
                                amount
                                (\count ->
                                    count + amount
                                )
                                name
                                dayData
                        )
                        day
                        monthData
                )
                month
                yearData
        )
        year
        achievements

Turning the repetition of updating into a function makes it a lot shorter. If you wanted you could make it even shorter by using currying like so

mapOrDefault : b -> (a -> b) -> comparable -> Dict comparable a -> Dict comparable b 
mapOrDefault defaultValue fun key =
    Dict.update key (Maybe.map fn >> Maybe.withDefault defaultValue >> Just)


increaseCount : Int -> Int -> Int -> Int -> String -> Achievements -> Achievements
increaseCount amount year month day name =
    mapOrDefault Dict.empty
        (mapOrDefault Dict.empty
                (mapOrDefault Dict.empty
                        (mapOrDefault amount ((+) amount) name)
                        day
                )
                month
        )
        year

The second is more terse, but I’d argue less readable. If you really wanted to keep this structure I’d probably break each level out into its own update. That does increase line count, but I think line count is significantly less important than how easy it is to read and refactor.

4 Likes

I would use a different datamodel. You can always encode/decode back to that nested record structure whenever you want.

type alias DateAndName = ((Int,Int,Int),String)
type alias DataModel = Dict DateAndName Int

To increment:

let 
  id = ((year,month,day),userName)
in 
case Dict.get id dict of 
  Nothing ->
    Dict.insert id 0 dict 
  Just existingValue ->
    Dict.insert id (existingValue + 1) dict

Another plus with a model like that it is sorted by date by default :slight_smile:
also easier to do queries.
Like get all items that has a bigger count than 10 the second day, no matter what year or month.

Dict.filter (\((year,month,day),userName) value -> 
    day == 2 && value > 10
) dict
5 Likes

I really like that solution, wolfadex. Thank you.
Not that the other comments weren’t useful. I could change the datastructure but my question stemmed from the realization I was writing a lot of lines compared to what I would do in Python. And I wanted to see how I could improve this, on a case where the difference was particularly large.
I’m glad to see it can be done and the inelegance of my code was more due to my lack of experience with the language than to limitations of the language itself.
Thanks everybody.

I don’t think this is an Elm specific question really. What stood out to me was that your Python code used that set default function but your Elm code didn’t use anything comparable. In your Python code you used helper functions but then skipped that idea in Elm.

My guess is that you feel more comfortable doing so in Python as you’ve been using it a lot longer, and Elm is new enough to you that maybe you get distracted by the novelty and forget to use easy solutions like creating a function. I’m actually not surprised at all because I see this a lot on Exercism, even from people who have lots of programming experience.

I am super excited you asked though as I think there are a lot of people who would have been too anxious to do so.

1 Like

I also think a good indicator of when to apply helpers and abstractions/extractions in elm is the amount of indention (which look like horizontal stairs
). I will try to keep them at two or three stairs at most. When you realize you are “climbing the stairs” it’s an indicator the entity has too many responsibilities. If you try to think in smaller functions you will more easily get the right ideas about how to optimize this kind of code…

1 Like

I tried to rewrite it without looking at your solutions. It was too difficult for me to remember how you did it but I ended up with a solution that’s more readable than my previous one.


    module Achievements exposing (Achievements)

    import Dict exposing (Dict)


    type alias Achievements =
        Dict Int (Dict Int (Dict Int (Dict String Int)))


    getDefault : comparable -> a -> Dict comparable a -> a
    getDefault key defaultValue dict =
        case Dict.get key dict of
            Just val ->
                val

            Nothing ->
                defaultValue


    increaseCount : Int -> Int -> Int -> Int -> String -> Achievements -> Achievements
    increaseCount increment year month day name achievements =
        let
            yearDict =
                getDefault year Dict.empty achievements

            monthDict =
                getDefault month Dict.empty yearDict

            dayDict =
                getDefault day Dict.empty monthDict

            countValue =
                getDefault name 0 dayDict

            newCount =
                countValue + increment

            newDayDict =
                Dict.insert name newCount dayDict

            newMonthDict =
                Dict.insert day newDayDict monthDict

            newYearDict =
                Dict.insert month newMonthDict yearDict
        in
        Dict.insert year newYearDict achievements
3 Likes