NoEtaReducibleLambdas elm-review rule

I published a rule that can be used to enforce varying levels of function simplification
elm-review-reducible-lambdas. The default settings are probably a little more aggressive than most in the community would prefer. However, you may be able to tinker with the settings to find a useful sweet spot for your project and so I decided to share in case anyone finds this useful.

Conservative Settings

This example config will only reduce single argument lambdas that have single letter argument names (thanks to @Arkham for the argument name predicate suggestion).

Error

  • \a -> someFunction a will reduce to someFunction

Not Error

  • \apples -> someFunction apples due to argument name length
  • \a b -> someFunction a b due to multiple arguments.
config : List Rule
config =
    [ NoEtaReducibleLambdas.rule
        { lambdaReduceStrategy = NoEtaReducibleLambdas.OnlyWhenSingleArgument
        , argumentNamePredicate = \argumentName -> String.length argumentName == 1
        }
    ]

More Aggressive

These are the default settings. These may reduce a little too aggressively for most.

Error

  • \apples -> someFunction apples reduces to someFunction
  • \apples bananas -> someFunction apples bananas reduces to someFunction
  • \apples bananas -> someFunction milk apples bananas reduces to someFunction milk
  • \apples bananas -> fn (apples 15) bananas reduces to \apples -> fn (apples 15)

Not Error

  • \bananas -> fn (apples 15) bananas because removing the lambda would change performance attributes by immediately evaluating (apples 15).
config : List Rule
config =
    [ NoEtaReducibleLambdas.rule
        { lambdaReduceStrategy = NoEtaReducibleLambdas.RemoveLambdaWhenNoCallsInApplication
        , argumentNamePredicate = always True
        }
    ]
8 Likes

Hey @john_s!

Very nice work! I know quite a few people are interested in this because I’ve seen asked a number of times whether this existed. Well now it does! :raised_hands:

You mention changing performance attributes, so I just want to mention that even the changes that this rule makes can have some performance impacts.

When they have multiple arguments, Elm functions are wrapped in a FX function (F2, F3, F4, …), and when functions are called with multiple argument, they are called with a AX function (A2, A3, A4, …). What the AX functions do is check whether the function to call is wrapped in a FX function of the same number (A2 and F2, A3 and F3, …). If it’s the case, then the function is called with the original functions like f(a, b, c, ...). If it isn’t, then the function will be called one argument at a time f(a)(b)(c)... until all the arguments are passed, which is noticeably slower for a function that is often called.

@robin.heggelund wrote a very nice series of articles on Elm performance, including one article explaining How Elm functions work, and its following articles explain how they got faster (links to those at the end of this article).

That means that List.map (\apples bananas -> someFunction milk apples bananas) list will be faster than List.map (someFunction milk) list. Of course, always benchmark instead of making a claim, so I wrote a benchmark.

For Elm 0.19.1, performance is faster with the curried version (I was very surprised to be honest, and I don’t know how to explain) by 5%-10%. However, when run with elm-optimize-level-2, the curried version is 3.5 times slower (according to this particular benchmark at least).

Of course, this rule can also have the opposite effect. when you have a function defined as someFunction a = \b -> ..., then having it called like List.map (someFunction 1) list should be more performant than List.map (\a -> someFunction 1 a) list. In this case, the rule would push towards the former version which would be a good thing.

These performance impacts is the reason why it didn’t explore this rule further. But I think that there are some cases where it’s always going to be okay or better (on a performance level, not in terms of personal preferences), like for \apples -> someFunction apples or \apples bananas -> someFunction apples bananas being reduced to someFunction.

I think I would see the value in having a setting for the rule that only reports errors when the performance can be estimated to be at least as good as the current version. I see at least 2 scenarios when it could do that:

  1. When the entire lambda can be reduced to a single variable name, like \apples -> someFunction apples → soimeFunction (that said, this should be benchmarked)
  2. When the rule knows which wrapper the function uses. So if we have someFunction milk = \apples bananas -> ..., then we can simplify \apples bananas -> someFunction milk apples bananas to someFunction milk (and otherwise this should be left untouched). This information can be gathered by looking at the definitions of each function. When the information is missing (code from dependencies, or functions from arguments), then no error should be reported.

What do you think?

PS: What does “Eta” mean?

2 Likes

The reason List.map (someFunction apple) bananas Is faster is because the curried call returns a single-arity function which will be executed directly by List.map, while List.map (\apples banana -> someFunction apples banana) bananas will have to create a curried version of the lambda (with F2, which has some overhead) and then List.map essentially calls two functions when executing (first the lambda through A2, then someFunction through A2).

3 Likes

η-reduction (η pronounced “eta”) is the transformation of \x -> f x to f, conversely η-abstraction or η-expansion is the reverse transformation.

https://wiki.haskell.org/Eta_conversion

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.