dart-lang / language

Design of the Dart language
Other
2.68k stars 205 forks source link

Switch expression doesn't actually seem to be an expression. #3061

Open your-diary opened 1 year ago

your-diary commented 1 year ago

According to the official documentation,

A switch expression is similar to a switch statement, but you can use them anywhere you can use an expression.

However, the code below doesn't compile (DartPad):

enum Color { red, blue, green }

void main() {
  final color = Color.red;
  switch (color) {
    Color.red => "red",
    Color.blue => "blue",
    Color.green => "green",
  };
}

The error message is

$ dart compile exe bin/abc.dart

Info: Compiling with sound null safety.
bin/abc.dart:6:5: Error: Expected to find 'case'.
    Color.red => "red",
    ^^^^^
bin/abc.dart:5:11: Error: The type 'Color' is not exhaustively matched by the switch cases since it doesn't match 'Color.red'.
 - 'Color' is from 'bin/abc.dart'.
Try adding a default case or cases that match 'Color.red'.
  switch (color) {
          ^
Error: AOT compilation failed
Generating AOT kernel dill failed!

On the other hand, these code compile:

enum Color { red, blue, green }

void main() {
  final color = Color.red;
  //binds to placeholder
  final _ = switch (color) {
    Color.red => "red",
    Color.blue => "blue",
    Color.green => "green",
  };
}
void main() {
  3; //expression statement
}

Why?

Note, in Rust, the equivalent code compiles:

enum Color {
    Red,
    Blue,
    Green,
}

fn main() {
    let color = Color::Red;
    match (color) {
        Color::Red => "red",
        Color::Blue => "blue",
        Color::Green => "green",
    };
}
bwilkerson commented 1 year ago

But according to this section of the specification:

Thanks to expression statements, a switch expression could appear in the same position as a switch statement. This isn't technically ambiguous, but requires unbounded lookahead to read past the value expression to the first case in order to tell if a switch in statement position is a statement or expression.

To avoid that, we disallow a switch expression from appearing at the beginning of an expression statement.

@MaryaBelanger

your-diary commented 1 year ago

For the future readers:

According to the spec

In the rare case where a user really wants one there, they can parenthesize it.

So this code compiles:

enum Color { red, blue, green }

void main() {
  final color = Color.red;
  (switch (color) {
    Color.red => "red",
    Color.blue => "blue",
    Color.green => "green",
  });
}
MaryaBelanger commented 1 year ago

I'll add all this to the site docs, thank you both

gamebox commented 1 year ago

Unfortunate that a different keyword, like match, wasn't utilized for the expression form. This parenthesized form is not ideal.

your-diary commented 1 year ago

@gamebox IMHO, using the same keyword both for the statement and the expression forms is a good idea to make the language simple. Actually, Rust doesn't have switch and just uses match keyword for both of the forms.

The essential problem here is, as written in the spec,

Thanks to expression statements, a switch expression could appear in the same position as a switch statement. This isn't technically ambiguous, but requires unbounded lookahead to read past the value expression to the first case in order to tell if a switch in statement position is a statement or expression. To avoid that, we disallow a switch expression from appearing at the beginning of an expression statement.

This implies improving the parser would fix this issue without redesigning the language itself (e.g. introducing a new keyword for the expression form). This may also mean improving the parser in such a way would make compilation speed slower.

Anyway, I hope this limitation will be removed in the future.

gamebox commented 1 year ago

Yes, that's why I'm saying that having different keywords for the statement versus the expression (which has different syntax anyway), this ambiguity goes away

A different onset like switch on (<expr>) may have been a good disambiguator as well. But the syntax is now the syntax, so this is crying over spilt milk 😉

caseycrogers commented 1 year ago

Can we re-open this? I get that this is in spec, but lets repurpose this issue as a proposal to change the spec? Or I can file a new issue if that's preferrable.

The way I assumed the new switch syntax was going to work was that this is simply the new syntax, and while the old syntax may stick around for backwards compatibility, it is overly verbose/awkward and will rapidly be considered an anti-pattern.

But instead I now either have to live with two inconsistent syntaxes in my code base or I have to do the weird paren hack. Both of these seem pretty undesirable, especially given how undiscoverable the paren hack is.

Also, this line from the spec just seems incorrect to me:

In the rare case where a user really wants one there, they can parenthesize it.

This is not a rare case. Anywhere where the old syntax is used I (and I think most devs) want to use the new syntax instead for consistency and because it's just flat out more writeable and readable. This is tricky because the new syntax doesn't have full feature parity with the old syntax. The three examples I can think of:

  1. No non-hacky support for use as a statement. The thrust of this issue.
  2. No support for labels. This is fine, labels are a kludgy (and I suspect very low use) feature anyway-this need is more idiomatically served with a helper function.
  3. No support for multi statement cases. This begs the question if we should support multi statement cases? I think it'd be highly desirable-especially if you agree with my mindset that the old syntax should be disused if not fully deprecated. The following would be a very clean and intuitive way to do this (borrowed from Scala):
    final someString = switch (someInt) {
    // All the lines execute and the case evaluates to the last expression.
    1 {
     print(1);
     someSideEffect();
     "foo";
    },
    2 => "bar",
    _ => "baz",
    }

    This just ports syntax conventions even further from functions where => is just syntactic sugar for a single-line function body. However it could be confusing in that devs may assume that they need a return and not realize that that'll return the function, not the case block.

I don't think it's just me that finds the bifurcated syntax confusing, see engagement on a related tweet: https://twitter.com/caseycrogers/status/1658946335004037120?t=L9meQwQaojmW6I2DFkSE5Q&s=19

I know very little about compilation and parsing so I don't know the best way to implement a fix here-I'm just describing what I think the ideal customer-facing behavior is.

munificent commented 1 year ago

The way I assumed the new switch syntax was going to work was that this is simply the new syntax, and while the old syntax may stick around for backwards compatibility, it is overly verbose/awkward and will rapidly be considered an anti-pattern.

Switch statements are not old syntax that is going away. They are the idiomatic way to do multi-way branching in a statement position. A switch statement is what you need if any case body needs to be a statement and not an expression. Also, switch statements can contain multiple statements in a case body, and they can have multiple cases share a body.

Switch statements are great. If you are writing a statement, and you want it to switch, a switch statement is the idiomatic way to do it. That was true before Dart 3.0 and is true today. We didn't deprecate switch statements. We made switch statements much more powerful by allowing them to contain patterns in their cases.

We also added switch expressions, because you can't use a statement where an expression is needed.

Just as most Dart users wouldn't write:

main() {
  someCondition ?
    print('True');
  :
    print('False');
}

And would instead use the statement form:

main() {
  if (someCondition) {
     print('True');
   } else {
     print('False');
  }
}

Instead of:

void main() {
  final color = Color.red;
  (switch (color) {
    Color.red => print("red"),
    Color.blue => print("blue"),
    Color.green => print("green"),
  });
}

The idiomatic thing to write today is the same as it was before Dart 3:

void main() {
  final color = Color.red;
  switch (color) {
    case Color.red: print("red");
    case Color.blue: print("blue");
    case Color.green: print("green");
  }
}

Note that the original example is a good example for why a switch expression isn't what you want:

void main() {
  final color = Color.red;
  (switch (color) {
    Color.red => "red",
    Color.blue => "blue",
    Color.green => "green",
  });
}

This code compiles, but it doesn't do anything. You have a switch expression that does nothing but produce a value which is immediately discarded because the switch expression is in a statement position.

If this had been written as a switch statement, it becomes more obvious that those case bodies are pointless.

MaryaBelanger commented 1 year ago

FYI we did try to adjust the site docs based on this issue. Albeit as brief and "language tour"-like as possible:

caseycrogers commented 1 year ago

Switch statements are great. If you are writing a statement, and you want it to switch, a switch statement is the idiomatic way to do it. That was true before Dart 3.0 and is true today.

I get that this is the intent of the Dart team-I'm proposing that, at the very least, Dart shouldn't arbitrarily limit where developers can use the expression syntax (I'll quit saying "old" and "new" because that was unnecessarily charged language on my part).

Here is my reasoning for allowing developers to use the expression syntax at the start of statements (please forgive me if I use some of the wrong terms here-I'm approaching this as a developer using the language, not as a language designer):

  1. Many developers expect to be able to do this
    • (see the original post in this issue, engagement on relevant tweet, and maybe you all have other ways to survey/evaluate dev expectations as well?)
    • Other expressions are allowed to be used as statements (ternaries being one of many examples). Regardless of whether or not using a ternary as a statement is desirable, it compiles. This sets a developer expectation that expressions are valid to use as statements.
  2. The switch statement's syntax is not consistent with the rest of Dart resulting in high cognitive load every time a developer tries to use it. The switch expression's syntax is more consistent and thus easier to work with so I (and I believe others) would rather use it than the statement syntax wherever possible.
    • It relies heavily on keywords, many of which are uncommon and/or unique to switch such that many developers aren't going to be super familiar with them (case, break, continue, default, labels where an arbitrary token behaves kind of like a keyword). I think re-using the existing relevant Dart symbols/conventions as much as possible results in a lot less cognitive load for a developer trying to write a switch. A switch expression with pattern matching does this by re-using ( ... ) => ... from single statement anonymous functions and _ from wildcard variables.
    • It uses : to signify a multi statement code block instead of {}
    • The fall-through behavior on empty cases is non-obvious (relies on recall not recognition). Fall-through is especially confusing given that it only applies to cases with empty bodies.
    • It's verbose (largely downstream of its reliance on keywords)
  3. Why'd we bother allowing void cases in switch expressions if we don't want switch expressions to be used as statements?
    • Here you say that using a switch expression for it's side effects is non-idiomatic. Yet in the bug that just got resolved in 3.0.1, the example you provide does exactly this: https://github.com/dart-lang/sdk/issues/52191 I don't mean this to sound too much like a "gotcha", I'm just trying to point out that using a switch expression for its side effects feels like a very natural thing to do that the language is already explicitly making an effort to support. Why then not support it at the start of an expression statement too?

To respond to your points directly:

Just as most Dart users wouldn't write: [ternary as statement] And would instead use the statement form: [if..else] The idiomatic thing to write today is the same as it was before Dart 3:

I'd posit that ternaries as statements are non-idiomatic because ternaries have poor readability and are too opinionated (require exhaustivity, don't support multi-statement bodies, don't support more than 2 cases, confusingly flip the <keyword> <clause> ordering used everywhere else in Dart...) when compared to if...else. Not because developers believe that statements and expressions should use different syntax. There already exists if (<predicate>) <expression> for single statement if bodies and guards in collection literals-I imagine something similar would've been used for ternaries if it weren't for the exhaustivity requirement?

If Dart wants there to be a sharp switch expression/switch statement demarcation like there is with ternaries and if..else then maybe the two kinds of switches should've used a different keyword/symbol like ternaries and if..else?

Note that the original example is a good example for why a switch expression isn't what you want: [switch expression used as unnecessary statement] This code compiles, but it doesn't do anything. You have a switch expression that does nothing but produce a value which is immediately discarded because the switch expression is in a statement position.

Is this really a value add that needs to be forced by a special-case limitation (switch expression can't be used at the start of a statement expression) to the language? AFAIK Dart isn't generally opinionated against using expressions as statements, and I don't see any first-principles specific to switches that suggest it should be opinionated about it here. Besides, we already have the unnecessary_statements lint, I'm not familiar with how its implemented, but maybe it could be extended to catch the same problem in the context of switch expressions used as statements?

Also, switch statements can contain multiple statements in a case body...

I wish switch expressions could have multi statement bodies. Maybe my Scala experience is showing too much here, but its philosophy of "every code block is an expression" is REALLY nice to work with and something that I think oughta be ported to other languages wherever it makes sense. As in my previous post, I think switch expressions should allow multi statement cases using { ... }. This also has the added benefit of letting devs use {} to signify an empty case body like they already use {} to signify an empty function body. I'll file a new issue for this (or jump on one if it already exists).

...and they can have multiple cases share a body.

Pattern matching trivially supports multiple cases with the same body. In fact, I find it much more intuitive and readable than the fall-through based approach that switch statements use because it just re-uses the || operator that I'm already familiar with instead of an implicit characteristic of switch statements:

var foo = switch (someInt) {
  (1 || 2) => 'one or two',
  (_) => 'some other number',
};

Expression statements don't have an equivalent to labels, but that seems like a pretty minor loss. Labels are such a weird special-case feature that (IMO) would be just as well if not better served by a helper function/method.

TLDR: I think there are a lot of compelling reasons to allow a switch expression to be used as a statement. If Dart were being remade from scratch today, I think it'd be hard to argue that there should be two distinct switch syntaxes, one for expressions and one for statements. As such, while it may be unreasonable to deprecate the statement syntax (I'd challenge even this claim, though that's probably best left for another time), at the very least lets not place artificial limits on when and where the expression syntax can be used.

caseycrogers commented 1 year ago

Can we re-open this bug or should I file a new issue and port the conversation here?

leafpetersen commented 1 year ago

I've reopened this to discuss on the team.

munificent commented 1 year ago

@caseycrogers, I find all of your points pretty compelling.

I think the existing switch statement syntax is familiar and easier to learn for users coming from some other C-derived language, but I agree that if you aren't steeped in that tradition, it sticks out like a sore thumb. (I could insert an essay here about how the design of switch statements in C is consistent with the rest of the language when you consider a point in time when labels and goto were idiomatic, but I'll skip that. :) )

I wish switch expressions could have multi statement bodies. Maybe my Scala experience is showing too much here, but its philosophy of "every code block is an expression" is REALLY nice to work with and something that I think oughta be ported to other languages wherever it makes sense.

I have a pretty strong preference for everything-is-an-expression languages too. But the initial designers of Dart did not and it's not clear to me if there is a path to get there that doesn't leave a lot of confusion and mess in its wake. I'm interested in exploring this, but I suspect it will be a bridge too far.

As in my previous post, I think switch expressions should allow multi statement cases using { ... }. This also has the added benefit of letting devs use {} to signify an empty case body like they already use {} to signify an empty function body. I'll file a new issue for this (or jump on one if it already exists).

Filing a separate issue (if you haven't already, I'm a little behind on GitHub) would be great.

lrhn commented 1 year ago

Filing a separate issue (if you haven't already, I'm a little behind on GitHub) would be great.

They did (#3117), and I closed it, deferring to #132, which is about allowing statements in expressions in general, not just in switches. Because that'd be generally useful.

We can also discuss allowing a terser syntax for switch statements here, which would be the other direction to go. (Just allowing you to omit the case is not going to be great. I also don't want to comma-separate statements.)

caseycrogers commented 1 year ago

@munificent thanks for reading through my whole post on the matter, I hope you all end up allowing switch expressions at the start of statements!

For multi-statement case bodies, I'll go join the conversation on the relevant conversation now :)