Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Case expressions and return patterns #4141

Open
eernstg opened this issue Oct 24, 2024 · 38 comments
Open

Case expressions and return patterns #4141

eernstg opened this issue Oct 24, 2024 · 38 comments
Labels
brevity A feature whose purpose is to enable concise syntax, typically expressible already in a longer form feature Proposed language feature that solves one or more problems patterns Issues related to pattern matching.

Comments

@eernstg
Copy link
Member

eernstg commented Oct 24, 2024

Dart 3.0 introduced patterns, including the following kind of construct known as an if-case statement:

if (json case [int x, int y]) {
  print('Was coordinate array $x,$y');
} else {
  throw FormatException('Invalid JSON.');
}

This statement has a semantics that makes it different from traditional if statements. In particular, it isn't specified as an if-statement whose condition is the expression json case [int x, int y]. The reason for this is that there is no expression of the form <expression> 'case' <guardedPattern>. Hence, an if-case statement does not have a condition expression of type bool, it has an expression (of an any type), and it has a pattern, and it chooses the "then" branch if and only if the pattern matches the value of that expression. Another specialty with if-case statements is that variable declarations in the pattern are in scope in that "then" branch.

This issue claims that we might as well turn the syntax <expression> 'case' <guardedPattern> into an expression in its own right. We're generalizing it a bit, and allowing it to occur in any place where we can have an expression. The new kind of expression is known as a <caseExpression>. For starters, it has static type bool, and it evaluates to true if and only if the match succeeds.

We say that the result type of the pattern is bool, which is true for all the patterns that we can write today.

This issue also proposes a new kind of pattern, <returnPattern>, which is used to specify a different value for the evaluation of the enclosing case expression when it matches, namely the matched value. In this case, the result type of the pattern is M? when the matched value type at the return pattern is M. The value of the pattern when the match fails is null.

Here are some examples illustrating the semantics (some less trivial examples can be seen in this comment):

void main() {
  // Case expressions of type `bool`.

  var b1 = 10 case > 0; // OK, sets `b1` to true.
  var b2 = 10 case int j when j < 0; // OK, sets `b2` to false.

  // Case expressions of other types.

  var s = "Hello, world!" case String(length: > 5) && return.substring(0, 5);
  var n = [1, 2.5] case [int(isEven: true) && return, _] || [_, double() && < 3.0 && return];

  Object? value = (null, 3);
  var i = value case (num? _, int j && return) when j.isOdd;

  // It works as follows.

  { // var b1 = 10 case > 0;
    bool b1;
    if (10 case > 0) {
      b1 = true;
    } else {
      b1 = false;
    }
    // `b1` has the value true.
  }

  { // var b2 = 10 case int j when j < 0;
    bool b2;
    if (10 case int j when j < 0) {
      b2 = true;
    } else {
      b2 = false;
    }
    // `b2` has the value false.
  }

  { // var s = "Hello, world!" case String(length: > 5) && return.substring(0, 5);
    String? s;
    if ("Hello, world!" case String(length: > 5) && String it) {
      s = it.substring(0, 5);
    } else {
      s = null;
    }
    // `s` has the value 'Hello'.
  }

  { // var n = [1, 2.5] case [int(isEven: true) && return, _] || [_, double() && < 3.0 && return];
    num? n;
    if ([1, 2.5]
        case [int(isEven: true) && final num result, _] ||
            [_, double() && < 3.0 && final num result]) {
      n = result;
    } else {
      n = null;
    }
    // `n` has the value 2.5.
  }

  {
    // Object? value = (null, 3); 
    // var i = value case (num? _, int j && return) when j.isOdd;

    Object? value = (null, 3);
    int? i;
    if (value case (int? _, int j) when j.isOdd) {
      i = j;
    } else {
      i = null;
    }
    // `i` has the value 3.
  }
}

Proposal

Syntax

<expression> ::= ... | <caseExpression>;
<caseExpression> ::= <conditionalExpression> 'case' <guardedPattern>
    ('=>' <expression>)?;

<expressionWithoutCascade> ::= ... | <caseExpressionWithoutCascade>;
<caseExpressionWithoutCascade> ::=
    <conditionalExpression> 'case' <guardedPatternWithoutCascade>;
<guardedPatternWithoutCascade> ::=
    <pattern> ('when' <expressionWithoutCascade>)?

<returnPattern> ::= <type>? 'return' <selector>*;

Static Analysis

With this feature, every pattern has a result type. The result type of every pattern which is expressible without this feature is bool.

The remaining patterns (which are only expressible when this feature is available) have a result type which is determined by the return patterns that occur in the pattern. They are specified below in terms of simpler patterns followed by composite ones.

The result type of a return pattern of the form return is the matched value type of the pattern. The result type of a return pattern of the form T return is T. The result type of a return pattern of the form return s1 .. sk where sj is derived from <selector> is the static type of an expression of the form v s1 .. sk, where v is a fresh variable whose type is the matched value type of the pattern. Finally, the result type of a return pattern of the form T return s1 .. sk is the static type of an expression of the form v s1 .. sk, where v is a fresh variable whose type is T.

For example, if the matched value type for a given return pattern P of the form return.substring(5).length is String then the result type of P is int. This is because v.substring(5).length has type int when v is assumed to have type String.

The result type of an object pattern that contains one field pattern with result type R, which is not bool, is R. It is a compile-time error if the object pattern has two or more field patterns with a result type that isn't bool.

It is a compile-time error if a listPattern, mapPattern, or recordPattern contains multiple elements (list elements, value patterns of the map, or pattern fields of the record) whose result type is different from bool. If all elements have result type bool then the result type of the pattern is bool. Otherwise, exactly one element has a result type T which is not bool, and the result type of the pattern is then T.

Consider a logicalAndPattern P of the form P1 && P2 .. && Pn where Pj has result type T which is not bool, and Pi has result type bool for all i != j. The result type of P is T. It is a compile-time error if a logicalAndPattern P of the form P1 && P2 .. && Pn has two or more operands Pi and Pj (where i != j) whose result type is not bool.

Consider a logicalOrPattern P of the form P1 || P2 .. || Pn where Pi has result type Ti, for i in 1 .. n. A compile-time error occurs if at least one operand Pj has result type bool, and at least one operand Pk has a result type T which is not bool. If all operands have result type bool then the result type of P is bool. Otherwise, the result type of P is the standard upper bound of the result types T1 .. Tn.

Consider a parenthesizedPattern P of the form (P1). The result type of P is the result type of P1.

Some other kinds of pattern also have the result type of their lone child: castPattern, nullCheckPattern, and nullAssertPattern.

The remaining patterns always have result type bool: constantPattern, variablePattern, and identifierPattern.

Assume that e is a case expression of the form e1 case P where P has result type T which is not bool; the static type of e is then T?. Assume that P has result type bool; the static type of e is then bool.

Assume that e is a case expression of the form e1 case P => e2. A compile-time error occurs if the result type of P is not bool. The static type of e is then T?, where T is the static type of e2.

With pre-feature patterns, it is an error if a pattern of the form P1 || .. || Pn declares different sets of variables in different operands Pi and Pj, with i and j in 1 .. n and i != j. This is no longer an error, but it is an error to access a variable from outside Pi or Pj unless it is declared by every operand P1 ... Pn, with the same type and finality.

The point is that we may well want to access different variables in return patterns: e case A(:final x, y: return.foo(x)) || B(:final a, b: return.bar.baz(a + 1)). For instance, a when clause which is shared among several patterns connected by || cannot use a variable like x, but the return pattern return.foo(x) can use it.

Variables which are declared by a pattern in a case expression are in the current scope of the pattern itself, and in the current scope of the guard expression, if any.

Moreover, such variables are in scope in the first branch of an if statement whose condition is a case expression (just like an if-case statement today), and in the first branch of a conditional expression (that is, (e case A(:final int b)) ? b : 42 is allowed). Finally, such variables are in scope in the body of a while statement whose condition is a case expression.

Dynamic Semantics

Evaluation of a case expression e case P where P has result type bool proceeds as follows: e is evaluated to an object o, and P is matched against o. If the match succeeds then the case expression evaluates to true, otherwise it evaluates to false.

