5
$\begingroup$

When defining an inline function, even the shortest way to do so usually requires naming its argument (unless you're going for the point-free style and you have an expression that returns a function).

// JavaScript
arr.map(x => x + 1)

// Rust
iter.map(|x| x + 1)

// Haskell
map (\x -> x + 1) arr

In maths, this is called “arrow notation”, e.g. $x \mapsto x + 1$.

This is already short and sweet, especially when you compare it to things like function (x) { return x + 1 }. But can we do better?

An arguably less common way of defining functions in maths is using the “dot notation”, which looks like this: $(\;\cdot\;) + 1$. Interpunct (the middle dot) is a common choice, but it's not a necessary one -- any symbol used rarely enough would work. The idea is to avoid giving any specific “common” name to a function argument, and to instead pick an esoteric one and stick with it.

So I thought why not try this in a language.

arr.map(# + 1)

Researching, I've found out that Scala supports exactly this using _, and that Kotlin has something very similar using a reserved variable name it.

// Scala
list.map(_ + 1)
// Kotlin
list.map { it + 1 }

A glaring question that comes up is how do you know how far out the implicit lambda reaches, i.e. do the examples above desugar into x -> list.map(x + 1) or list.map(x -> x + 1).

In Scala, desugaring happens up the syntax tree until the first pair of parentheses () is reached. In our case, that's the parenthesis for the function call, so the above snippet is what we intuitively expect.

In Kotlin, {} is used in general to denote lambdas. For example, you could rewrite the above as list.map { x -> x + 1 }. I'm not sure on the details, but apparently you can avoid writing the usual () parenthesis for function call if a lambda is the only argument to a function. So the full syntax sugar chain is list.map({ x -> x + 1 }) as list.map { x -> x + 1 } as list.map { it + 1 }. So the scope is determined just like always: using {}.

This shows two ways to determine the reach of an implicit lambda.

Scala becomes tricky as soon as you have a more complex expression, where you want to use parentheses for grouping. Something as simple as x -> 2 * (x + 1) cannot be expressed using 2 * (_ + 1) because it would inwind into 2 * (x -> x + 1), not x -> 2 * (x + 1). Kotlin's approach seems better as there's a pre-established delimiter pair so this kind of thing doesn't happen. This also plays nicely with tiny additional shortcuts you can take like collapsing ({}) into just {}. The price is the additional {} you have to introduce everywhere, so instead of val f = x -> x + 1, you need val f = { x -> x + 1 } (which is not necessarily bad, but since we're talking about a first world problem anyway, it's worth pointing it out as a con).

What are other possible ways to determine the reach of a lambda expression, found in other programming languages (including non-mainstream ones)? What are their pros and cons?

$\endgroup$
4
  • $\begingroup$ Does this come down to: given a variable in an expression, assume this was introduced by a lambda such that the expression is in the body of the lambda expression - where would that lambda be? If so, I think this is a unique concept because we are used to the other way round: have constructs that introduce a binding and ask for its scope. I expect this to create challenging ergonomics. $\endgroup$ Dec 6, 2023 at 10:48
  • 2
    $\begingroup$ @ChristianLindig Arguably, Kotlin's approach works the way you expect: {...} without -> introduces a binding of it, which is then in scope within the braces. $\endgroup$
    – kaya3
    Dec 6, 2023 at 14:37
  • 1
    $\begingroup$ Not the question, but might be helpful to think together: nested lambdas all using the special character(s)/token(s), as in a.map(_ + sum(b.map(_ + 42))). $\endgroup$
    – Pablo H
    Dec 6, 2023 at 14:56
  • 1
    $\begingroup$ I'm not 100% on the grammatical details, but Mathematica uses a trailing & to close off that sort of inline lambda, for another data point. I think you could set operator precedence up so that a unary operator (suffix, in that case) had low precedence and would thus "grab" the right chunk of expression, a la 2 * (_ + 1)& if the unary is lower precedence than the * you make the whole expression your lambda $\endgroup$
    – Alex K
    Dec 7, 2023 at 17:41

1 Answer 1

2
$\begingroup$

Here's one: integrate the parser and type-checker so that only token trees (lexed tokens and parentheses groups) are initially parsed, then the compiler further parses outer expressions and type-checks them before parsing inner expressions.

When an expression gets parsed to a function call, the compiler will type-check the function and, whichever arguments are supposed to be closures, will be parsed as implicit closure expressions unless they are explicit (either start with x -> or identifiers which resolve to lambda expressions). When an expression gets parsed to the assignment, if the assigned-to identifier is a variable, the assigned value will be parsed as an implicit closure unless it's explicit.

Pros: this gives you maximum flexibility: you can create expressions like this, which aren't otherwise possible:

functionWhichTakesClosure(functionWhichTakesRegularArgument(_))
// Desugars to
functionWhichTakesClosure(x -> functionWhichTakesRegularArgument(x))


functionWhichTakesRegularArgument(functionWhichTakesClosure(_))
// Desugars to
functionWhichTakesRegularArgument(functionWhichTakesClosure(x -> x))

However, I'd say this is a bad idea. Because it creates a situation where the compiler is smarter than the developer, so that it generates code when it should throw a type error. What if someone changes functionWhichTakesRegularArgument so that it actually does take a closure (you know that in practice, functions won't have these obvious names)? What if functionWhichTakesClosure no longer takes a closure, the error message "_ expression but none of the outer calls take a closure" isn't very helpful (or if one of the outer calls do take a closure, that will be even less helpful). Moreover, there are still ambiguities: what if functionWhichTakesRegularArgument takes a generic parameter, will it be inferred to a closure? You can resolve them (like say, generic types which can't be inferred will never be parsed as implicit closure, or just simplify so that even generics which do resolve to closure can't be parsed implicitly), but that only creates more complexity and confusion.

In this case, Scala's and Kotlin's simple solution (just desugar the inner-most parenthesis or braces into an implicit closure) is the best. Because it doesn't require type knowledge to understand; when the developer does mess up and pass an inner closure, they are breaking a simple rule, which is a lot easier to spot than breaking a more complicated one.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .