Pre-SIP: Allow single-line lambdas after `:`

Summary

This proposal is to allow a lambda expression following a : on the same line.
Currently, we need a newline and indent after the arrow, e.g.

xs.map: x =>
  x + 1

We propose to also allow to write the lambda on a single line:

xs.map: x => x + 1

The lambda extends in this case to the end of the line.

History

This feature has been demanded repeatedly since the colon-lambda syntax was introduced as part of SIP 44, for instance see a recent thread in Scala Users. The original SIP 44 did not include it, because the concern at the time was the feature as a whole would look too much like type ascription and single line lambdas after colon would make that worse. But the experience since SIP 44 shipped has shown that the concerns about confusion with type ascriptions were largely overblown. So we now come back to the issue in a separate SIP.

Motivation

The new behavior is more general and more intuitive. We can now state that a : means application if it is followed by an indented block or by a lambda.

The new behavior also makes refactoring easier. One often splits or combines lines when some code part changes in length. We can now do this for lambda arguments without having to switch between parentheses and :.

Other Examples

The syntax works for all kinds of function literals. They can start with one or more parameters, or with type parameters, or they can be partial functions starting
with case.

Seq((1, 2), (3, 4)).map: (a, b) => a + b

Seq((1, 2), (3, 4)).map: (a: Int, b: Int) => a + b

Seq((1, 2), (3, 4)).collect: case (a, b) if b > 2 = a

(1, true).map: [T] => (x: T) => List(x)

Detailed Spec

A : means application if its is followed by one of the following:

  1. a line end and an indented block,
  2. a parameter section, followed by =>, a line end and an indented block,
  3. a parameter section, followed by => and an expression on a single line,
  4. a case clause, representing a single-case partial function.

(1) and (2) is the status quo, (3) and (4) are new.

Restriction: (3) and (4) do not apply in code that is immediately enclosed in parentheses (without being more closely enclosed in braces or indentation). This is to avoid an ambiguity with type ascription. For instance,

(
  x: Int => Int
)

still means type ascription, no interpretation as function application is attempted.

Compatibility

Because of the restriction mentioned above, the new scheme is fully compatible with
existing code. This is because type ascriptions with function types are currently only allowed when they are enclosed in parentheses.

The scheme is also already compatible with SIP-XX - No-Curly Partial Functions and Matches since it allows case clauses after :, so single case clauses can appear syntactically in all contexts where lambdas can appear. In fact, one could envisage to merge the two SIPs into one.

Implementation

An implementation of the new rules supports this SIP as well as SIP-XX - No-Curly Partial Functions and Matches. The new behavior is enabled by a language import language.experimental.relaxedLambdas.

The implementation is quite straightforward. It does require a rich model of interaction between lexer and parser, but that model is already in place to support other constructs. The model is as follows:

In the Scala compiler, the lexer produces a stream of tokens that the parser consumes. The lexer can be seen as a pushdown automaton that maintains a stack of regions that record the environment of the current lexeme: whether it is enclosed in parentheses, brackets or braces, whether it is an indented block, or whether it is in the pattern of a case clause. There is a backchannel of information from parser to scanner where the parser can push a region on the stack.

With the new scheme we need to enter a “single-line-lambda” region after a :, provided the : is followed by something that looks like a parameter section and a =>. Testing this condition can involve unlimited lookahead when a pair of matching parentheses enclosing a parameter section needs to be identified. If the test is positive, the parser instructs the lexer to create a new region representing a single line lambda. The region ends at the end of the line.

Syntax Changes

ColonArgument  ::=  colon [LambdaStart]
                    indent (CaseClauses | Block) outdent
                 |  colon LambdaStart expr ENDlambda
                 |  colon ExprCaseClause