Evaluation of a case expression e case P where P has result type T which is not bool proceeds as follows: e is evaluated to an object o, and P is matched against o, yielding an object r. If r is null then the case expression evaluates to null. Otherwise, r is a function, and the case expression then evaluates to r().

A return pattern of the form T return evaluates to () => v where v is a fresh variable whose value is the matched value when the matched value has type T, otherwise it evaluates to null. A return pattern of the form return evaluates to () => v where v is a fresh variable whose value is the matched value (and it never fails to match).

A return pattern of the form return s1 s2 .. sk where sj is derived from <selector> evaluates to the value () => v s1 s2 .. sk where v is a fresh variable bound to the matched value. A return pattern of the form T return s1 .. sk where sj is derived from <selector> evaluates to the value () => v s1 .. skwherevis a fresh variable bound to the matched value if the matched value has typeT. If the matched value does not have type T` then it evaluates to null.

For example, return.foo() evaluates to () => v.foo() where v is the matched value.

An object pattern that contains one field pattern with result type R, which is not bool, is evaluated by performing the type test specified by the object pattern on the matched value, yielding null if it fails, and otherwise evaluating each of the pattern fields in textual order. If every pattern field yields true, except one which yields a non-null object r then the object pattern evaluates to r. Otherwise the object pattern evaluates to null.

A listPattern, mapPattern, or recordPattern is evaluated in the corresponding manner, yielding the non-null object r from the element whose result type is not bool when all other elements yield true, and yielding null if any element yields false or null.

Consider a logicalAndPattern P of the form P1 && P2 .. && Pn where Pj has result type T which is not bool, and Pi has result type bool for all i != j. P is evaluated by evaluating P1, ..., Pn in that order. If every result is either true (when the result type is bool) or a non-null object r (when the result type is not bool), the evaluation of P yields r. Otherwise it yields null.

Consider a logicalOrPattern P of the form P1 || P2 .. || Pn where Pi has result type Ti, for i in 1 .. n. The case where Ti == bool for all i has the same semantics as today. Hence, we can assume that Ti != bool for every i. Evaluation of P proceeds by evaluating a subset of P1, ..., Pn, in that order. As long as the the result is null, continue. If this step uses all the operands P1 .. Pn then the evaluation of P yields null. Otherwise we evaluated some Pj to a non-null object r, in which case P evaluates to r.

Consider a parenthesizedPattern P of the form (P1). Evaluation of P consists in evaluating P1 to an object r, and P then yields r.

A castPattern P of the form P1 as T evaluates by evaluating P1 to an object r. If r has a run-time type which is T or a subtype thereof then P evaluates to r, otherwise P evaluates to null.

A nullCheckPattern P of the form P1? evaluates by evaluating P1 to an object r. If r is not null then P evaluates to r, otherwise P evaluates to null (that is, P evaluates to r in all cases).

A nullAssertPattern P of the form P1! evaluates by evaluating P1 to an object r. If r is not null then P evaluates to r, otherwise the evaluation of P completes by throwing an exception.

The remaining patterns always have result type bool (constantPattern, variablePattern, and identifierPattern), and they evaluate to true if the match succeeds, otherwise they evaluate to false.

Of course, an implementation may be able to lower patterns containing return patterns and get a behavior which isn't observably different from the semantics specified above without using any function objects, which is perfectly fine (even preferable because it is likely to be faster). However, it is important that the evaluation of a selector chain is only done in the case where the given return pattern "contributes to the successful match". If, in the end, the match fails, then we shouldn't have executed any of those selector chains. Similarly, if we have P1 || P2 and P1 fails but P2 succeeds then we must execute the selector chain for P2, at the very end, but it is not allowed to execute the selector chain for P1.

Versions

  • Nov 1, 2024: Simplified return patterns (they cannot introduce variables any more). Inspired by @tatumizer, I generalized case expressions to support an optional => expression part, e.g., 41 case int() && final value => value + 1.
  • Oct 30, 2024: First version which is reasonably complete.
@eernstg eernstg added feature Proposed language feature that solves one or more problems patterns Issues related to pattern matching. labels Oct 24, 2024
@hydro63
Copy link

hydro63 commented Oct 24, 2024

  1. i'm for case as expression, since imo case should be expression since Dart 3, and shouldn't be left as it's own language construct.

  2. i'm really iffy about the return pattern, or at least the specification as it stands. This is based on the example you've provided, where it took me considerable effort to understand what is actually being returned (without looking at the desugared code).

also, in the grammar for the case expression, is there any need for the with / without cascade division? From what i see, the grammar for both is the same, so unless i'm forgetting something, it could be a single rule, right? Could you please clarify why is not?

@leafpetersen
Copy link
Member

I find some of the return patterns pretty hard to grok. In general, I think the examples here don't do a great job of motivating the feature, maybe there are some better ones that would be more convincing? For the examples above, I think there are better ways to write them in Dart as it exists now.

 // Case expressions of type `bool`.

//  var b1 = 10 case > 0; // OK, sets `b1` to true.
var b1 = 10 > 0;

//  var b2 = 10 case int j when j < 0; // OK, sets `b2` to false.
var b2 = 10 > 0;

  // Case expressions of other types.

//  var s = "Hello, world!" case String(length: > 5) && return.substring(0, 5);
var h = "Hello, world!"; // Might be nice to have let for this example
var s = (h.length > 5) ? h.substring(0, 5) : null

//  var n = [1, 2.5] case [int(isEven: true) && return, _] || [_, double() && < 3.0 && return];
  var n = switch ([1, 2.5]) {
          [int(isEven: true) && var i, _] => i,
          [_, double() && < 3.0 && var d] => d,
          _ => null
        };


  Object? value = (null, 3);
//  var i = value case (num? _, int return j) when j.isOdd;
var i = switch (value) { (num? _, int j) when j.isOdd => j, _ => null}

@hydro63
Copy link

hydro63 commented Oct 24, 2024

@leafpetersen if we are talking purely about case being an expression / operator, then i'm actually for it. I'm sure that i'm not the only one that has wanted to use case in loops or tried to use case as a comparison operator. I have already thought about wanting to use case as a normal operator rather than as it's own construct.

if we are talking about the return pattern, i am against it purely because i can't read it. If there was more readable way to do it, i wouldn't mind.

@leafpetersen
Copy link
Member

I'm sure that i'm not the only one that has wanted to use case in loops or tried to use case as a comparison operator.

Would you mind sharing some motivational examples? It's useful to see examples of how folks could imagine using a feature.

@hydro63
Copy link

hydro63 commented Oct 24, 2024

@leafpetersen First of all, i want to admit, that when i was looking for examples for the case as expression / operator, it took me a long time to find an actual use-case, which made me lose confidence in this feature.

I remember wanting it, when i was writing parser combinator, but since it was only a hobby project, i didn't backup the code (*), and now i can't find it. There are other project where i wanted to use it (at least in some capacity), like recursive descent parser and DFA to regex transpiler, but i also didn't backup the projects.

But before you write off this feature, here are the use-cases i found:

  1. boolean / filtering functions
final list = [...];
final filtered = list.where((e) => e case Foo(:int prop) || Bar(:int prop) when prop > 5);

// or
final filtered = list.where((e) {
  return e case Foo(:int prop) || Bar(:int prop) when prop > 5;
});
  1. combining while(true) and if() break + possible destructuring inside the condition
// example 1 - when i was doing recursive descent parser
// nextTok -> Token?
while(nextTok case Token(type: .string || .number, :final value)){
  // can use value, and other fields we destructure 
  ...
}

// example 2
// parsePrimary() -> PrimaryNode?
while(parsePrimary() case var node?){
}

// without it - example 1
while(true){
  if(nextTok == null || ![TokenType.string, TokenType.number].contains(nextTok.type)) break;
  final value = nextTok.value;
}

// without it - example 2
while(true){
  final node = parsePrimary();
  if(node == null) break;
}

In essence, after thinking about it, i think the use-cases for both are niche, but are also quite good and don't cause many problems. In the first case, the destructured variables would be scoped to the case expression, and in the while case, they would be passed to the block.

These are all the uses i found, so you can decide if you wish to have it, or not. Either way, i won't complain whether it's implemented or not.

*i hate overusing git for short projects

@leafpetersen
Copy link
Member

@hydro63 thanks!

I think the while example is compelling, and indeed there is already an issue tracking this from when we shipped the original feature.

The boolean case has also been raised elsewhere.

I'd suggest giving those issues a thumbs up since I think they will likely remain the canonical issues for those specific sub-features.

@tatumizer
Copy link

@eernstg:

Are going to support this, too?

var x = a > b && return a || return b;

@hydro63
Copy link

hydro63 commented Oct 25, 2024

Are going to support this, too?

@tatumizer i suppose not. Disregarding the missing case keyword, patterns currently don't allow comparing with non constant expression, meaning it would be a compile error.

Edit - i thought the comment was referencing this issue, but it was instead referencing another proposal.

@eernstg
Copy link
Member Author

eernstg commented Oct 25, 2024

Here are some examples where case expressions are useful, based on the document Dart Patterns -- Survival of the fittest:

// Currently in Flutter code.

String? runtimeBuildForSelectedXcode;
final Object? iosDetails = decodeResult[iosKey];
if (iosDetails != null && iosDetails is Map<String, Object?>) {
  final Object? preferredBuild = iosDetails['preferredBuild'];
  if (preferredBuild is String) {
    runtimeBuildForSelectedXcode = preferredBuild;
  }
}

// Using current patterns.

final String? runtimeBuildForSelectedXcode = switch (decodeResult[iosKey]) {
  {'preferredBuild': final String preferredBuild} => preferredBuild,
  _ => null,
};

// Using a case expression.

final String? runtimeBuildForSelectedXcode =
    decodeResult[iosKey] case {'preferredBuild': String return};

The main abbreviation above comes from the fact that a case expression that contains a return pattern evaluates to null when the match fails. However, that does seem to be a useful behavior to get implicitly.

Note that this semantics is similar to the semantics of expressions like a?.b?.c: The expression as a whole evaluates to null if "anything went wrong" during the evaluation, and the enclosing code can detect this and act accordingly.

It could be argued (for expressions using null shorting like a?.b?.c as well as for case expressions) that we can also get null in other ways (e.g., a is non-null, a.b is non-null, but a.b.c is null). We must take care to avoid interpreting the result null to mean "the situation I required doesn't exist" if it really means "the situation does exist, but we successfully looked up null". However, this doesn't seem to create huge issues today (e.g., with null shorting), and it should also be OK to have to handle it with case expressions. After all, the exact same issue comes up with a switch expression that includes a default case of the form _ => null.

// Currently in Flutter code.

T? get currentState {
  final Element? element = _currentElement;
  if (element is StatefulElement) {
    final StatefulElement statefulElement = element;
    final State state = statefulElement.state;
    if (state is T) {
      return state;
    }
  }
  return null;
}

// We could already simplify this using pre-existing Dart features.

T? get currentState {
  final Element? element = _currentElement;
  if (element is StatefulElement) {
    final State state = element.state;
    if (state is T) return state;
  }
  return null;
}

// Using current patterns.

T? get currentState => switch (_currentElement) {
  StatefulElement(:final T state) => state,
  _ => null,
};

// Using a case expression.

T? get currentState => _currentElement case StatefulElement(state: T return);

The most interesting part above is probably the type tests: The case expression evaluates to null if _currentElement isn't a StatefulElement, or if it is a StatefulElement whose state doesn't have the type T (which is a type variable of an enclosing class). In other words, we're describing a situation that includes several assumed types, and we're using (_currentElement as StatefulElement).state if everything is OK. If anything isn't OK then we just return null. We can say this concisely because "anything isn't OK" is an implicit property of several parts of this expression.

// Currently in Flutter code.

class ParsedProjectGroup {
  ParsedProjectGroup._(this.identifier, this.children, this.name);

  factory ParsedProjectGroup.fromJson(String key, Map<String, Object?> data) {
    String? name;
    if (data['name'] is String) {
      name = data['name']! as String;
    } else if (data['path'] is String) {
      name = data['path']! as String;
    }

    final List<String> parsedChildren = <String>[];
    if (data['children'] is List<Object?>) {
      for (final Object? item in data['children']! as List<Object?>) {
        if (item is String) {
          parsedChildren.add(item);
        }
      }
      return ParsedProjectGroup._(key, parsedChildren, name);
    }
    return ParsedProjectGroup._(key, null, name);
  }

  final String identifier;
  final List<String>? children;
  final String? name;
}

// Using current patterns.

class ParsedProjectGroup {
  ParsedProjectGroup.fromJson(this.identifier, Map<String, Object?> data)
      : children = switch (data['children']) {
          final List<Object?> children => children.whereType<String>().toList(),
          _ => null,
        },
        name = switch (data) {
          {'name': final String name} => name,
          {'path': final String path} => path,
          _ => null,
        };

  final String identifier;
  final List<String>? children;
  final String? name;
}

// Using a case expression.

class ParsedProjectGroup {
  ParsedProjectGroup.fromJson(this.identifier, Map<String, Object?> data)
      : children = data['children'] case List return.whereType<String>().toList(),
        name = data case {'name': String return} || {'path': String return};

  final String identifier;
  final List<String>? children;
  final String? name;
}

In the example above it's worth noting that e case T return works like the safe cast operation which is requested in #399, that is e as? T. Another reason why the expression is concise is that we can use return in two different OR-branches of the second case expression, using the value of the key 'name' in one case and 'path' in the other case.

// Currently in Flutter code.

int? findIndexByKey(Key key) {
  if (findChildIndexCallback == null) {
    return null;
  }
  final Key childKey;
  if (key is _SaltedValueKey) {
    childKey = key.value;
  } else {
    childKey = key;
  }
  return findChildIndexCallback!(childKey);
}

// Using current patterns.

int? findIndexByKey(Key key) {
  late final Key childKey = switch (key) {
    _SaltedValueKey(:final Key value) => value,
    final Key key => key,
  };

  return findChildIndexCallback?.call(childKey);
}

// Using a case expression.

int? findIndexByKey(Key key) => findChildIndexCallback?.call(
  key case _SaltedValueKey(value: Key return) || return
);

In the example above we again get rid of some repetition of names. Note that we still avoid evaluating the key when findChildIndexCallback is null.

// Currently in Flutter code.

Diagnosticable? _exceptionToDiagnosticable() {
  final Object exception = this.exception;
  if (exception is FlutterError) {
    return exception;
  }
  if (exception is AssertionError && exception.message is FlutterError) {
    return exception.message! as FlutterError;
  }
  return null;
}

// Using current patterns.

Diagnosticable? _exceptionToDiagnosticable() => switch (exception) {
  AssertionError(message: final FlutterError error) => error,
  final FlutterError error => error,
  _ => null,
};

// Using a case expression.

Diagnosticable? _exceptionToDiagnosticable() => exception case
  AssertionError(message: FlutterError return) || FlutterError return;

This example once again uses a case expression to obtain a value with a choice: If we're looking at an AssertionError whose message is a FlutterError then we will use that message, if we're looking at a FlutterError we'll use that, and everything else will be ignored (that is, replaced by null).

// Currently in Flutter code.

final bool hasError = decoration.errorText != null;
final bool isFocused = Focus.of(context).hasFocus;

InputBorder? resolveInputBorder() {
  if (hasError) {
    if (isFocused) {
      return decoration.focusedErrorBorder;
    }
    return decoration.errorBorder;
  }
  if (isFocused) {
    return decoration.focusedBorder;
  }
  if (decoration.enabled) {
    return decoration.enabledBorder;
  }
  return decoration.border;
}

// Using current patterns.

final bool isFocused = Focus.of(context).hasFocus;

InputBorder? resolveInputBorder() => switch (decoration) {
  InputDecoration(errorText: _?) when isFocused => decoration.focusedErrorBorder,
  InputDecoration(errorText: _?)                => decoration.errorBorder,
  InputDecoration() when isFocused              => decoration.focusedBorder,
  InputDecoration(enabled: true)                => decoration.enabledBorder,
  InputDecoration()                             => decoration.border,
};

// Alternative solution using current patterns.

InputBorder? resolveInputBorder() => switch ((
  enabled: decoration.enabled,
  focused: Focus.of(context).hasFocus,
  error: decoration.errorText != null,
)) {
  (enabled: _,     focused: true, error: true) => decoration.focusedErrorBorder,
  (enabled: _,     focused: _,    error: true) => decoration.errorBorder,
  (enabled: _,     focused: true, error: _)    => decoration.focusedBorder,
  (enabled: true,  focused: _,    error: _)    => decoration.enabledBorder,
  (enabled: false, focused: _,    error: _)    => decoration.border,
};

// Using a case expression (and assuming #4124).

final bool isFocused = Focus.of(context).hasFocus;

InputBorder? resolveInputBorder() => isFocused
    ? decoration case _(errorText: _?) && return.focusedErrorBorder ||
          return.focusedBorder
    : decoration case _(enabled: true) && return.enabledBorder ||
          return.border;

In this last example we're inspecting the given decoration when focused, "returning" its focusedErrorBorder if it has an errorText, otherwise its focusedBorder; and similarly for the two cases where it isn't focused.

@eernstg
Copy link
Member Author

eernstg commented Oct 25, 2024

@hydro63 wrote:

is there any need for the with / without cascade division?

It allows a case expression to occur in a location where an <expressionWithoutCascade> is required. The main case would be in cascades:

void main {
  var x = MyClass()
    ..property1 = e1 case P1
    ..property2 = e2 case P2;
}

It's probably a good trade-off to accept the slightly more complex grammar in return for being able to use case expressions in places like this. Otherwise we would just have to put () around the case expression, but that may seem inconvenient, especially if viewed by someone who knows that this requirement could easily have been avoided. ;-)

I'm sure that i'm not the only one that has wanted to use case in loops

The special exception about case expressions in if statements is that the variables they declare are in scope in the body.

However, it should certainly be possible to allow a while loop to have a case expression as its condition, with the same special treatment of the scope, and similarly for a few other constructs (probably not do-while because it would be really weird to have declarations at the end of a block where they are in scope).

In all other locations we can use a case expression as an expression, but the variables that it declares are not in scope anywhere other than in the when clause of the case expression itself, if any.

@leafpetersen wrote:

I think the examples here don't do a great job of motivating the feature

Indeed, the examples in the original posting were only intended to illustrate the semantics. A set of more meaningful examples can be found here.

@tatumizer wrote:

Are going to support this, too?

var x = a > b && return a || return b;

I don't quite know how this would generalize, but var x = a > b ? a : b; seems to do the job for this example.

However, we can use a case expression to avoid declaring some variables (and writing those names twice):

abstract class A { int get a; }
abstract class B { double get b; }

void foo(Object o) {
  // Using a case expression.
  var x = o case A(a: return) || B(b: return); // `x` has type `num?`.
  
  // Using a switch expression.
  var y = switch (o) {
    A(:final a) => a,
    B(:final b) => b,
    _ => null,
  };
}

We can also use it to perform a bit of processing on the objects that we've found via pattern matching:

abstract class A { double get a; }
abstract class B { String get b; }

void foo(Object o) {
  var x = (o case A(a: return.toInt()) || B(b: return.length)) ?? 42; // `ab` has type `int`.
}

@tatumizer
Copy link

@eernstg:
The use of return in expressions has been a subject of debate.
Some commenters suggested that the syntax of conditional expression cond ? a : b can be expanded by adding support for return (after all, we allow throw, which makes return a legitimate option).
However, the suggested meaning of return in var x = a > b ? return a : b was different: it was supposed to signify the return from the containing function.

The question closely related to this: consider

void foo(Object o) {
  var x = o case A(a: return) || B(b: return);
  // VS 
  o case A(a: return) || B(b: return);    
}

Is the second expression allowed?
If not, on what grounds?

@eernstg
Copy link
Member Author

eernstg commented Oct 25, 2024

The use of return in expressions has been a subject of debate.

Right, that's probably #2025. That would be a separate proposal, and it wouldn't conflict with this one (in this proposal return is parsed as a pattern, but in #2025 it is parsed as an expression).

The different forms of return pattern do not give rise to a returning completion of the current expression evaluation or statement execution, they just specify the value of the current pattern during a pattern matching process.

void foo() {
  var x = 1 case return;
  print('Still here!'); // Yes, this will be printed, and `x` has the value 1.
}

.. the suggested meaning of return in var x = a > b ? return a : b was different: it was supposed to signify the return from the containing function.

Exactly. And the parser will have no difficulties knowing that return a is an expression in this case.

void foo(Object o) {
  var x = o case A(a: return) || B(b: return); // OK.
  // VS 
  o case A(a: return) || B(b: return); // OK.
}

Both case expressions will investigate the object o; if it's an A then the case expression evaluates to (o as A).a; if it's a B then the case expression evaluates to (o as B).b; otherwise it evaluates to null.

In line 1 of the body of foo, this result is stored in x. In line 3 it is discarded. That's OK, even though it wouldn't be very useful in this case. If you just want to have the side effects of calling a respectively b then you could do o case A(a: _) || B(b: _);.

@tatumizer
Copy link

tatumizer commented Oct 25, 2024

That would be a separate proposal, and it wouldn't conflict with this one (in this proposal return is parsed as a pattern, but in #2025 it is parsed as an expression).

The compiler can certainly parse the same word differently in different contexts, but the problem is that the user has to parse it in the head, which may lead to confusion. There's nothing magical in the case keyword that would suggest a different interpretation of the subsequent "return". Or you think there is?


Unrelated: I think you are overdoing it in B(b: return.length). Is there a precedent for this? Could you also write B(b: return +1)? Or the syntax is reserved for chains only? Why not require the explicit value in return, like:
B(b: return b.length)

@lrhn
Copy link
Member

lrhn commented Oct 26, 2024

I've wanted case-expressions for a long time. Wanted them in a while statement today, and have wanted them in conditional expressions and for-loop conditions before.
Had to day what the scope of the variables bound by the test expression is. Definitely anything guarded by the test .
Probably only that, like the variables of an if-case only begging in scope in the then-branch.

The return pattern... Not sold!
It feels like it's mixing expressions and patterns, and I think that's a very bad idea for readability.

Something like e case (P1 && return) || (P2 && return) looks like JavaScript logical expressions.
Just do a switch, or add expression variable declarations and do

((var tmp = e) case P1) ? v1 : 
  (tmp case P2) ? v2 : null

It's not totally clear that return short-circuits pattern evaluation. It probably has to, but that means it can only occur in tail position.
You can't do: case: return && P2 because P2 is unreachable.
(I can see why using a control flow keyword makes sense. Why not break? If we get break-with-value for breaking out of expressions, this mighty fit that pattern better than return. After all it's function local control flow.)

Otherwise, the best way I have found to think about the return is as a special variable binding for the implicit " Return value variable". Which also short-circuits.

The retuen.focusedErrorBorder breaks that understanding. It looks like something that happens after returning. And it's not a pattern. The pattern would be _(focusedErrorBorder: return).

The pattern e case SomeType return occurs a few times. That's precisely what e as? SomeType would do, and I'd prefer as? for that.

(I'm fine with making return an expression, but then it will actually return from the curb m current function.)

@tatumizer
Copy link

FWIW, among the examples given above in #4141 (comment) I find the ones marked as "using current pattern syntax" most readable. With "return patterns", the notation is shorter, but you have to pay for brevity by an extra effort of re-scanning the text back and forth in an attempt to parse it into a more human-friendly representation, which (arguably) is close to the "using current syntax" variant.

An interesting experiment in literary form though 👍 😄

@hydro63
Copy link

hydro63 commented Oct 26, 2024

My biggest problem with return pattern is that i don't really know what is being returned, even after reading it multiple times. If they at least had clearly marked return value (=> val), just like switch expressions, i would be able to sort of get it. But even then, i'd probably get lost in the multiple branches that you would try to stuff into a single pattern.

The return pattern is a hard no for me.

@eernstg
Copy link
Member Author

eernstg commented Oct 28, 2024

@tatumizer wrote:

There's nothing magical in the case keyword that would suggest a different interpretation of the subsequent "return". Or you think there is?

I do agree that we have to tread carefully when we introduce a new language construct using existing special words (reserved words, built-in identifiers, or even regular identifiers like on and hide that are known to the grammar). It is certainly possible that this can give rise to confusion, especially for readers for whom the new construct is unknown, or at least not very well-known.

However, we have lots of situations where these special words have different meanings in different contexts, and so do other terms. For example: The on clause of a mixin is completely different from the on type of an extension, and from the on clause of a try/catch statement, and from a variable or type whose name is on. The extends clause of a class/enum declaration is completely unrelated to the use of extends to declare an upper bound for a type variable. The as clause of an import directive is completely unrelated to the type cast expression that uses as as an operator. And so on.

For patterns, in particular, we have the following distinction: In an irrefutable context, a plain identifier that occurs as a pattern denotes a new local variable which is being introduced into the current scope (like x and y below). In a refutable context it denotes a reference to a constant variable in scope (like c in case c:).

void main() {
  const c = 1;
  var (x, y) = (c, c + 1);
  switch (x) {
    case c: print('Match!');
  }
}

In other words, it's definitely not a new thing that a reader of code needs to take the context into account in order to recognize the meaning of a special word or even an apparently very simple expression.

I think the same thing will work for return patterns: When return is encountered in a pattern, it is a (or is part of a) return pattern. Recognizing this will be a natural step to take for anyone who reads the code (as soon as they've gotten acquainted with return patterns, of course).

Unrelated: I think you are overdoing it in B(b: return.length). Is there a precedent for this? Could you also write B(b: return +1)? Or the syntax is reserved for chains only? Why not require the explicit value in return, like:
B(b: return b.length)

The idea is that a case expression e case P or e case P when b allows us to evaluate an expression (e) and explore the result (by matching the pattern P against the value of e), confirming that it satisfies some requirements (specified as subpatterns of P or in b) and navigating from the given object as needed (which is also part of pattern matching), and then specify the value of the case expression as a whole in terms of the matched value at the point where a return pattern is being matched.

We could certainly restrict return patterns such that they can only allow us to say that the value of the case expression is the currently matched object. However, I expect the ability to compute a value based on that matched object to be quite useful.

Currently I'm just proposing that a return pattern admits a chain of selectors, which will work without issues in the grammar. More general expressions are probably possible, but I suspect that they will create difficulties for the parser, and possibly also for a human reader of the code. That may or may not be true. In any case, we can explore generalizations in that direction if it seems to be useful and manageable.

In some cases you can just move the selector chain out of the case expression, but if you wish to treat different matched values differently then it won't work so easily:

class A {}
class B1 extends A {
  List<int> get b1 => [42];
}
class B2 extends A {
  double get b2 => 4.2;
}

void foo(A a) {
  // Use return patterns in the same way everywhere.
  var s = a case B1(b1: return.toString()) || B2(b2: return.toString());

  // Not using it: In this case we can simply move the selector chain out.
  var s2 = (a case B1(b1: return) || B2(b2: return)).toString();

  // Using return patterns with different selector chains.
  var i = a case B1(b1: return.length) || B2(b2: return.toInt());

  // Not using it: Requires a full-blown switch expression.
  var i2 = switch (a) {
    B1(:final b1) => b1.length,
    B2(:final b2) => b2.toInt(),
    _ => null,
  };
}

The reasons why I haven't proposed that the return pattern should specify a general expression (like return e where e is an <expression>) are at least the following:

  • This is a brevity mechanism, and you can always rewrite a case expression to a somewhat larger switch expression which is equivalent. Hence, we don't want to make case expressions more verbose in order to handle a broader set of situations, unless the added expressive power is considered to be worth the added noise for all other case expressions.
  • If we'd use return e then we would have to introduce a name for the matched value. It could be a new, predefined name (a "context-dependent reserved word") like matchedValue or it; or it could be a special word like this; or we could require that the name is somehow already declared by the developer (as in B(b: return b.length), but it isn't obvious how we'd handle e case P && return because return needs to return the matched value which is the value of e, but we don't have a declared name for it at that point). So this is a problem that we'd have to solve.

Note that the ability to have a selector chain does not introduce extra syntax in the basic case where we just use a plain return.

Of course, extension methods already allow us to turn an arbitrary expression into a getter or method invocation, that is, into a selector chain:

extension<X> on X {
  Y doCall<Y>(Y Function(X) callback) => callback(this);
}

void foo(int i) => i case return.doCall((self) => 17 ~/ self);

I don't think this would be a commonly used technique, but it might be useful if we're repeatedly encountering situations where we'd like to use a return pattern with a more general kind of expression than a selector chain.

@eernstg
Copy link
Member Author

eernstg commented Oct 28, 2024

@lrhn wrote:

I've wanted case-expressions for a long time. Wanted them in a while statement today, and have wanted them in conditional expressions and for-loop conditions before.
Had to day what the scope of the variables bound by the test expression is. Definitely anything guarded by the test .
Probably only that, like the variables of an if-case only begging in scope in the then-branch.

Sounds like case expressions whose pattern does not contain any return patterns. Very good, that should just work out of the box for while and ?:.

I'm not so sure about for ... where would the case expression occur?

We agree completely about the scoping, that variables introduced by the pattern are in scope in the body of the while loop and the first branch of the conditional expression (that is, for b case P when b ? e1 : e2, variables declared by P are in scope for e1).

The return pattern... Not sold!

Surprise, surprise. ;-)

It feels like it's mixing expressions and patterns, and I think that's a very bad idea for readability.

return statements have two aspects: (1) They specify the value which will be the result of the current invocation of the nearest enclosing function (for return e; it is the result of evaluating e and for return; it is null). (2) they cause the execution of the current function body to complete returning said value.

A return pattern only specifies the value of the nearest enclosing case expression, it does not have any control flow effects. In particular, we don't stop matching the pattern of a case expression just because a return pattern was matched successfully, instead we remember the matched value (and the selector chain, if any), and continue to match the rest of the pattern.

It is enforced by static analysis that the pattern matching step will never find multiple matched values for any pattern containing one or more return patterns.

(This is very similar to the analysis that makes it a compile-time error to use the same variable name too few or to many times in a pattern: e case C(c: return) || D(enabled: true, d: return) is fine, just like switch (e) { C(c:final T result) || D(enabled: true, d:final T result) => result }, but e case C(c: return) || D(enabled: true) is an error because the second subpattern of the OrPattern leaves the returned value undefined, and e case C(c1: return, c2: return) is an error because it leaves the returned value overconstrained.)

For e case P, if the value of e fails to match P then the value of e case P is null. Otherwise, the value of e case P is the result of evaluating the given selector chain on the given matched value.

class A {
  final bool b;
  final int i, j;
  A(this.b, this.i, this.j);
}

extension on int {
  int effect() {
    print('Working on $this!');
    return this + 10;
  }
}

void main() {
  var x = A(false, 1, 2) case A(i: return.effect(), b: true) || A(b: false, j: return.effect());
}

This will invoke i, obtain 1, mark it as the matched value (on the condition that the first subpattern of the OrPattern matches), then invoke b, obtain false, and determine that the first subpattern of the OrPattern did not match, then unbind the matched value, and proceed with the second subpattern of the OrPattern: obtain the value of b from cache, confirm that it is false, invoke j and obtain 2, then conclude that the second subpattern of the OrPattern matched, so the entire pattern matched, and the matched value is 2, and the associated selector chain is .effect(), which means that Working on 2 is printed, and x is initialized to have the value 12.

Something like e case (P1 && return) || (P2 && return) looks like JavaScript logical expressions.

Surely they have a different semantics. I'd just recommend not thinking in terms of JavaScript when trying to read Dart code.

Just do a switch, or add expression variable declarations and do

((var tmp = e) case P1) ? v1 : 
  (tmp case P2) ? v2 : null

This is an abbreviation mechanism, obviously you can express the same thing using existing syntax (but you may have to do some upper bound computations on types manually in order to be able to rewrite a case expression as a somewhat longer switch expression).

For e case (P1 && return) || (P2 && return), it would be guaranteed that P1 and P2 do not contain any return patterns (because that would be a compile-time error because it over-constrains the value), so you should be able to use any of the following:

void main() {
  // Original expression.
  var x1 = e case (P1 && return) || (P2 && return);

  // Alternative ways to do the same thing.

  var x2 = e case (P1 || P2) && return; // Seems natural.

  var x3 = switch (e) { // A bit longer.
    (P1 || P2) && var tmp => tmp,
    _ => null,
  };

  final tmp = e;
  var x4 = tmp case P1 || P2 ? tmp : null; // Needs the separate `tmp` declaration.

  SomeSuitableType? x5; // Initially null.
  if (e case (P1 || P2) && final tmp) x5 = tmp;
}

It's not totally clear that return short-circuits pattern evaluation. It probably has to, but that means it can only occur in tail position.

Certainly not. I'd much prefer to maintain the semi-declarative nature of patterns. That is, patterns look declarative, and they mostly work in a declarative manner because the invoked getters generally do not have observable side effects.

The true semantics is, of course, that every getter invocation (or operator invocation, etc) that a pattern matching process can perform can have arbitrary side effects, and (in general, in principle) we always need to be aware of the evaluation order of every single subpattern of any pattern, and their potential side-effects.

However, that isn't going to get significantly worse if we add return patterns.

You can't do: case: return && P2 because P2 is unreachable.

Yes, you can, but it's a compile-time error if P2 contains any return patterns. The value of the case expression will be the value of the expression at the left end (omitted above), or null if P2 doesn't match.

(I can see why using a control flow keyword makes sense. Why not break? If we get break-with-value for breaking out of expressions, this mighty fit that pattern better than return. After all it's function local control flow.)

break would signal "this is about control flow" and that's the opposite of what I want.

Otherwise, the best way I have found to think about the return is as a special variable binding for the implicit " Return value variable". Which also short-circuits.

That sounds right! (except that it doesn't short-circuit anything)

The retuen.focusedErrorBorder breaks that understanding. It looks like something that happens after returning. And it's not a pattern. The pattern would be _(focusedErrorBorder: return).

It is a pattern because the proposal specifies that it is a pattern. ;-) Just like 1_000_000 is an integer literal because we specified that it is an integer literal, even though it wasn't previously.

The return pattern return.focusedErrorBorder differs in a subtle way from _(focusedErrorBorder: return) in that I'm proposing that the former evaluates theMatchedValue.focusedErrorBorder if this return pattern is part of the successful matching. The latter, _(focusedErrorBorder: return) will evaluate focusedErrorBorder during matching.

Of course, it would be possible to specify the mechanism such that it will evaluate the selector chain of every return pattern which is matched, but I think it's more useful to only evaluate the selector chain in the case where we have actually succeeded in a complete match of the top-level pattern P in a case expression of the form e case P. You could say that "only the return pattern whose match was used to obtain a successful match will have its selector chain executed."

The purpose of this selector chain is to proceed with further computations that are needed by the receiver of the value of the case expression. The reason why it's convenient to be able to specify the selector chain on each return pattern (rather than using (e case P).the().selector().chain()) is that the matched value may have different types in different subpatterns, and we may also wish to do different things with it before it can serve as the result of the case expression.

I do agree that the computations performed in the selector chain are not about pattern matching (so they can't be expected to be essentially side-effect free, unlike the pattern matching itself). They are about post-processing of the matched value for a given purpose.

The pattern e case SomeType return occurs a few times. That's precisely what e as? SomeType would do, and I'd prefer as? for that.

Sure, but it's hardly realistic to require that any given language mechanism must be unable to express the same semantics as some other construct (already in the language, or proposed).

(I'm fine with making return an expression, but then it will actually return from the curb m current function.)

I'd like that, too!

But an expression of the form return e or return (returning null) is a different topic, so I won't go into that here.

@eernstg
Copy link
Member Author

eernstg commented Oct 28, 2024

@hydro63 wrote:

My biggest problem with return pattern is that i don't really know what is being returned, even after reading it multiple times.

I hope this helps: For a plain return, it's the matched value where the return pattern occurs:

var x1 = [1, 2, 3] case [_, return, ...]; // `x1 == 2`.
var x2 = [1, 2, 3] case [_, ...return]; // `x2 == [2, 3]`.
var x3 = [1, 2, 3] case List<int>(length: > 2) && return; // `x3 = [1, 2, 3]`.

For a T return, it's the matched value where this return pattern occurs, but it only succeeds if the matched value has type T. For a final return id it provides two ways to access the matched value: (1) it determines the result of the enclosing case expression, if the pattern where this return pattern occurs succeeds (that is, same thing as a plain return), and (2) it introduces the identifier id (which could be used just like other variables introduced by this pattern, e.g., in a when clause). Similar rules work for final T return id and var return id.

For a return pattern with a selector chain, e.g., return.m(expr), if the matching step succeeded, and this return pattern was part of the successful match, and the matched value was o, then the value of the case expression is o.m(expr).

@tatumizer
Copy link

tatumizer commented Oct 28, 2024

@eernstg:

var x = A(false, 1, 2) case A(i: return.effect(), b: true) when i > 15;

This still looks like an experiment in form - James Joyce kind of thing: you have to scan the sentence back and forth and translate it to a simpler representation in your head. This kind of unorthodox prose may appeal to some readers, but in Dart, this style would feel slightly out of place IMO. I don't question the originality of the idea, and appreciate it, but developing fluency in reading programs written in this style would require a long painstaking practice IMO. 😄

(Also of note is that there's not one, not two, but several syntax ideas here that don't rhyme with anything in dart)

@lrhn
Copy link
Member

lrhn commented Oct 28, 2024

I'm not sure it makes sense for a pattern with a return to also have a when clause. At least a pattern with a return clause can exit without binding later variables, so the when clause can't refer to any variable that occurs after a return.
But I guess each return is at the end of a || separated pattern, and those don't share variables unless they all declare the same variable.

The return just reads too weirdly. It's a refutable pattern, and defaulting to null for "no match" (and presumably a failing when?) is more default than I'd like.

I'd prefer to do something like

var x = [1, 2, 3] case [_, return, ...];

as something like:

var x = let [_, result, ...] = [1, 2, 3] in result;

... or just like we can do today:

var [_, x, ...] = [1, 2, 3];

That keeps patterns for themselves and expressions for them selves, and doesn't try to make patterns have a value, except when they don't.

@eernstg
Copy link
Member Author

eernstg commented Oct 29, 2024

@tatumizer

you have to scan the sentence back and forth and translate it to a simpler representation in your head

var x = A(false, 1, 2) case A(i: return.effect(), b: true) when i > 15;

Let's try.

We can skip A(false, 1, 2) because the scrutinee expression is just an expression, and it shouldn't be harder to read than any other expression in the language. So let's assume that we can read the expression and see case rather easily, or at least with no greater difficulty than other expressions in the language.

Next, the keyword case itself indicates that the following thing is a pattern. This is true for switch statements and for if-case statements, so that should be a well-known signal.

For the pattern itself, I'd like to promote readings that are focused on the declarative nature of the pattern syntax. It looks like we're describing a situation, in a way that doesn't involve side effects or evaluation orders, we're just looking at the situation as a motionless picture.

Of course, the actual detailed semantics is that there is a very specific evaluation order, and side effects could make the whole thing behave in completely different ways if you reorder a couple of subpatterns. But I'd claim that it is bad style to write code where evaluation order and side effects play an observable role for a pattern matching operation. Imperative semantics should be expressed using imperative style coding. The style guide already helps, because getter invocations aren't supposed to have observable side effects.

The example above was written specifically in order to focus on the details of the evaluation order, because this is about language design, and the detailed semantics must obviously be well-defined. So I wrote an example where the elements are written in a somewhat quirky and surprising order, simply because I wanted to make the ordering of side effects noticeable, and in order to cover some not-so-obvious cases. In particular, I'd expect the above to be written as A(b: true, i: return.effect()) rather than the opposite order, but we still need to know how the program should behave in both cases.

A return pattern with a selector chain does play a separate role in the semantics. At the end, when we have checked that everything matches the given pattern, the return pattern return.effect() is executed by (1) replacing return by the matched value v, and (2) computing the expression v.effect(), which is then the value of the case expression as a whole.

The fact that this happens at the very end is unusual, but I also think it's the most comprehensible timing: Nobody promises that a selector chain won't have side effects, and we definitely don't want return.effect() to be executed in the case where the enclosing pattern fails to match. One perspective that you could use is illustrated by the following rewrite:

var x = switch (A(false, 1, 2)) {
  A(i: final result, b: true) when i > 15 => result.effect(),
  _ => null
};

In summary, I do recognize that the detailed evaluation order and handling of side effects must be well defined, but I do not think it's good style to write code where the evaluation order and side effects matter during pattern matching (with or without return patterns, that is!). The only thing to be aware of is that a return pattern (including the ones with a selector chain) is executed at the very end, when a pattern match has been successfully achieved.

That should be more reader-friendly than 'scan the sentence back and forth'.

@eernstg
Copy link
Member Author

eernstg commented Oct 29, 2024

@lrhn wrote:

a pattern with a return clause can exit without binding later variables, so the when clause can't refer to any variable that occurs after a return.

A return pattern does not prevent pattern matching from proceeding, and variables would be bound if and only if the same variables would be bound if the return pattern were replaced by a wildcard pattern. Hence, the when clause can refer to variables in the same way as today.

But I guess each return is at the end of a || separated pattern

I'm sure that would be a very natural way to use this feature.

I'd prefer to do something like

var x = [1, 2, 3] case [_, return, ...];

as something like:

var x = let [_, result, ...] = [1, 2, 3] in result;

... or just like we can do today:

var [_, x, ...] = [1, 2, 3];

Sure, if the case expression occurs as the initializing expression of a local variable then we can just edit the whole declaration and make it a <patternVariableDeclaration>.

However, that won't work if the case expression occurs in any other context. For instance, if the variable isn't local, or if the case expression isn't used to initialize a variable at all, but is passed as an actual argument to a method invocation.

Another crucial difference is that the pattern in the case expression is in a refutable position whereas the pattern in the pattern variable declaration (and presumably in the let expression) is in an irrefutable position. This means that we can't do anything that isn't guaranteed to succeed.

For case expressions, the fact that we're exploring a situation and may not have it is at the core of the feature. That's the reason why a direct and faithful rewrite of a case expression uses a switch expression:

var x = switch ([1, 2, 3]) {
  [_, final result, ...] => result,
  _ => null,
};

@lrhn
Copy link
Member

lrhn commented Oct 29, 2024

I'm sure that would be a very natural way to use this feature.

But not necessary. Got it!
The return does not actually return, it just binds an anonymous variable that then becomes the result of the pattern match expression when it completes successfully. Like a Pascal return value assignment.

That's an easier model to reason about.
Then return.foo is a questionable feature. If the selectors are evaluated in the middle of the pattern, then it's inconsistent. (If some of the same selectors have already been cached, should the cached value be used?)
If they're only evaluated at the end of a successful pattern match, then evaluation order is harder to grok.

If anything, I'd just treat return (or heck, use yield), as a special variable name.
Binding that name makes the pattern match expression evaluate to a user chosen value if it matches, and null if not, instead of true/false.
There is no new pattern syntax, it's just that "variable name" contains an extra reserved word.
(And the type of the expression can be non-nullable if the pattern is irrefutable and binds the value on all paths).
But then, having to write var return isn't an improvement on return.

Still not sure the feature is worth it. It's just a shorthand for switch (e) { ... var it ... => it} plus , _ => null if refutable.

It's also not general enough to, fx, extract two values.
If I have two lists and want the last element of each, I can't do (pair case ([..., return], [..., return])), so I'll still have to do switch (pair) { ([... var first], [... var second]) => (first, second)}.
Or use an extension plus selectors, if we allow those, to do (pair case ([..., var first], [..., return.pairWith(first: first))])).
(I worry about features that make people want to do workarounds. Either they're not powerful enough, because they can't do what people really want, or they're slightly more powerful than they should be, because it's not something people should do to begin with.)

I think I'd rather just tell people to do:

(pair case ([..., var first], [..., var second])) ? (first, second) : null

Explicit over implicit, for the null too.

If we wanted to add add anything for that, it's an if expression with optional else (defaulting to else null) so you can do:

if (pair case ([..., var first], [..., var second])) (first, second)`

