jfmengels/elm-review-performance/1.0.0

NOTE: the latest version is 1.0.2

NoUnoptimizedRecursion

Reports recursive functions that are not tail-call optimized.

Tail-call optimization makes Elm code more performant and helps prevent stack overflows.

Since this optimization is done silently and under specific circumstances, it is unfortunately relatively easy to not notice when the optimization is not being applied. You can find the reasons why a function would not be optimized below.

Configuration

Configuration for NoUnoptimizedRecursion.

Use optOutWithComment or optInWithComment to configure this rule.

You can use comments to tag functions as to be checked or ignored, depending on the configuration option you chose. This comment has to appear on the line after the = that follows the declaration of your function. Note that this comment only needs to contain the tag that you're choosing and that it is case-sensitive. The same will apply for functions defined in a let expression, since they can be tail-call optimized as well.

Reports recursive functions by default, opt out functions tagged with a comment.

config =
    [ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optOutWithComment "IGNORE TCO")
    ]

With the configuration above, the following function would not be reported.

fun n =
    -- elm-review: IGNORE TCO
    if condition n then
        fun n * n


    else
        n

The reasons for allowing to opt-out is because sometimes recursive functions are simply not translatable to tail-call optimized ones, for instance the ones that need to recurse over multiple elements (fun left + fun right).

I recommend to not default to ignoring a reported issue, and instead to discuss with your colleagues how to best solve the error when you encounter it or when you see them ignore an error.

I recommend to use this configuration option as your permanent configuration once you have fixed or opted-out of every function.

Reports only the functions tagged with a comment.

config =
    [ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optInWithComment "CHECK TCO")
    ]

With the configuration above, the following function would be reported.

fun n =
    -- CHECK TCO
    if condition n then
        fun n * n


    else
        n

When (not) to enable this rule

This rule is useful for both application maintainers and package authors to detect locations where performance could be improved and where stack overflows can happen.

You should not enable this rule if you currently do not want to invest your time into thinking about performance.

Try it out

You can try this rule out by running the following command:

elm-review --template jfmengels/elm-review-performance/example --rules NoUnoptimizedRecursion

The rule uses optOutWithComment "IGNORE TCO" as its configuration.

Success

This function won't be reported because it is tail-call optimized.

fun n =
    if condition n then
        fun (n - 1)


    else
        n

This function won't be reported because it has been tagged as ignored.

-- With opt-out configuration
config =
    [ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optOutWithComment "IGNORE TCO")
    ]


fun n =
    -- elm-review: IGNORE TCO
    fun n * n

This function won't be reported because it has not been tagged.

-- With opt-in configuration
config =
    [ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optInWithComment "CHECK TCO")
    ]


fun n =
    fun n * n

Fail

To understand when a function would not get tail-call optimized, it is important to understand when it would be optimized.

The Elm compiler is able to do tail-call elimination only when all the recursive calls are the last operation that the function would do in a branch. Any recursive calls happening in other locations de-optimizes the function.

Here are the locations when a recursive call may happen in:

  • branches of an if expression
  • branches of a case expression
  • in the body of a let expression

and only if each of the above appeared at the root of the function or in one of the above locations themselves.

Following is a list of likely situations that will be reported.

An operation is applied on the result of a function call

The result of this function gets multiplied by n, making the recursive call not the last thing to happen in this branch.

factorial : Int -> Int
factorial n =
    if n <= 1 then
        1


    else
        factorial (n - 1) * n

Hint: When you need to apply an operation on the result of a recursive call, what you can do is to add an argument holding the result value and apply the operations on it instead.

factorialHelp : Int -> Int -> Int
factorialHelp n result =
    if n <= 1 then
        result


    else
        factorial (result * n)

and split the function into the one that will do recursive calls (above) and a "API-facing" function which will set the initial result value (below).

factorial : Int -> Int
factorial n =
    factorialHelp n 1

Calls using the |> or <| operators

Even though you may consider these operators as syntactic sugar for function calls, the compiler doesn't and the following won't be optimized. The compiler doesn't special-case these functions and considers them as operators just like (*) in the example above.

fun n =
    if condition n then
        fun <| n - 1


    else
        n
fun n =
    if condition n then
        (n - 1)
            |> fun


    else
        n

The fix here consists of converting the recursive calls to ones that don't use the operator.

Calls appearing in || or && conditions

The following won't be optimized.

isPrefixOf : List a -> List a -> Bool
isPrefixOf prefix list =
    case ( prefix, list ) of
        ( [], _ ) ->
            True


        ( _ :: _, [] ) ->
            False


        ( p :: ps, x :: xs ) ->
            p == x && isPrefixOf ps xs

The fix here is consists of using if expressions instead.

isPrefixOf : List a -> List a -> Bool
isPrefixOf prefix list =
    case ( prefix, list ) of
        ( [], _ ) ->
            True


        ( _ :: _, [] ) ->
            False


        ( p :: ps, x :: xs ) ->
            if p == x then
                isPrefixOf ps xs


            else
                False

Calls from let declarations

Calls from let functions won't be optimized.

fun n =
    let
        funHelp y =
            fun (y - 1)
    in
    funHelp n

Note that recursive let functions can be optimized if they call themselves, but calling the parent function will cause the parent to not be optimized.