language icon indicating copy to clipboard operation
language copied to clipboard

Allow switch without scrutinee and patterns in cases

Open alexrintt opened this issue 7 months ago • 14 comments

Recently Dart 3 introduced switch expressions, although they facilitate type-casting-like flows through pattern matching, we still can't use flows where there is no compile-time pattern to match but a runtime expression to evaluate:

late final action;

if (isAvailableX()) {
  action = doAndReturnX();
} else if (isAvailableY()) {
  action = doAndReturnY();
} else if (isAvailableZ()) {
  action = doAndReturnZ();
}

Using switch expression we are forced to give the switch the runtime object we want to work with along with the compile-time patterns:

final action = switch (null) {
  _ when isAvailableX() => doAndReturnX(),
  _ when isAvailableY() => doAndReturnY(),
  _ when isAvailableZ() => doAndReturnZ(),
  _ => null,
};

In Kotlin we can simply:

val action = when {
  isAvailableX() -> doAndReturnX()
  isAvailableY() -> doAndReturnY()
  isAvailableZ() -> doAndReturnZ()
  else -> null
}

A similar approach in Dart would be:

final action = switch {
  isAvailableX() => doAndReturnX(),
  isAvailableY() => doAndReturnY(),
  isAvailableZ() => doAndReturnZ(),
  _ => null,
};

alexrintt avatar Nov 09 '23 15:11 alexrintt

So if a switch has no expression, the patterns are all reduced to the when clause expressions (no value matches no pattern 100% of the time), or to a _ or default pattern.

This would apply to both expression and statment switches:

int compare(int a, int b) => switch {
  a < b => -1,   // no 'pattern when'
  a == b => 0,
  _ => 1,
};

and

int compare(int a, int b) {
  switch {
    case a < b: return -1;   // no 'pattern when'
    case a == b: return 0;
    default: return 1;
  }
}

Not unreasonable. (Can we remove if now? :wink:)

lrhn avatar Nov 09 '23 17:11 lrhn

I love it.

munificent avatar Nov 09 '23 22:11 munificent

Thinking about it more. Maybe this should be an if, not a switch:

if {
  a < b => -1,
  a == b => 0,
  else => 1,
}

Or maybe not, leave if to the binary choice, and always use switch for multi-way choices. That's consistent, and the syntax is built for it. I didn't try to make an if statement syntax. (We could allow else as a default branch of switch expressions, I think it reads quite well.)

It would also pretty much assume an expression-if, but when you can do

switch (cond) {
  true => thenExpr,
   _ => elseExpr,
 }

today, that's not a big leap.

lrhn avatar Nov 10 '23 07:11 lrhn

I particularly like the if syntax, it sounds natural and readable as when:

  • "when condition B meets, do B".
  • "if condition B meets, do B".

when: We almost got there using switch + when, so we can abbreviate to when and make it an actual language keyword and not just a guard clause.

if: I think it's no longer just a binary choice mechanism since Dart 3 when if case syntax was released. So I see no problems in using the switch + when with if { } syntax).

Also, I agree that else is more readable... actually I wish it was allowed on switch expressions. It's far more readable than putting a _.

alexrintt avatar Nov 10 '23 17:11 alexrintt

This is about choosing the first of a list of possible cases whose boolean condition evaluates to true. That really is an if/else chain. Maybe if the formatter allowed a briefer syntax, we wouldn't need to introduce another one:

if (isAvailableX()) action = doAndReturnX();
else if (isAvailableY()) action = doAndReturnY();
else if (isAvailableZ()) action = doAndReturnZ();

or even

if (isAvailableX()) action = doAndReturnX(); 
else 
if (isAvailableY()) action = doAndReturnY(); 
else 
if (isAvailableZ()) action = doAndReturnZ();

These are all valid Dart syntaxes, just not ones supported by the formatter.

If feel like a bunch of the syntax requests we get are for things that wouldn't be necessary if the formatter was less strict about putting things on the same line. If the formatter allowed:

  void oneLineFunction(arg) { expressionStatement; }

we wouldn't have needed to special-case

  void oneLineFunction(arg) => expressionStatement;

and if we allowed if (test) expressionStatement; to stay on one line, even if followed by an else, then maybe we wouldn't need this issue.

