Skip to content

Instantly share code, notes, and snippets.

@mbloms
Last active December 10, 2020 18:36
Show Gist options
  • Save mbloms/0d646524143450aeee5752144d637fb4 to your computer and use it in GitHub Desktop.
Save mbloms/0d646524143450aeee5752144d637fb4 to your computer and use it in GitHub Desktop.
My take on brace-less syntax for Scala 3

An alternative Optional Braces syntax for Scala 3

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:

  1. Blocks that smell like template bodies (where)
    • traits
    • classes
    • singleton objects
    • enums
    • structural given instances
    • anonymous instances
    • extension method blocks
    • (package objects)
  2. Blocks that are types (with)
    • type refinements
  3. Actual block expressions (do and let..in)
    • Bodies of def, val and var
    • Bodies of then and else
    • Function argument blocks
    • .. and more
  4. 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?

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 by do, or yield)
  • while (if followed by do)

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.

Blocks that smell like template bodies

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.

Extension methods

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..

Blocks that are types

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

Blocks that are actual expressions

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.

Block expressions by default

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 only try
  • finally do: Same thing
  • what else

Expressions that look like blocks but aren't

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.

Lambda expressions

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)

Optional Braces

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>

Concessions

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment