language icon indicating copy to clipboard operation
language copied to clipboard

Case expressions

Open lrhn opened this issue 8 months ago • 4 comments

(This is basically reopening https://github.com/dart-lang/language/issues/2181 as a new feature.)

Allow <expression> 'case' <pattern> ('when' <expression>)? as an expression which:

  • Evaluates to a bool result.
  • Introduces variables that are available on the true path out of that bool, if it occurs in a condition position.

Since patterns look like expressions, and can end in ?, and the start and end of case...when are expressions, we must know where a case expression ends. That means it must likely be a production of <expression>, which means it cannot be combined with any operators without being parenthesized, and it will have to be delimited in some way in all uses.

Proposed:

  <expression>  ::= ...
                               | <caseExpression>
  <caseExpression> ::=
        <expressionWithoutCascade> 'case' <pattern> ('when' <expressionWithoutCascade>)?

(Not absolutely sure about the placement in the grammar, but likely close to this. At least you definitely don't want an unparenthesized case-expression as the value or when clause of another case-expression.)

If the pattern introduces variables, then those variables are in scope in the when clause as usual. Further, if the case-expression (or parenthesized expression, or !-negated expression) is use in a condition position, which means any position where its value directly affects control flow (has a "true" branch and a "false" branch), then the variables are available on the true path, until the end of the current constructor/block. (This is not intended to be something new, it's exactly the same continuation where a promoting test would promote, except that the variables are also limited to the scope they are declared in.)

Example:

var x = input case Box(:var value) ? value : input;

and

{
   (input case Box(:var value)) || throw "nope";
   print(value);
}

(I know this is bad style. It does show that a variable is available in the rest of the block, as long as it's dominated by the true test. An alternative is to define a scope for each test context, like the scope of an if condition is the then or else branch, and not allow bound variables to flow to after the if. Or say that each composite statement introduces a new scope block.)

If a case expression is used as the condition of an if statement/element, a for statement/element or a while statement, then the variables are available in the body (if that's the true-branch), but not outside of the satement. It is as if the if/loop structure introduces a scope block containing the condition itself, and then the body is another nested scope.

This is consistent with how if/case works today, but this allows doing multiple independent cases:

if ((ex case Foo(:var x) when x > 0) && (ex2 case Bar(:var y) when y > 0)) { 
  both `x` and `y` in scope.
}

lrhn avatar Apr 30 '25 15:04 lrhn

This looks really nice. In particular, I have sometimes wanted to have an if condition that is a chain of case and/or other conditional tests like:

if (thing case Foo(:var bar) && someCondition && bar case Blah() && otherCondition) { ... }

It looks like this would address that.

I'm not as sold on allowing variable declarations outside of if conditions. I think this is probably fine:

input case Box(:var value) ? value : input;

But:

{
   input case Box(:var value) || throw "nope";
   print(value);
}

Feels pretty subtle and unintuitive to me. I do grant that it effectively offers a solution for #2537. I just wonder if it's too subtle and we should have a more explicit guard-like statement.

But aside from that, this looks great.

munificent avatar May 01 '25 17:05 munificent

I'd love this! It would also allow us to do:

if (input != other && (input case MyType(:var value))) {
  print(value);
}

Which reads the != test first and could short-circuit before the pattern without the need of a full outer if or weird workarounds like:

if ((input == other ? null : input) case MyType(:var value)) {
  print(value);
}

FMorschel avatar May 03 '25 02:05 FMorschel

Another case where this would help would be assertions.

class A {
  A(int a, int max) : assert(a case 0 || max);
}

With some asserts, repeating the variable name multiple times can make it harder to read.

FMorschel avatar May 26 '25 16:05 FMorschel

I think we should buy in to the current control flow features, because otherwise we may lose out on any improvements we make to those.

We allow:

  Object o = ...;
  bool isInt = o is int;
  ...
  if (isInt) {
     // o has type int here.
  }

We should allow:

  Object o = ...;
  bool isInt = o case int();
  ...
  if (isInt) { 
     // o has type int here.
  }

too, because otherwise we'd be doing a worse job that we are already doing for is.

And then it's not a big leap to say:

  Object o = ...;
  bool isInt = o case int oAsInt; // Introduces oAsInt into current scope, as unusable.
  ...
  if (isInt) { 
     // o has type int here.
     // oAsInt is usable here.
  }

and it might even feel weird if it doesn't work. The variable is considered not in scope when not dominated by the test being true. An identifier lookup will look past it. (Or something. Need to avoid clashes between variables that aren't all in scope. I think I can do that in an intuitive way.)

lrhn avatar Sep 05 '25 09:09 lrhn