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
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
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 case
s, 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 class
es and case object
s 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:
- 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
- Symbolic binary
case
/case class
names can be written with infix syntax.
..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.
[stuff]
is syntactic sugar for ..apply(stuff)