Feedback sought: Optional Braces

The frequency that colon-delimited indentation comes up is a red flag that optional braces isn’t complete on it’s own.

I think it would be far worse to let sunken costs be a major factor in shipping something that clearly still needs work. I could be misunderstanding what you wrote, but that’s what it sounds like :man_shrugging:.

I get that it sucks to consider that a major overhaul of the documentation and literature was premature, I really do. That doesn’t change the fact that, even if all this thread results in is changes to the details of how significant indentation works, it’s still all going to need to be revised.

6 Likes

Whatever the final design, a nice property would be a precisely defined (i.e., implemented in tooling) bijection between a braceless program and its braceful equivalent. At least two advantages would be:

  1. For a braceless program, I could immediately show students the structure of how the compiler is interpreting the program. Such a way to see what is really going on is very important for beginners (and perhaps not only for beginners).
  2. An IDE could implement the bijection, so people with a strong preference could make the IDE show them all code in their preferred style. IntelliJ already shows all sorts of optional stuff in grey, such as inferred type annotations.

A bijection is not directly possible because there are many alternative brace placements for a given braceless program. However, we could have a linter/autoformatter that enforces a specific placement of braces in a braceful program; then there could be a bijection between braceless programs and braceful programs that pass the linter.

Hopefully, the linted-braceful => braceless direction of the bijection would really be just dropping braces; then we could truly speak of “braces optional”. The opposite direction would amount to inferring brace placement to preserve semantics.

I feel such precision is desirable, and it is worth investing in a design that makes implementing such a bijection possible.

An anecdote to illustrate its importance: it has been suggested in this thread that the return keyword is optional and of course for the most part it is, but sometimes it isn’t. I have just spent a semester with beginners writing medium-sized Scala programs, and dozens of students have spent a couple of hours each debugging fragments like xs.map { x => ... return foo ... } instead of xs.map { x => ... foo ...}. An experienced Scala developer would never encounter this pitfall because they would not write the return keyword there, so it is difficult for experienced developers to predict what will trip up beginners. The problem in this example is that the “optional” return significantly changes semantics; I want to be sure that optional braces do not have similar unexpected effects.

Thus, I feel it is important for “braces optional” to truly be braces optional in a precisely defined way.

8 Likes

I would guess that it would be better to have this in the compiler, similar to the desugaring the compiler currently offers. That way it’s a canonical mapping, and any IDE could take advantage of it. I don’t know if there’s support for this sort of transformation in the LSP, but it would be handy if there were.

That’s less because of it’s optionality, and more because return is a leaky abstraction. A different analogy I think might be better examples is for-comprehension desugaring, which I’ve found to be really helpful when I need to explain to a developer that’s newer to Scala details like why you can’t mix Future and Option in a for-comprehension, but you can mix List and Vector or List and Option.

I like that (although I think fewer options are better than more options), and as several others have suggested in this thread, if anything, I would lean toward making : optional in all cases.

My current material teaches that we make braces optional by adding a colon in some cases (template declarations), and by indenting in other cases (control flow, method declarations). The distinction feels somewhat arbitrary, and the only way to learn these rules are to memorize them.

Fewer rules and more consistency means faster learning, so I would love it if template declarations did not require a colon. I know that would mean poorly indented code following a declaration would be (wrongly) associated with the declaration, but that’s a problem that’s easy to fix: fix the formatting! Beyond this fix, you can already use braces if you want to format code in non-standard ways, so catering for this audience at the expense of ergonomics for properly formatted code seems unnecessary.

Personally, I love the way declarations read without the colon character:

trait Logging
  def logger: Logger

package config
  val defaultConfig = Config("localhost", 452)

// Etc.

Clean, consistent, and beautiful!

This would leave only the “lambda problem”. Personally, I find parentheses acceptable providing we can define local variables, e.g.:

xs.map (x =>
  val square = x * x
  square)

Or possibly one could take a cue from Haskell:

xs.map \x =>
  val square = x * x
  square

One can already approximate this with match:

xs.map (_ match
  case x => 
    val square = x * x
    square)

In any case, regardless of whether or not any changes are made, I am enjoying optional braces, and I have found that many students quickly gain an appreciation for the syntax, reversing their formerly held opinions. I take that as a good sign, and a sign of things to come!

8 Likes

Is there really a compelling reason not to just do this instead:

xs.map { x =>
  val square = x * x
  square
}

If there is, I’m not seeing it.

7 Likes

Using parens around multi-line lambdas would match the syntax used around a single-line lambda, in other words if I start with xs.map(x => foo(x) * 2) and then decide I need to add more lines in my lambda, I can do it without removing the parens and replacing them by braces (or :, or a keyword, in the other proposals which have been made).

5 Likes

Most elegant (and simplest) suggestion I’ve seen so far.

I always thought the requirement to switch to curly braces when adding statements was such an annoying and unjustified burden. I don’t see a reason we can’t write this, for instance:

xs.map(x => val tmp = foo(x); tmp * tmp)
// or on multiple lines:
xs.map( x =>
  val tmp = foo(x)
  tmp * tmp
)

This generalizes to multiple arguments, non-lambda arguments, and even partial function syntax:

foo(0, 1, x => val tmp = foo(x); tmp * tmp, 3)

foo(val tmp = 42; tmp * tmp, 1, 2, 3)

foo(case Some(x) => x; case None => 0)

It’s natural to treat ; as higher precedence than , because , binds to the surrounding parentheses pair, so there is never a visual ambiguity. It might look weird at first, but I think one can get used to it quickly.

11 Likes

It seems like everyone is chiming into this issue so I will too.

Earlier today I started pair-programming a brace-less version of some Scala 3 code together with a an excellent junior developer who I have recently been mentoring. I jokingly mentioned that I was now using Scala 3’s most controversial feature but he did not notice the braces were missing. I kept adding objects inside classes, then classes inside the methods, then objects inside those, until the structure was four-nesting levels deep. It was only then, after five minutes, that he noticed, and it didn’t make any sort of difference in comprehension.

I don’t know whether significant whitespace will hurt Scala or help it but I do not believe it will significantly impact usability either way.

5 Likes

More to that point, I don’t think that braces are of very much use to us as it stands today. At least not as useful as we think they are.

The strongest argument to make for brace-based syntax is that it helps to delimit blocks in an extremely consistent fashion however, because in Scala, single-statement constructs already don’t require braces, that is a much, much weaker argument.

Take this kind of construct for an example:

def fun(foo: Something) =
  foo match {
    case bar => "bval"
    case baz =>
      something(baz) match {
        case X(a) => x
        case Y(b) => u
      }
  }

Now imagine this is a long method and you’re looking at just the last four lines of it in a text scroll.
Here is what you see:

        case X(a) => x
        case Y(b) => u
      }
  }

Do these two ending braces really make anything clearer? I think they just make things more confusing since there are multiple interpretations of what is above the cursor (three nesting layers with four spaces or six with two?). This is different from languages like C where every single braced-enclosure almost always reflects a real nesting layer.

Here is a version of the former statement without braces:

        case X(a) => x
        case Y(b) => u

Now that we are forced to consider the space-based nesting levels as the primary delimiter as opposed to the braces, the fact that this is a 4-layer deep nested construct becomes apparent to us. It also helps that most editors will even keep track of this for us with subtle lines.

In conclusion, I don’t really think that brackets work as a nesting-level indicator in the first place.

P.S. If you really think braces help you refactor, maybe add them back in, in the selected areas you are refactoring and then do the refactor.

3 Likes

I know you probably don’t mean it like that, but just a head’s up that this comes off as really condescending if you’re familiar with Python, and use it despite the syntax being based on significant indentation, not because it’s something you find pleasant.

Dislike of this type of syntax isn’t always a sign of ignorance, some of us (myself included) simply don’t find it to be a particularly pleasant working experience.

Not to put too fine a point on it, but it’s not dissimilar to telling someone that’s partially red-green colorblind that they’re a reasonable person, and will learn to accept that they love your red ink and green stationary combination.

Okay. I took it out. I’m sorry if that sounded like that. I also modified the second section as well.

No worries, I figured it probably wan’t your intention.

My guess is that, if you find this syntax pleasant, it’s hard to imagine that’s not the case for everyone. That’s unfortunately a very human pitfall :man_shrugging:. I personally find it very hard to distinguish blocks and indentation levels based on differences in whitespace.

Not that this particularly matters, because unless I’ve badly misread @odersky’s comments about it above, it’s “full speed ahead, and damn the torpedos” on this one. Best I’m hoping for at this point is mitigation of the worst of the rough spots, and possibly editor support to provide the illusion of their presence, even if they aren’t there in fact.

2 Likes

Okay, thanks for the quick feedback. It was nice to find out that I missed a subtle-but-important point now and not three days later :sweat_smile:

1 Like

TLA+ is actually really awesome in this regard in that they (in spirit) write it like this:

def f(x): Boolean = 
  val p = someFunctionInvolving(x)

  || someLongCondition(p)
  || anotherLongCondition(p)
  || yetAnotherCondition(p)