That said, I can see why:

if {
  isAvailableX(): action = doAndReturnX();
  isAvailableY(): action = doAndReturnY();
  isAvailableZ(): action = doAndReturnZ();
}

might read even better. The else is really noise here. Maybe we need a shorter else if?

if (isAvailableX()) action = doAndReturnX(); 
 | (isAvailableY()) action = doAndReturnY(); 
 | (isAvailableZ()) action = doAndReturnZ();

(I know that'l never parse, but it does look nice.)

lrhn avatar Apr 14 '24 08:04 lrhn

This is about choosing the first of a list of possible cases whose boolean condition evaluates to true.

I do agree.

That really is an if/else chain.

Ifs aren't allowed to be used within an expression context as switch does. You cannot:

final value = if (isAvailableA()) getA() 
              else if (isAvailableB()) getB() 
              else null;

Even if it did, we are still left with the verbose else issue.

and if we allowed if (test) expressionStatement; to stay on one line, even if followed by an else, then maybe we wouldn't need this issue.

This issue is not just about having a clever way of "choosing the first of a list of possible cases whose boolean condition evaluates to true" but also being able to do that within an expression context.


Let's say we have the following syntaxes:

if (isAvailableX()) action = doAndReturnX();
else if (isAvailableY()) action = doAndReturnY();
else if (isAvailableZ()) action = doAndReturnZ();
if (isAvailableX()) action = doAndReturnX(); 
else 
if (isAvailableY()) action = doAndReturnY(); 
else 
if (isAvailableZ()) action = doAndReturnZ();
if {
  isAvailableX(): action = doAndReturnX();
  isAvailableY(): action = doAndReturnY();
  isAvailableZ(): action = doAndReturnZ();
}
if (isAvailableX()) action = doAndReturnX(); 
 | (isAvailableY()) action = doAndReturnY(); 
 | (isAvailableZ()) action = doAndReturnZ();

All of them need to write action = over and over again.

The key is to be able to do a clever if/else within an expression context (e.g one-line function, variable assignments).

alexrintt avatar Apr 14 '24 17:04 alexrintt

There's an option to reuse the syntax of conditional expression by adding some decorations, with a different formatting

var x = if {
  : cond1 ? expr
  : cond2 ? expr
  : expr
}

This syntax works and formats nicely with or without the assignment. (The first colon is added for beauty) Sadly, it dissonates with switch syntax.

tatumizer avatar Apr 15 '24 00:04 tatumizer

Don't need the if for that:

action = 
  isAvailableX() ? doAndReturnX() :
  isAvailableY() ? doAndReturnY() :
  isAvailableZ() ? doAndReturnZ() :
  null;

We already have the syntax for condition chains as expressions. The biggest issue is the formatter not recognizing a chain of theses as something that should be laid out at the same indentation level.

lrhn avatar Apr 15 '24 05:04 lrhn

Suppose the formatter somehow acquires the ability to nicely format this expression. This will solve the problem of if- expressions syntax, right? Then your example of if- statement

if {
  isAvailableX(): action = doAndReturnX();
  isAvailableY(): action = doAndReturnY();
  isAvailableZ(): action = doAndReturnZ();
}

can be encoded like this:

isAvailableX() ? action = doAndReturnX() :
isAvailableY() ? action = doAndReturnY() :
isAvailableZ() ? action = doAndReturnZ() :
doNothing; // we can use null here, too

The problem is that the syntax forces us to write an extra line which has no meaning. An obvious solution is to make the last colon optional (in this context), so we can write it simply as

isAvailableX() ? action = doAndReturnX() :
isAvailableY() ? action = doAndReturnY() :
isAvailableZ() ? action = doAndReturnZ() ;

Does this solve the problem? Or it will need some extra decor - e.g. in the form of enclosing if {...} for readability? If we do need it, then wouldn't it be more consistent to apply the same decor to the chain of if-expressions, too?

tatumizer avatar Apr 15 '24 15:04 tatumizer

My worry here is that we may be chasing brevity, without actually achieving readability.

The examples here show things that are similar. They have similar structures because they do similar things in similar ways.

The goal of code formatting is to expose such similarities instead of hiding them. Automatic formatting might not always be able to achieve that, because it cannot know which similarities are fundamental, and which are accidental. That is, sometimes automatic formatting doesn't give the most readable result.

Adding new syntax to get around that seems like treating a self-inflicted wound. We could just allow code to be exempted from automatic formatting, and then the author can do whatever brings out the similarities, even if it's as un-traditional as:

if (isAvailableX()) action = doAndReturnX(); 
else 
if (isAvailableY()) action = doAndReturnY(); 
else 
if (isAvailableZ()) action = doAndReturnZ();

Maybe there will be a handful of non-standard patterns that people end up preferring for specific code patterns, and maybe the formatter can eventually learn to recognize and support them.

But we shouldn't introduce new syntax just to get around the formatter. It's much easier to change or ignore the formatter then.

lrhn avatar Apr 15 '24 17:04 lrhn

Changing the formatter to allow something like this:

final value = 
    isAvailableA() ? getA() :
    isAvailableB() ? getB() : 
    isAvailableC() ? getC() : null;

is going to solve the issue of not having a clever way of "choosing the first of a list of possible cases whose boolean condition evaluates to true".

But I think the best output of this issue would be to have a more unified flow control for multiple choices, like we have in Kotlin with the when keyword.

What about integrating the runtime evaluation with all current compile-time supported features of the switch patterns? So, for instance, I would be able to create patterns as we do now, and if none of them pass the test, I could check for something at runtime.

TestLevel level;

// Scrutinee optional. So `switch { ... }` is also valid.
final result = switch (level) {
  // Currently throws 'The binary operator is is not supported as a constant pattern.' (it's a non-constant expression)
  // Only supported through pattern-matching `FirstSpecialCaseLevel()` (Less readable but possible to resolve as constant).
  is FirstSpecialCaseLevel => ...,

  // Currently throws 'The relational pattern expression must be a constant.'
  // Also, If no scrutinee is given, throws a compile-error, since there are no value to compare `< minimumAllowed()` with.
  < minimumAllowed() => ...,
  > maximumAllowed() => ...,
  tooHigh() => ...,
  tooLow() => ...,
  withinHealthLevels() => ...,

  is SpecialCaseLevel => ...,
  AnotherSpecialCaseLevel(:final field) when ... => ...,
  anotherCheck() when lastCheck() => ...,
  _ => ...,
};

I wonder if this may have implications because we will be mixing different kinds of expressions ("non-constant" and "constant") within the same "scope" (I've no internal SDK knowledge so feel free to correct misplaced terms).

alexrintt avatar Apr 15 '24 20:04 alexrintt

I think we are chasing not brevity for its own sake, but rather trying to come up with visually recognizable patterns, and those tend to be relatively short.

Imagine that isAvailableX() and others are not there, and instead we have to write them as inline expressions, each of different size. Then good formatting would be a challenge even for an author. The way out is to introduce intermediate variables.

late isAvailableX = ...long expression
late isAvailableY = ...
late isAvailableZ = ...

The goal is to make every cond ? expr : fit in a single line. Can someone provide an example where the introduction of auxiliary variables and/or functions makes the program less readable?

tatumizer avatar Apr 16 '24 03:04 tatumizer

Currently the only way I can do that is by:

final isAvailableX = (() {
  // ... long expression with ifs and explicit returns
})();

// instead of
final isAvailableX = // ... a bunch of mixed '&&', '||' and '()' with unrelated subjects.

Although it would be a great improvement, I think it's outside of the scope of the current issue (at least from what I thought initially, but I would love to see these inline expressions as well).

final isAvailableX => {
  if (check1) return result1;
  if (check2) return result2;
  return resultDefault;
};

alexrintt avatar Apr 16 '24 14:04 alexrintt

Assuming you don't really need a function , you can write the condition as

final isAvailableX  =
   check1 ? result1 :
   check2 ? result2 :
   resultDefault;

If you do need a function, you can write it in a similar manner:

isAvailableX()  =>
   check1 ? result1 :
   check2 ? result2 :
   resultDefault;

The chain of conditional expressions is an exact equivalent to a switch without the scrutinee (the topic of current thread)

tatumizer avatar Apr 16 '24 17:04 tatumizer