(Or not, that doesn't really read well.)

@eernstg
Copy link
Member Author

eernstg commented Oct 29, 2024

Like a Pascal return value assignment.
That's an easier model to reason about.

Exactly!

return.foo is a questionable feature. ..
If they're only evaluated at the end of a successful pattern match, then evaluation order is harder to grok.

That's true. My assumption is that it is really tempting to allow return.foo() when we'd otherwise have to write a somewhat longer switch expression to get the same thing:

void main() {
  var x1 = e case Something(a: true, b: < 10, return.foo()) || OtherThing(:final c, d: return.bar(c));

  var x2 = switch (e) {
    Something(a: true, b: < 10, final result) => result.foo(),
    OtherThing(:final c, :final d) => d.bar(c)),
  };
}

It's like "I have it right here, let me use it right here!" vs. "OK, we need to use this matched value later on, let's put a label on it; later: use the label".

@tatumizer
Copy link

tatumizer commented Oct 29, 2024

Here's a modest idea: introduce a no-frills case? expression with a single case, returning null on no match. The syntax is the exact copy of the case statement used in the switch expression, just with the added suffix ?.

// using current syntax
T? get currentState => switch (_currentElement) {
  StatefulElement(:final T state) => state,
  _ => null,
};

// Using a case expression as proposed by OP