and in more complicated scenarios:

def f(x): Boolean = 
  val p = someFunctionInvolving(x)
  || someLongCondition(p)
  || && anotherLongCondition1(p)
     && anotherLongCondition2(p)
     && || x
        || y
  || yetAnotherCondition(p)

// equivalent to

def f(x): Boolean = 
  val p = someFunctionInvolving(x)
  (  someLongCondition(p)
  || (   anotherLongCondition1(p)
      && anotherLongCondition2(p)
      && (x || y))
  || yetAnotherCondition(p)
  )

Here are some more edge-case-like translations to clarify how it works:

def a =
  || x
def b =
  && y

// equivalent to

def a =
  x: Boolean
def b =
  y: Boolean

// so that if x or y are non-booleans, you get an error message.
def a =
  || x
  && y

// throws an exception that || and && are at the same indentation level
// but are different operators

You can also indent to a new line at the start of new blocks. So the second example above can also be written like this:

def f(x): Boolean = 
  val p = someFunctionInvolving(x)

  || someLongCondition(p)
  ||
     && anotherLongCondition1(p)
     && anotherLongCondition2(p)
     &&
        || x
        || y
  || yetAnotherCondition(p)

I don’t know how good a fit it would be for Scala but in TLA+ where you’re always writing big blocks of nested conditionals, it’s fantastic.

1 Like

Strongly disagree with this process, IMO things should not have happened that way. But it’s done now and you’re flashing that BDFL card so thanks for the honesty. I don’t love the process but I do love the honestly. So, I won’t waste any more time trying to convince anyone to put this behind a flag which saves me time and effort I guess. I just hope this process and bringing out the BDFL staff won’t be too common. Otherwise let’s make this whitespace feature the best we can :slightly_smiling_face:

6 Likes

I’d like to give one more proposal for a start-of-block delimiter.

Why colon, : doesn’t work..

As someone who does a significant amount of ML research code in Python, : would be a nice consistency. However, in Scala that character already is associated with typing. Since static typing is core to the language, I feel we should avoid overloading :.

Why with doesn’t work..

with is already being used for mixins, and if you include whitespace, is five characters, which is too many (as pointed out by lihaoyi).

Why where doesn’t work..

where suffers from the same problem as with, it’s simply too many characters.

An alternative: ..

Why not two consecutive periods?

Pros

  • Is pretty rare in regular writing and programming languages, thus distinguishing it from other tokens/keywords
  • Easier to type than do and : (as it doesn’t require the shift key)
  • Not already being used (to my knowledge) in another context
  • Arguably has no semantic meaning, unlike do, with, then, and where

If you take the devil’s advocate argument that a single period and two consecutive periods might be semantically related due to visual similarities, it actually supports the alternative: A single period, ., is used to access fields inside of some instance/class/object/etc. Two periods, .. is used to signify going inside of some block.

Cons

  • It’s different

Examples:

xs.foreach ..
  println(_)     // wow so easy to type

xs.foreach do
  println(_)       // great!

xs.foreach with
  println(_)        // wat?

xs.exists with
  _ > 0            // ok!

xs.exists do
  _ > 0            // wat?

xs.exists ..
  _ > 0       // wow, so visually pleasing
class Foo() ..
  ... 
given Bar ..
  ...
extension (xs: List[Int]) ..
  ...
if x < 3 ..
  ...

xs.map ..
  x =>
    ...
// which we should probably also support as
xs.map .. x =>
  ...
given [T](using Ord[T]): Ord[List[T]] ..
  def compare(x: List[T], y: List[T])

9 Likes

A lot of interesting opinions here. My $0.02 after using Dotty for the last several months on a “real” project is that I’ve grown to like the brace-optional aesthetic. Its cleaner and modern-feeling. I wasn’t initially a fan but it grew on me. I like that I always have the option of using the braces for clarity when needed and for backwards compatibility. I hope this stays in.

1 Like

If it is possible to make the : for indentation optional, that could be very nice. Like in the case of control structures, and there is just one rule. As it stands, the choice of : has cognitive interference with : as type ascription. But maybe : is the best single character option, despite its use for type ascription.

I tried the new syntax on a small project and overall I found it easy to learn and “pretty”. I particularly appreciate the lack of 3-4 lines of closing } at the end of blocks: it makes more code fit within my editor window and easier to get an overview of the program. I also like that scala 3 will have an exciting new feature that will be visually obvious: I choose Scala 10 years ago also for its higher pace of innovation versus Java and taking bold steps is what will make Scala still relevant and innovative for the next 10 years.
Alex

2 Likes