Pre-SIP: a syntax for aggregate literals

I’d like to write down a few more thoughts about this that have crossed my mind recently. I’ve come to the conclusion that it’s probably overambitious to try and cover the wide range of problems that have been discussed with a single language feature. Instead, we should consider several smaller changes and extensions.

FP fundamentals should just work

As a functional language, Scala should be able to express typical FP idioms as elegantly and concisely and possible. By this I mean code like the following: a prototypical implementation of map:

  extension[A, B](l: List[A]) def map(f: A => B): List[B] =
    l match
      case head :: next => f(head) :: l.map(f)
      case Nil => Nil

(Let’s ignore for now that List already has map and that this is not stack safe). This is the nice, idiomatic functional code that the language is intended for and that we want people to be able to write.

This code works today because

  1. Nil and :: aren’t scoped inside List like they would be if an enum had been used, which would be the idiomatic way to declare types like this
  2. List has a method called :: defined on it, and there’s a weird syntax rule that says that some identifiers when used in infix are looked up on the right operand rather than the left one

Neither of these would be the case if we had just declared List like so:

enum List[+A]:
  case Nil
  case ::(head: A, next: List[A])

And I feel strongly that the above implementation of map should just work without any ceremony. To me, any uglification like List.:: @:: or ..:: (ew!) is an unacceptable step backwards, and so would be importing Nil and ::.

It follows that we need new name lookup rules for at least some identifiers. When matching against an enum type, its cases must automatically be in scope without imports or the like, and in a position where an enum is expected, its cases should also be in scope. And for binary symbolic cases, it should also be possible to apply them using infix syntax.
The same thing is true when matching against a sealed type: the derived case classes and case objects should be in scope both for matching and construction. In fact, I recently changed a bunch of code from sealed trait (with derived types declared in the same scope, not in the companion object) to enum, and while this did improve the declaration of those types, it made using them much less pleasant.

The need to define a separate :: method to construct these things is also a wart, and it requires a weird syntax rule to even work. Maybe we can say that in places where an enum type is expected, the enum cases with a symbolic name (e. g. ::) can be applied with infix syntax?

Too much of a good thing?

Assuming that we can agree on the above, it’s easy to jump to the conclusion that everything should be looked up in the companion object scope when the companion object is known. This is tempting because it makes some things very easy. Want to make a LocalDate? Easy, just type of(…) and you’re done!
But many types’ companion objects declare dozens or even hundreds of methods, and that could easily lead to an unacceptable level of namespace pollution.

OTOH, I still feel that we should have some way to make the companion object more accessible, and I think a good compromise is to allow unqualified lookup for enum case/case class/case object symbols while requiring explicit syntax like .. for all other symbols in the companion object scope.
..of(1958, 9, 5) is a reasonable syntax to make a LocalDate, and at the same time there’s a visual clue that some special name lookup is going on.

The apply method

As a special case, apply is used more often than any other name for “factory” functions, for the obvious reason that it is treated specially in various ways by the language. A syntax like ..apply(1,2,3) would defeat the purpose. It was suggested that we might abbreviate this to ..(1,2,3). But when I look at @Ichoran’s example from earlier…

    ..(II, ..(..("rrf-3", ..("b", 26))),
    ..(IV, ..(..("fem-1", ..("hc", 17))),

… then I can’t help but feel that it looks excruciatingly ugly, and it’s the dots that are bothering me. I really think that

    [II, [["rrf-3", ["b", 26]]],
    [IV, [["fem-1", ["hc", 17]]],

looks much cleaner. Regarding multiple parameter lists and using clauses, I think it’s fairly simple to solve: [stuff] is really just a syntax for ..apply(stuff), and so if you need more parameter lists or using clauses, you just do this:
[stuff](bla)(using blub), which would desugar to ..apply(stuff)(bla)(using blub). [stuff][A] would be desguar to ..apply(stuff).apply[A].

In addition to the motivation that we already discussed (e. g. k8s objects), I ran into another case recently where this kind of syntax just makes sense, and it’s the zio-aws project. Basically all methods in this project look something like this:

  def createBucket(
      request: CreateBucketRequest
  ): IO[AwsError, zio.aws.s3.model.CreateBucketResponse.ReadOnly]

There are hundreds (or even thousands?) of these, and of course everybody and their dog is using AWS these days, so a simpler syntax to create those requests really would make the language feel a lot more light-weight for a large number of people. What would you rather read and write: s3.createBucket(CreateBucketRequest(bucket = BucketName("foo"))) or s3.createBucket([bucket = ["foo"]])? I know which one I’d pick, and it’s not the first one!

Summary

My current feeling is that we’ve been trying to cram too much functionality in one feature, so I would propose four separate ones:

  1. In expressions where an enum or sealed type is expected, the relevant case/case class/case object identifiers can be used unqualified. The same applies when matching an enum or sealed type: case/case class/case object names can be used unqualified
  2. Symbolic binary case/case class names can be written with infix syntax.
  3. ..foo (or @foo or whatever else we agree on) is a syntax to modify name lookup to occur in the companion object of the expected type.
  4. [stuff] is syntactic sugar for ..apply(stuff)
3 Likes