Pre-SIP: `let` expressions in Scala

Would let expressions be a good fit for Scala? I haven’t found evidence for this being proposed already, which is surprising. If it has been, let me know!

Full description, and a limited implementation (as a compiler plugin) are here: https://github.com/jeremyrsmith/let-plugin

TLDR - Allow these:

val result = let (x = expr1(), y = expr2()) in resultExpr(x, y)

val result = let {
  x = expr1()
  y = expr2()
} in resultExpr(x, y)

A minimal motivating example (excerpted from above link):


def wizzle(a: Int, b: Int) = {
  val temp = foo(a, b)
  bar(temp, baz(temp))
}

We had to pass the result of foo to two different functions, so we assigned it to a value. This is perfectly fine and great.

Except, the body of wizzle is no longer an expression, syntactically speaking. It’s now a Block (in Scala AST terms), which is a list of “stats” and a result expression. The fact that it isn’t syntactically an expression can be witnessed by the fact that the curly braces surrounding it cannot be removed.

It’s better[citation needed?] to use expressions everywhere. Certainly from a functional programming standpoint, if everything is an expression you can be reasonably assured of purity (until a method is called which is not an expression).

However, using a val doesn’t mean it isn’t semantically an expression, because it can be trivially transformed into one - even with existing Scala syntax:

def wizzle(a: Int, b: Int) = foo(a, b) match {
  case temp => bar(temp, baz(temp))
}

Here, the braces have been eliminated, and we know we have an expression from a syntactic perspective.

But this misappropriation of match is misleading - we aren’t doing any pattern matching; only naming the result of foo so that it can be referenced multiple times in a nested expression.

This is why a let expression would be great:

def wizzle(a: Int, b: Int) = let (temp = foo(a, b)) in bar(temp, baz(temp))

All three versions of wizzle are semantically equivalent. But the third example, which is what this plugin enables, allows wizzle to be specified as an expression without a confusing use (misuse?) of match.


Some arguments against:

  • No more keywords, please
  • No more syntax sugar, please
  • Using vals is clearer anyway. People don’t care about syntactic expression-ness.

Hello,

If you really really want your let, how about:

def let[A, B](a: A)(f: A => B): B = f(a)

def wizzle(a: Int, b: Int) = let(foo(a, b))(temp => bar(temp, baz(temp)))
But why? Purity just for the sake of purity? What’s wrong with blocks,
aren’t they a kind of expression?

Best, Oliver

There’s nothing wrong with blocks. They’re fine. I think that let could lead to more readable and often more concise code, is all. Of course, “readable” is in the eye of the reader, and maybe I’m alone in that opinion!

The idea you posted isn’t really a substitute. It dissociates the bound expression from the bound name, which would be less clear and less readable than just using val (and again, there’s nothing wrong with val).

In general, it’s better to have only one way to do things, if all else is equal. In your examples (modified for the variable names to be needed), compare:

val result = { val x = expr1(); val y = expr(2); resultExpr(x, y, x, y) }
val result = let (x = expr1(); y = expr2()) in resultExpr(x, y, x, y)

Yes, maybe it is slightly nicer in these cases, but it’s really close.

And the wizzle example, with forward pipes (as “pipe”, to make it clunky) as comparison:

