jfmengels / elm-review-performance / NoUnoptimizedRecursion

rule : Configuration -> Review.Rule.Rule

Reports recursive functions that are not tail-call optimized.

Configuration


type 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.

optOutWithComment : String -> Configuration

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.

optInWithComment : String -> Configuration

Reports only the functions tagged with a comment.

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

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

fun n =
    -- ENSURE 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 "ENSURE 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 apply tail-call optimization only when a recursive call (1) is a simple function application and (2) is the last operation that the function does in a branch.

(1) means that while recurse n = recurse (n - 1) would be optimized, recurse n = recurse <| n - 1 would not. Even though you may consider <| and |> as syntactic sugar for function calls, the compiler doesn't (at least with regard to TCO).

As for (2), the locations where a recursive call may happen are:

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

The compiler optimizes every recursive call that adheres to the rules above, and simply doesn't optimize the other branches which would call the function naively and add to the stack frame. It is therefore possible to have partially tail-call optimized functions.

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 recursive call 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
        factorialHelp (result * n)

and split the function into the one that will do recursive calls (above) and an "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 a pipe 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.