LambdaStart    ::=  FunParams (‘=>’ | ‘?=>’)
                 |  TypTypeParamClause ‘=>’```

The second and third alternatives of ColonArgument are new, the rest is as before.

Notes:

  • Lexer inserts ENDlambda at the next EOL, before producing a NEWLINE.
  • The case does not apply if the directly enclosing region is bounded by parentheses ( … ).
11 Likes

I want to preface this by saying I’m a fan of indentation/fewer-brace syntax. Having said that, I thought : means that we’re starting a new block / scope:

Seq((1, 2), (3, 4)).map: (a, b) =>
  a + b

This is consistent with other built-in usages of blocks like:

object A:
  val a = 1

If someone doesn’t want to start a new block, then they should use normal function-call syntax:

xs.map(x => x + 1)
  .foldLeft(0)((x, y) => x + y)

The linked Scala Users forum also cites foldLeft to be a motivating example, but ironically using : as application won’t work because foldLeft takes two parameter lists. If we’re saying :-is-application, then the following should work?

// I'm not advocating for this
xs.map: x => x + 1: foldLeft: 0: (x, y) => x + y

(3) and (4) do not apply in code that is immediately enclosed in parentheses (without being more closely enclosed in braces or indentation).

Normally parenthesis can only affect the precedence of operations like (1 + 2) * 3 to aid parsing, but here we’re saying that putting an expression in parenthesis would prioritize type ascription over method application. This feels ad-hoc, and I’m not sure if the juice is worth the contortion.

6 Likes

No, it shouldn’t, because it doesn’t fall into any of the four cases.

But if we had, say, foldRL(r: R => A)(l: L => A) on Either[L, R] then

val e: Either[String, Int] = Right(5)
e.foldRL: i => i+1
  : s => s.length

Seems like it ought to work by the rules. However, arguably

e.foldRL
  : i => i + 1
  : s => s.length

is a pretty okay way to express what’s going on. So…maybe it’s actually fine with a tweak. (The former one with one separated out and the other not seems less okay)

We also have to understand what to do about things with a lambda in an earlier block.

def example(s: String)(f: String => Int)(i: Int) = f(s) + i

// Works, gives 5
example("eel"){ s => s.length }
  (2)

// Works
example("eel"){ s =>
    s.length }
  (2)

// Doesn't work
example("eel"): s =>
    s.length
  (2)

// Should this work???
example("eel"): s => s.length
  (2)

Generally we can substitute the lambda expression x => x + 1 with _ + 1 and x => f(x) with f.

If I understand the detailed spec correctly, the first forms would work but not the substitutions. Is this intended?

Yes. Allowing the other forms would cause syntactic ambiguities.

No that is not covered by the rules. But here is a way to write the same:

xs.map: x => x + 1
  .foldLeft(0): (x, y) => x + y

That currently does not work either since a : has to immediately follow an ident or closing parenthesis, a newline is not allowed here. But it would probably just take a small tweak to change this. Nevertheless it’s a different topic that should be evaluated separately. I don’t think it should be part of this SIP.

3 Likes

To make this into one line, naturally, someone would try to write with parentheses, and probably get a compiler error saying x is not found:

// BAD?
(xs.map: x => x + 1).foldLeft(0): (x, y) => x + y
         ^
         Not found: type x

because expressions directly inside the parentheses would be interpreted as type ascription, thus it’s equivalent to:

(xs.map: Function1[x, +[x, 1]]).foldLeft(0): (x, y) => x + y

If we really want to do this, I suggest we deprecate => as a type-level sugar for Function1, and not do the paren special rule.

1 Like

Well that would not work for sure since it is in (...).

I believe we just need to emphasize that lambdas after colon go to the end of the line. There’s plenty of examples in other languages that do this (e.g. Ruby or Swift).

1 Like

Whatever else you do, please don’t do that! I’d much prefer to write a few parentheses here and there than lose A => B as a valid type.

7 Likes

I hope you meant that as a long and polite way of saying “please don’t do this”. :sweat_smile:

1 Like

I find the stated motivation for this change unconvincing and I also think that it’s almost always easier to read when the body of a lambda starts in a new line. The exception would be very short lambdas like x > 0, but parens are fine for those.

2 Likes

Thanks for this comment. I didn’t realize that I was looking at a trailing lambda proposal. Here are some reference material for others. Most of them seem to use { ... } to pass in lambda outside of parens, as if it’s a control construct. This is something Scala 2.x could already do. Indentation syntax complicates the situation since we don’t have the end marker.

Ruby’s block argument

Block argument.

The block argument is always last when sending a message to a method. A block is sent to a method using do ... end or { ... } :

my_method do
  # ...
end

my_method {
  # ...
}

my_method do |argument1, argument2|
  # ...
end

Swift’s trailing closures

Training Closure.

// plain Swift function call that takes a lambda
someFunction(closure: {
    // closure's body goes here
})

// With a trailing closure
someFunction() {
    // trailing closure's body goes here
}

// You can pass in multiple trailing closures in Swift.
// In the following onFailure is a param name
loadPicture(from: someServer) { picture in
    someView.currentPicture = picture
} onFailure: {
    print("Couldn't download the next picture.")
}

Kotlin’s training lambda

Passing trailing lambdas.

// if the last parameter of a function is a function, then a lambda expression
// passed as the corresponding argument can be placed outside the parentheses
val product = items.fold(1) { acc, e -> acc * e }

idk what others think but this is a bit of a mind bender (from the implementation PR):

val d2: String = xs
  .map: x => x.toString + xs.dropWhile: y => y > 0

i.e. two lambdas on the same line separated with + operator,

i guess the precedence rules explaination would be that everything to the right of : ... => is nested

2 Likes

Oh…ugh…that looks way worse than any infix notation anything I’ve seen.

Maybe we want to not commit to pushing lambdas into a one-line stack?

3 Likes

I think we can forbid nested single-line lambdas. We’ll just have to say that single-line lambdas are not recognized in regions bounded by parentheses or by another single-line lambda.

So the proposal is to add a new syntax for the sole purpose of avoiding a single newline character, and then immediately put some arbitrary constraints on it (no nesting) to prevent it from making the code unreadable?

You already made it clear that you don’t like the feature. There’s no need for piling on with more hyperbole in a forum like this.

1 Like

about the double nested, Haskell also has the $ operator which makes everything to the right nested, although not seen it used with literal lambda syntax

What I’ve written is not hyperbolic at all but 100% factual: everything enabled by this proposal can also be done by adding one newline character per lambda, and you did propose placing additional restrictions on it in the name of readability.

Having a cleaner path for refactoring is an important feature. This one (pre-SIP) is considerably harder to refactor (easy, but it adds up) than are trailing commas, and we have trailing commas.

Furthermore, visual layout can be an important guide to code function. Newlines are one of the most impactful characters. (And the spaces/tabs are technically characters too…)

So it’s entirely consistent with the philosophy of Scala as it is to have it, as long as it doesn’t cause other problems. Figuring out what and whether the problems are enough to outweigh the advantages, or to diminish the value enough to not make it worth it, are what discussions like this are for.

It’s important to recognize the benefits of a proposal if you’re going to argue against it on merit. If one simply dislikes it aesthetically, one can say so, and leave it at that.

(Note also that it is possible for things to be 100% factual in a certain type of literal reading, but carry an awful lot of meaning implicitly.)

3 Likes