def wizzle(a: Int, b: Int) = { val temp = foo(a, b); bar(temp, baz(temp)) }
def wizzle(a: Int, b: Int) = foo(a, b).pipe(temp => bar(temp, baz(temp))
def wizzle(a: Int, b: Int) = let (temp = foo(a, b)) in bar(temp, baz(temp))

Now we’re actually worse off than a common status quo, and exactly the same as the block method.

So I don’t think this feature would pull its weight. In some other language, this could be the way to do things (it is in OpenSCAD for instance!), but I don’t think it’s needed in Scala given what the language already offers.

5 Likes

Again, I get that “better” and “worse” are subjective.

let might not always be the absolute shortest, but of the three wizzles you gave - all approximately the same length - it is the most readable (to me, at least). pipe is the worst, despite being the shortest. The block is not particularly easy to read, but mainly because it’s artificially on one line.

So I’d argue that all else isn’t equal, and there is a benefit. Just like for has a benefit (syntactically, at least) over flatMap/map/filter despite all the issues around it.

The val version seems fine to me. I don’t see a need here.

5 Likes

I don’t have a strong opinion, although I do see the attraction of being able to write everything in a single expression instead of a block, but I also agree with most of the points against. And not adding extra syntax to enable doing the same thing in two different ways probably outweighs the benefits…
However if one were to add this feature I would prefer the syntax let {x = expr1(); y = expr2()} in resultExpr(x, y) (i.e. curly braces and (inferred) semicolons for multiple definitions at once) to be more in line with other Scala syntax.

Fair enough. Thanks for reading!

I think there’s actually something very wrong with blocks: they don’t allow shadowing. For me, this has been a constant pain in the neck. In OCaml, I can do:

let name = get_name() in
let name = capitalize name in
...

In Scala, I’m always frustrated by the need to continually invent new names because values are recursive by default:

val name = getName()
// val name = name.toUpperCase // error: recursive value 'name' needs type
val name2 = name.toUpperCase
...

This is error-prone (it’s easy to pick the wrong version of a variable), and requires useless work from the programmer. What if I want to try the program without capitalizing names? In the OCaml version, I just comment the shadowing definition. In Scala, I have to also change all the usages of name2 to name, or introduce a silly val name2 = name binding.

I’ve even found myself using mutable variables in Scala in pipeline-like functions just because I didn’t want to deal with the fresh-names problem –– but that often doesn’t work, like when the type changes or is refined by the new binding.


However, we do have an idiomatic alternative to blocks in Scala: for comprehensions. We only need to relax the rules related to them (I believe there were such proposals floating around at some point).
I’d like for comprehensions to shadow = bindings and not just <- bindings, and I’d like to be able to write them even when there isn’t any monadic effect going on, just as an alternative binding structure:

def foobar = for {
  name = getName()
  name = name.toUpperCase
} yield ...

def wizzle(a: Int, b: Int) =
  for { temp = foo(a, b) } yield bar(temp, baz(temp))

val result = for { x = expr1(); y = expr2() } yield resultExpr(x, y)

I could see that becoming the preferred way of doing functional programming in Scala, notably because it’s a more natural (less imperative) syntax where you can’t discard return values implicitly.

3 Likes

Well, let is basically equivalent to a for without any generators.

Obviously I’m biased because I’m the one floating let, but I think I’d prefer it to using for in this way because it makes it immediately obvious that there’s no flatMap, map, or filter involved.

I was just about to make the same point: if you want to have this syntax, we already have the language feature for it: for. We just need to allow it to generalize a little bit. (Or, rather, to avoid the existing somewhat surprising restrictions.)

Then the desugaring of for {x = foo... would just be { val x = foo; ... as opposed to the weird secretly-tupling-but-only-inside-a-closure thing it is now.

1 Like
let (temp = foo(a, b)) in bar(temp, baz(temp))

for { temp = foo(a, b) } yield bar(temp, baz(temp))

Ignoring the fact that discourse doesn’t highlight let, the former looks better to me. Conflating the two could hurt, as whenever I see for I’d have to pay close attention to understand whether it’s monadic or not.

I guess I don’t see the downside of let (compared to co-opting for), given how trivial the implementation is (63 lines of code, much of it compiler plugin boilerplate). I don’t think it’s even necessary to reserve let or in. OTOH, if the argument is “it’s not worthwhile to do either of these” then I understand that.

Edit: let’s try

let (temp = foo(a, b)) in bar(temp, baz(temp))
for { temp = foo(a, b) } yield bar(temp, baz(temp))

for a fair aesthetic comparison :slight_smile:

The ticket of record on allowing for (a = ...; is https://github.com/scala/bug/issues/907, I’m still in favor nine years later :slight_smile:

2 Likes

The downside is you have more stuff to learn.

You already have to learn how for works, including how x = foo() works and how yield works. You really don’t have anything extra to learn if you admit for (x = foo) yield bar(x, f(x)), at least if you’re going to really understand for.

Also, you definitely need a reserved word:

class In() { def in[A](value: A) = value }
def let[A](x: A): In = new In()

This collides with your proposed syntax in the case where you try to assign to the variable x.

I’m not sure this is a good idea at all given that there are ways to do it and every extra way you add makes understanding code more difficult (because you have to keep every possible way in mind); but if this is going to be allowed it definitely should be for and not let.

1 Like

I’ll take issue with your use of “should definitely”, but I’ll yield (no pun intended) to your counter arguments. If it’s already on the table for for to be able to do essentially the same thing, I’d have a hard time arguing for a new construct.

FWIW, I also take issue with the idea that “more different things make code harder to understand.” If different things use the same syntax, that’s harder to understand - your brain must essentially perform look-ahead and pay close attention in order to know which one of the things it is.

If I’m scanning some code and I see let, I immediately know how to read it (and realistically, so would the majority of people, since it’s been in a wide variety of programming languages for decades, and in math notation since… well, math). If it’s conflated with for, then the word for is no longer enough to tell me as much as it does today; I must “look-ahead” to parse the expression for its meaning.

Wikipedia also makes a pretty good case for me… asserting that let is something that’s “appeared in most functional languages since [LCF]” and describing its place in the lambda calculus.

Anyway, with those closing arguments, I’ll leave it there. Thanks for taking the time to read and discuss!

Your scoped let isn’t the mathematical let. The latter exists in Scala and is called val.

I agree that if you’re going to have different features, it’s better to use different syntax for them. The thing is, though, for comprehensions already allow scoped variable binding via assignment! It is just restricted to not-be-first.

Anyway, my point wasn’t that more different things make the code harder to understand, it’s that if there are many ways to do the same thing it’s harder to understand. You have to maintain big tables of equivalence in your head, which is doable if you’re working on it all the time, but it raises the entry barrier (and re-entry barrier), etc…

1 Like