T? get currentState => _currentElement case StatefulElement(state: T return);

// Using case? expression: no new syntax except `?`
T? get currentState => _currentElement case? StatefulElement(:final T state) => state;

(It can be considered a special case of switch for n=1)

@lrhn
Copy link
Member

lrhn commented Oct 30, 2024

A switch? that never needs to be exhaustive, if its an expression it evaluates to null if there is no match, would be a good match for a switch! that is always forced to be exhaustive.
The latter is something you may sometimes want for switch statements, when inference cannot recognize that the switch is actually exhaustive.

@eernstg
Copy link
Member Author

eernstg commented Oct 30, 2024

A switch? expression that simply adds on a _ => null case at the end would be nice. And a switch! statement that always requires exhaustiveness would be nice. No, make that "Really Nice!"!

I do agree that we could make this proposal a bit less radical by dropping the return patterns and having => in the case expression. But I'll continue to push on the return patterns because I think they provide a significant amount of extra immediacy and conciseness.

For example:

var x1 = e case
    Something(a: true, b: < 10, return.foo()) ||
    OtherThing(:final c, d: return.bar(c));

If we didn't have return patterns then we'd have to split this into two cases (so the case? proposal wouldn't suffice anyway) and do something like this:

var x1 = switch (e) {
  Something(a: true, b: < 10, final result) => result.foo(),
  OtherThing(:final c, d: final result) => result.bar(c),
};

We can't use a single case because there is no reason to expect that the two variables named result have the same type. In that case it can't be the same variable, unless that variable has a type which is a common supertype of those two types, and then it's unlikely that we can do what we want to do (unless we use the type dynamic). Moreover, a single expression after the => would not easily be able to treat the result differently.

The semantics of these two expressions is exactly the same (including evaluation order), but with the switch expression we have to invent a name for the result (in each case) and then we have to refer to it after the =>. I think the return patterns have the potential to be more readable, when we've gotten used to return patterns.

@lrhn
Copy link
Member

lrhn commented Oct 30, 2024

I do like the => idea. It's like an inline single-case switch.
You can do:

switch (m) { p when c => e }

or you can do

(m case p when c => e)

Grammar migth be a problem. The c => e can look like a function expression.

(m case p when (x) => y)

A function literal will never be a bool expression, so maybe we can get away with the condition being an "expression-no-function-literal", like the expressions of a constructor initializer list.

A case-expression with no => evaluates to a boolean and binds variables on a true-branch if immediately branching on the result.
A case-expression with a => only binds variables in the result expression.

But maybe the =>-form just isn't worth it.
It's not much shorther than the single-case switch. A switch? would let you omit a _ => null case, and otherwise it's also not big, or you can do:

(m case p when c) ? e : null

for the same effect, which is only 4-5 characters longer than (m case p when c => e), the cost of having to write the null explicitly. (Or allow (if (m case p when c) e) with no else to work as an if-expression with an implicit null else-branch, which is only 2 characters longer than the => format, but isn't specific to pattern matching.
The big enabler here is to make (m case p when c) and expression, then any branch logic can be used with it.
Adding syntax that only works with pattern matches is less powerful than that. Adding both might allow some cases to be slightly shorter, but I'm not sure it's enough to be worth it. And the embedded return just doesn't read well for me.)

@tatumizer
Copy link

(m case p when c) ? e : null

The problem is that the variables defined in pattern p must be available in ? e part, but not in : other part. This is a bit problematic. The whole point of case? variant (with =>) is to eliminate this problem.

But if we support case?, it might make sense to support also something like when? cond => p for a general condition cond:
var a = when? b > c => d.

@eernstg
Copy link
Member Author

eernstg commented Oct 30, 2024

I'm pretty sure we would want to have a special rule about conditional expressions (?:) saying that variables introduced by a case expression in the condition are in scope in the first branch. Just like the special rule that we have about if statements saying that such variables are in scope in the "then" branch.

That's also what I've chosen for the proposal in the OP of this issue.

@tatumizer
Copy link

... saying that variables introduced by a case expression in the condition are in scope in the first branch.

For this, you have to disallow shadowing (which is what java does), otherwise

var x = 0;
var y = foo case Foo(:final x) ? x+1: x-1;

will be a valid expression, where the meaning of x is different in different branches.

@eernstg
Copy link
Member Author

eernstg commented Oct 30, 2024

I don't think this situation is worse than other kinds of shadowing, and we don't prevent that:

var x = 0;
int y;
if (foo case Foo(:final x)) {
  y = x+1; // `x` from pattern.
} else {
  y = x-1; // `x` from enclosing scope.
}

It might be helpful to lint this kind of situation, but that would then apply to all these situations. dart-lang/linter#872 didn't fly, but similar lints could be considered.

@tatumizer
Copy link

tatumizer commented Oct 30, 2024

It didn't fly because it was too broad and too restrictive.
Here, we are talking specifically about disallowing shadowing in patterns only.
This was a show-stopper for negative case conditions.
Java (generally) allows shadowing, but not in this scenario (I posted some examples of related java code, but I can't find where b/c github search is no good, so I don't remember exactly what "this scenario" means, but there's certainly some scenario 😄)

But sure, if dart today allows shadowing in patterns, and has no plans of disallowing it, then what you are suggesting is not worse.
However, now you have to agree that ?: operator is always sufficient:

var x = a case Foo(:final x) when x>0 ? x+1 : null;

You can even handle several alternatives:

var x = 
   (a case Foo(:final x) when x>0 ? x+1 : null) ??
   (a case Bar(:final y) when y.isEven ? y+1: null);         

Agree? 😄

@tatumizer
Copy link

tatumizer commented Oct 31, 2024

@lrhn: an extra argument in favor of switch? is that it naturally morphs into a "switch without scrutinee".
Without it, the switch of the form

var a = switch {
  cond1 => v1,
  cond2 => v2
};

would often require an extra line _ => null - which would become redundant if you use switch? instead,

If switch? is introduced, it would add more weight to the idea of case?: the rhyme between switch? and case? legitimizes both.

The advantage of switch? over ?: with several subordinate branches is that the former is more readable and lends itself to better formatting. Then the style guide may suggest using ?: only in the simplest (n=1) case.

@eernstg
Copy link
Member Author

eernstg commented Nov 2, 2024

I generalized the proposal to include an optional => expression at the end (thanks, @tatumizer!). This means that there is support for writing arbitrary expressions as the result of a successful match. With an expression of the form e1 case P => e2, it is a compile-time error to have a return pattern in P.

I also simplified the return patterns such that they can't introduce variables, and there is only one form (previously the proposal had <declarationReturnPattern> and <expressionReturnPattern>). This allows for a special case which seems to be rather common, namely a sequence of tests of the form "if we have an instance of Type1 then the result is obtained by calling getter1" and similarly for Type2/getter2, and so on. The member invocation could also be a method call, or a chain of calls. But the point is that we just want to talk about which type we can handle, and which member to call, and the rest is noise.

void main() {
  // Today.
  var x1 = switch (expression) {
    Sometype sometype => sometype.oneGetter,
    OtherType otherType => otherType.otherGetter,
    _ => null,
  };

  // With return patterns.
  var x2 = expression case 
      Sometype return.oneGetter || 
      OtherType return.otherGetter;

  // Of course, we could also use return patterns in switch expressions.
  // This could be useful, e.g., if `Sometype` and `OtherType` are the
  // immediate subtypes of a sealed type (no need for a default case,
  // and `x3` could have a non-nullable type).
  var x3 = switch (expression) {
    Sometype return.oneGetter,
    OtherType return.otherGetter,
  }
}

@tatumizer
Copy link

I think a more straightforward solution would be to allow introducing local variables with short names in some contexts where it's not yet allowed.

If the names are short (e.g. 1-letter), they can serve as convenient "pronouns"
Compare:

var s = "Hello, world!" case String(length: > 5) && return.substring(0, 5);
// VS
var s = "Hello, world!" case String(length: > 5, this: var e) => e.substring(0, 5);

Today, AFAIK, we can introduce short names for the fields inside the pattern, but not for the "scrutinee".
I don't know what the least disruptive syntax could be, but (irrefutable) this: var e for sure won't conflict with anything.
Another example:

var n = [1, 2.5] case [int(isEven: true) && return, _] || [_, double() && < 3.0 && return];
// VS
var n = switch? ([1, 2.5]) {
  [int(isEven: true, this: var i),  _] => i,
  [_, double(this: var d) ] when d < 3.0 => d;
};

(It would be good to find some syntax shorter than this: var d though).

@lrhn
Copy link
Member

lrhn commented Nov 3, 2024

I still don't like how return reads, and think it mixes expressions and patterns in a worrisome way.
The p => e can work. Not sure it's really worth the extra effort over p ? e : null, and the implicit null also worries me a little.

You can't do (v case p1 => e1) || (v case p2 => e2) like you can do (v case (p1 || p2) => e).
You can do (v case p1 => e1) ?? (v case p2 => e2), at least if e1 isn't nullable (or even if it is, it's just a different thing, which might be useful).
You can do (v case p1) ? e1 : (v case p2) ? e2 : null because it doesn't mix values and the boolean of whether it matched or not.

About the this: ..., you can get that today with extension <T> on T { T get self => this; }.
Or you can use &&:

var n = switch? ([1, 2.5]) {
  [int(isEven: true) && var i,  _] => i,
  [_, double d && < 3.0] => d;
};

That's shorter than the this syntax (at least , this: var i vs && var i).

Or as a conditional expression, either:

  ([1, 2.5] case [int(isEven: true) && num n, _] || [_, double() && < 3.0 && num n])? n : null

or

  ([1,2.5] case var p) && 
     ((p case [int(isEven: true) && var i, _]) ? i : 
      (p case[_, double d] when d < 3.0) ? d : 
      null)

((e case var p) && ... is a way to introduce a local variable in a boolean expression.)

That is, (e1 case p) ? e2 : e3 where e3 can be null is the most general form, the conditional expression version of switch (e1) {p => e2, _ => e3} - a two-outcome switch with only one pattern.

Everything else discussed here, the return and =>, are shorthands, and basically shorthands for not writing : null/_ => null.
I'm just not convinced such a shorthand is worth its own complexity.

@eernstg eernstg added the brevity A feature whose purpose is to enable concise syntax, typically expressible already in a longer form label Nov 7, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
brevity A feature whose purpose is to enable concise syntax, typically expressible already in a longer form feature Proposed language feature that solves one or more problems patterns Issues related to pattern matching.
Projects
None yet
Development

No branches or pull requests

5 participants