This is my view of what a dream scenario for the new syntax in Scala 3 would look like, and why I think it's better than the current syntax implemented in Dotty.
A diff of the EBNF syntax can be found here: https://github.com/lampepfl/dotty/commit/9638ebfeec002554897836b2c97f70e371f26cdd
The main goal in this alternative is to prioritize ease of reading over ease of writing. Code is generally written once, and then read (and edited) over and over again.
My only two remaining grievances with the current syntax is this:
- Giving new meaning to
:
is confusing. It's one of Scala most important symbols that already has two meanings depending on whether it's a context bound or type declaration.- This may not be an issue for a compiler, but definitely for a human reader.
- It basically dictates what all future syntax should look like because it looks weird next to type
:
and context bound:
. - It's in no way self-explanatory, for someone coming from C# or C++ it even looks like
extends
.
- Not requiring a block marker in many locations is confusing. I would like to be able to see just from the first line of a defnintion whether it's a single expression or a block.
I would divide blocks into four different syntactic categories:
- Blocks that smell like template bodies (
where
)- traits
- classes
- singleton objects
- enums
- structural given instances
- anonymous instances
- extension method blocks
- (package objects)
- Blocks that are types (
with
)- type refinements
- Actual block expressions (
do
andlet
..in
)- Bodies of
def
,val
andvar
- Bodies of
then
andelse
- Function argument blocks
- .. and more
- Bodies of
- Expressions that look like blocks but aren't (no keyword required)
- Enumerators in
for
- The body of a
match
- Import selectors
- Export selectors
- other things?
- Enumerators in
It makes sense to consider whether these should be handled differently given that they are different constructs in the language with different semantics.
In short, three new keywords are introduced: where
, let
and in
.
do
and with
are repurposed.
Braces are intended to be optional after:
where
with
do
let
match
for
(if followed bydo
, oryield
)while
(if followed bydo
)
And maybe also after:
try
catch
finally
But not after:
else
then
=
The intention is to instead use do
or let
..in
to explicitly open a block.
Prioritizing speed of writing over readability is in my opinion a big mistake. This is why I'm going to continue to obnoxiusly advocate where
over :
. Despite being a few extra characters to write, the fact that it stands out, and looks different from anything else is a big strength.
I think it's good because:
- It's immediately obvious what it means in context
- It has precidence in other languages where it's used generously
- It doesn't clash with anything else
Some people argue it's too long. I don't think that's a problem, I think it makes the start of a template block stand out in a way which exclusively improve readability. Many template definitions are long anyway, so where
doesn't add much noise there.
enum Location(val inParens: Boolean, val inPattern: Boolean, val inArgs: Boolean) where
case InParens extends Location(true, false, false)
case InArgs extends Location(true, false, true)
case InPattern extends Location(false, true, false)
case InPatternArgs extends Location(false, true, true) // InParens not true, since it might be an alternative
case InBlock extends Location(false, false, false)
case ElseWhere extends Location(false, false, false)
In Haskell where
is used literally everywhere, and though this is by no means an argument in favor of where
in and of itself, it goes a long way to show that this is not unreasonable. While much of Haskells syntax is weird, where
is immidiately clear:
Haskell | Scala |
---|---|
module Demo where
type List a = [a]
class SemiGroup t where
combine :: t -> t -> t
class SemiGroup t => Monoid t where
unit :: t
instance SemiGroup String where
combine x y = x ++ y
instance Monoid String where
unit = ""
instance SemiGroup Int where
combine x y = x + y
instance Monoid Int where
unit = 0
combineAll :: Monoid t => List t -> t
combineAll xs = foldl combine unit xs
main = putStrLn lst where
lst = combineAll ["Hello"," ","World"]
|
object Demo where
trait SemiGroup[T] where
extension (x: T) def combine (y: T): T
trait Monoid[T] extends SemiGroup[T] where
def unit: T
given Monoid[String] where
extension (x: String) where
def combine (y: String): String = x.concat(y)
def unit: String = ""
given Monoid[Int] where
extension (x: Int) where
def combine (y: Int): Int = x + y
def unit: Int = 0
def combineAll[T: Monoid](xs: List[T]): T =
xs.foldLeft(summon[Monoid[T]].unit)(_.combine(_))
def main(args: Array[String]) =
println(combineAll(List("Hello"," ","World"))) |
In the comparison above, you can see that the occurrenses of where
would actually be fewer in Scala. I tried my best to find examples of people disliking or complaining about Haskell's generous use of where
, but could only find suggestions to introduce it even more.
It would be surprising if extension methods wasn't grouped in braces. For the same reason I think it's clearer to include where
:
extension (something: T) where
def foo..
def bar..
For type refinements I find with
makes sense. &
would be completely logical in the spirit of DOT
and given that with
will be removed to mean &
. &
however, doesn't really look good at the end of a line:
def m: Foo &
type T = Int
def m: Foo with
type T = Int
That means these would be equivalent:
class Record(elems: (String, Any)*) extends Selectable {
private val fields = elems.toMap
def selectDynamic(name: String): Any = fields(name)
}
type Person = Record {
val name: String
val age: Int
} |
class Record(elems: (String, Any)*) extends Selectable where
private val fields = elems.toMap
def selectDynamic(name: String): Any = fields(name)
type Person = Record with
val name: String
val age: Int |
For givens this means the following:
A structural instance is written like this:
given righteousDude: Record with Dude where
val name: String = "Frank"
val age: Int = "57"
val righteous: Boolean = true
An abstract instance with a refined type is written like this:
given abstractDude: Record with Dude with
val name: String
val age: Int
An alias instance is written like this:
given aliasDude: Record with Dude = richard
For expressions, I think do
as proposed by @lihaoyi is extremely natural. It's already used in the syntax of for
and when
so it makes complete sense to generalize it.
do expr1
expr2
expr3
or
do
expr1
expr2
expr3
Would be equivalent to {expr1,expr2,expr3}
.
A common objection to this is that it doesn't make sense in pure code. I agree with this, but I've always found blocks a bit weird for the same reason. It's not actually immidiately obvious that {expr1,expr2,expr3}
returns expr3
. It's just something we're all used to. Putting extra emphasis on expr3
would make sense. For this reason I also suggest let..in
:
let expr1
expr2
in expr3
or
let
expr1
expr2
in expr3
or
let
expr1
expr2
in
expr3
Could mean {expr1,expr2,expr3}
. The appeal of this is that it's suddenly immidiately clear that expr3
is returned.
Whether any restrictions should be put on the expressions in do
or let..in
is not extremely important in my opinion. It makes sense to simply let them be stand in for { block }
expressions.
Maybe some expressions could be blocks by default. Personally I don't see the point. What makes braces break flow when writing code is that they have to be inserted in two places.
If you have to change this:
def m(x: Foo): Bar = expr1
into this:
def m(x: Foo): Bar = do
println(x)
expr1
the cost of typing a do<enter>println(x)<enter>
to typing <enter>println(x)<enter>
is negligible.
The benefit of requireing do
is that I know to expect one or more lines just from seeing do
. This in my opinion improves readability immensly.
This argument also holds for all control syntax I can think of, but that's a matter of opinion:
try do
: On one hand, why not. On the other, it's usually a block so it makes sense to allow onlytry
finally do
: Same thing- what else
The blocks in match
or catch
, import
and export
aren't actually real expressins. The same applies to the enumerators (but not the body) in for
.
These are already covered by the new quitet control syntax in Scala 3.
It's hard to do anything about this syntax:
xs.map {x => f(x)}
Personally, I don't mind it. Few languages allow anonymous lambdas to be passed as arguments without parenthesis. I also don't see the benefit of writing this instead:
xs.map(x => f(x))
Theoretically, if one wanted to embrace keywords even more, this is possible:
xs.map lambda x => f(x)
I don't think it's justified.
Would it be possible to simply allow this:
xs.map x => f(x)
It also makes sense if the syntax allowed for braces to actually be optional in the sense that they can be inserted without changing anything else. This way braces could even be inferred
in IDEs the same way the names of method arguments can be inferred.
Braces can be inserted without changing anything else:
this is equivalent to this
where where {
<body> <body>;
}
with with {
<refinement> <refinement>;
}
do do {
<exprs> <exprs>;
}
let let {
<exprs> <exprs>
in }
<expr> in <expr>
Some may feel like three new keywords where
and let..in
is excessive. My personal opinion is that this is minor compared to the significance of making braces optional.
I also understand that given all the work which went into :
, it might be too expsensive to remove all occurences of it. A compromise could be to allow :
in place of where
in some occurances, possibly with the long term goal of deprecating it and then removing it.
For example, :
could be allowed as shorthand for where in
- traits
- classes
- singleton objects
- enums
but not in
- structural given instances
- anonymous instances
- type refinements
Having two ways to write one thing may sound horrible, but I think that would be a reasonable compromise given the circumstances.