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

A "guard-let"-like statement form #2537

Open
munificent opened this issue Sep 29, 2022 · 46 comments
Open

A "guard-let"-like statement form #2537

munificent opened this issue Sep 29, 2022 · 46 comments
Labels
patterns Issues related to pattern matching.

Comments

@munificent
Copy link
Member

The patterns proposal adds a new "if-case" statement form for matching a single refutable pattern:

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

That binds pattern variables inside the then branch when the pattern matches. Swift has something similar with "if-case" (which is where I got the idea). Swift also has a "guard-case" feature:

func processRequestResponse(_ response: NetworkResponse) {
  guard case let .response(urlResp, data) = response,
    let httpResp = urlResp as? HTTPURLResponse,
    200..<300 ~= httpResp.statusCode else {
      print("Invalid response, can't process")
      return
  }
  print("Processing \(data.count) bytes…")
  /* … */
}

Which that, if the pattern does not match, then the guard body is executed. That body must exit the surrounding scope (by returning, throwing, breaking, etc.). Any variables bound by the pattern are now in scope in the rest of the block following the guard-case. This lets users flatten out a level of control-flow related nesting.

Before null safety, Dart had much simpler type promotion rules. It would promote inside the then branch of an if statement:

test(Object obj) {
  if (obj is String) {
    print(obj.length);
  }
}

But it would not promote when the if performed a negative test and the then branch exited:

test(Object obj) {
  if (obj is! String) return;
  print(obj.length); // Error before Dart 2.14.
}

This was a frequent source of frustration because when you had multiple promotions, you were forced to add a level of nesting for each one. I suspect that if we only have if-case and not a corresponding guard-like form, we will create something equally frustrating.

Can we come up with one? If so, what should it look like? I've tried a few times, but never found a syntax I liked. Two ideas:

test(List<Object> json) {
  unless (json case [int x, int y]) {
    throw FormatException('Invalid JSON.');
  }
  print('Was coordinate array $x,$y');
}

So using the contextual keyword unless instead of Swift's guard. Or:

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

Like an if-case-else except there is no then branch at all.

@munificent munificent added request Requests to resolve a particular developer problem patterns Issues related to pattern matching. and removed request Requests to resolve a particular developer problem labels Sep 29, 2022
@Wdestroier
Copy link

Wdestroier commented Sep 30, 2022

Just to complement, I have seen an idea about an if! statement:

if! (json case [int x, int y]) {
  throw FormatException('Invalid JSON.');
}

Edit: Or maybe if (json case! [int x, int y]) {...}

@mmcdon20
Copy link

mmcdon20 commented Oct 1, 2022

The issue I have is that, for me, It isn't immediately obvious what the scope of x and y are, you have to think about it for a moment.

unless (json case [int x, int y]) {
  // x and y are not in scope
  throw FormatException('Invalid JSON.');
}
print('$x,$y'); // x and y are in scope

// compared to
if (json case [int x, int y]) {
  // x and y are in scope
  print('$x,$y');
}
// x and y are not in scope

Also, I sort of expect an unless keyword to work like in ruby where it is just an if not, but this seems to also require an early exit like the swift guard statement.

It is also unintuitive if if () else {} would have different behavior than if () {} else {}.

if (json case [int x, int y]) else {
  // early return required
  throw FormatException('Invalid JSON.');
}
// x and y in scope
print('Was coordinate array $x,$y');

// compare to
if (json case [int x, int y]) {} else {
  // early exit no longer required
  throw FormatException('Invalid JSON.');
}
// x and y no longer in scope

@munificent
Copy link
Member Author

The issue I have is that, for me, It isn't immediately obvious what the scope of x and y are, you have to think about it for a moment.

This is an issue for me too. It's useful to have a construct like this that drops the variables into the current block scope because it lets you flatten out nesting, but it's not obvious that it does that.

Also, I sort of expect an unless keyword to work like in ruby where it is just an if not, but this seems to also require an early exit like the swift guard statement.

It is also unintuitive if if () else {} would have different behavior than if () {} else {}.

Agreed on all counts. That's why I haven't found a syntax I like yet. :-/

@leafpetersen
Copy link
Member

I wonder whether it would help to turn the syntax on its head?

test(List<Object> json) {
  {
    throw FormatException('Invalid JSON.');
  } unless (json case [int x, int y]);
  print('Was coordinate array $x,$y');
}

This makes the scoping of the variables read much more naturally.

@munificent
Copy link
Member Author

I toyed with some ideas like that too, but never found anything I liked there. It makes the scoping more obvious "if you get here, you can use these". But I think it makes the control flow read counter to importance. "Why am I throwing an exception here? Oh wait, I'm not. Because there's a condition after this block that decides whether the preceding code is executed."

@lrhn
Copy link
Member

lrhn commented Oct 4, 2022

A pattern match, using case, has a certain "flow" to it, values come in, flow through the pattern, bindings come out the other side. It's all or nothing, either it matches completely, or it doesn't match at all. The code depending on the binding comes right after the pattern.
That's also why negating a pattern is not one of the pattern primitives.

Here it sounds like we want to put the action of a non-match before action on a match. That destroys the flow, because the bindings coming out of the match are now detached (spatially) from the code which uses them.
And we can't just negate the pattern, like we would a normal if condition.

One way Swift differs from our if/case is that they're doing it on a binding (let construct).

Something similar in Dart might be:

case (var x, var y) = e when x < y
else break;
// rest of block using x, y

The case signifies that the declaration is refutable, the else statement (can be a block too) is what happens if the match fails.

This lifts the pattern match out of being nested inside the if condition, and puts it at the level where the variables would be introduced.

It's basically as shorhand for

if (e case (var x, var y) when x< y) {
  // rest of block
} else {
  break;
}

which is also why I don't think it's a very important feature. It allows you to flip branches and unnest the else branch. That's nice, but hardly essential.

(Could we just introduce an unless (test) falseBranch else trueBranch, and do unless (e case p) break;, and let variables spill out whenever the else branch definitely escapes?)

@mraleph
Copy link
Member

mraleph commented Oct 4, 2022

Any specific reason why we can't make some variation of variable declaration followed by else work?

final [int x, int y] = json else {
  throw "";
}

case [int x, int y] = json else {
  throw "";
}

I really would love to see some regularity in our pattern matching (both internally and externally, with other programming languages), so it would be easy to remember the syntax and also understand what the syntax means. I guess the current reason for post-fix case in conditions is to try and match switch-case syntax. But maybe that is just confusing and hard to read.

Most of variable declarations go left to right (variable is equal to expression). In pattern matching we suddenly go right to left (expression decomposed into variable). But only if pattern occurs in the if. The choice seems strange.

I think it is beneficial for readability to keep switch-case as an outlier and instead align on variable(pattern)-expression order which is used in destructuring.

// Instead of
if (json case [int x, int y]) {
  // ...
}

// Do 
if (final [int x, int y] = json) {
  // ...
}

// Or even 

if final [int x, int y] = json {
  ...
}

// This naturally leads to 

final [int x, int y] = json else {
  // ...
}

Such syntax is easy to remember, it's fairly regular. It matches normal destructuring.

@lrhn
Copy link
Member

lrhn commented Oct 4, 2022

The distinction between case and non-case pattern matching is that case is used for refutable patterns, and non-case for irrefutable assignments/declarations.

The distinction matters because refutable pattern matching must be used in a branch condition of some kind, because the bindings only work in code guarded by the match.

The allowed patterns are also different between the two.
You can do if (e case (0, int x?)), constant patterns and null-check patterns are refutable, but not final (0, int x?) = e;.
If we then introduce an else branch to handle that, you can write:

 final (0, int x?) = e else break;

but now the patterns allowed in the "declaration" depends on whether there is a trailing else or not.
You can't actually write final in front of a refutable pattern, so this is new syntax. Identifiers need to be prefixed, so final (0, x) = e else break; is invalid, you need it to be var x.

The two kinds of patterns are different enough that it's important to keep them clearly separated.

I'd probably be fine with case pattern = expression else escape-statement where the pattern is a matching pattern. The case is enough to mark it as a refutable pattern. (But it's possibly bad for parsing if the first statement of a case block can be case declaration.)

About the sequencing, declarations are really the problem. Doing var x = 4; introduces x first, then evaluates 4, then goes back and initializes x. Then the binding falls out the bottom. That destructuring assignment does the same is an artifact of the original problem. (Consider a language where assignment was expr -> variable instead of variable = expr. We declare variables before their value because C did it, and C did it because it had a one-pass parser.)

For if/case and switch/case, the values do come from the left/above, passes through the pattern in left-to-right order (the possibly through a when) and finally the binding comes out the other side, into the guarded block.
That's a much more consistent flow.

@leafpetersen
Copy link
Member

Such syntax is easy to remember, it's fairly regular. It matches normal destructuring.

@mraleph The penultimate proposal essentially admitted and used that syntax. But for various reasons, we decided to split the syntax of patterns, so that binding patterns (irrefutable, used for destructuring declarations) and matching patterns (refutable, used for switch cases) have slightly different syntax. Basically, a bare identifier in the first means "bind a new variable with this name", whereas a bare identifier in the second means "refer to a constant with this name". Given this choice, the syntax you propose doesn't really work well, because you now have a refutable pattern in a declaration location. For this reason, as part of the change above, @munificent changed the if syntax to use case.

@munificent
Copy link
Member Author

Building on what @lrhn and @leafpetersen said, more concretely;

final [int x, int y] = json else {
  throw "";
}

That syntax gets weird when you consider simple identifiers:

const x = 1;
const y = 2;
{
  final [x, y] = json else { ... }
  ...
}

Here, are x and y variable declarations, or patterns that match the values of the existing constants x and y? Since this is a refutable pattern, the expectation should be the latter, because that's what [x, y] means in a switch case. But that looks very confusing here because the final [x, y] syntax mirrors pattern variable declaration syntax, where x and y are variable bindings.

We tried very hard to come up with a unified grammar that uses the exact same syntax in refutable and irrefutable contexts, but it's hard to get past the fact that x is clearly a variable binding in var x = foo; and clearly a constant in case x:.

So instead (like Swift), the variable binding syntax is slightly different between irrefutable and refutable contexts. But that in turn means that a guard-let-like construct (which takes a refutable pattern) should look less like a variable declaration statement and more like a switch case.

case [int x, int y] = json else {
  throw "";
}

I've considered something like that, but it gets kind of weird when it happens to appear as a statement inside a switch case body:

switch (foo) {
  case bar:
    case [int x, int y] = json else {
      throw "";
    }
}

It might not actually be ambiguous, but it's pretty hard to read, I think.

@mraleph
Copy link
Member

mraleph commented Oct 6, 2022

Thanks for the responses @leafpetersen @lrhn @munificent. I am pretty confident that you likely spent hours discussing this before, so I am somewhat afraid to repeat arguments that we already raised... That being said. I assign high value to syntax consistency because it makes things easier to remember and understand. It feel counter-intuitive to me that we will have two syntactically different ways to do almost exactly the same thing, with some additional inconsistencies on top.

final [x, y] = expr;  // ok, x & y are variables

if (expr case [x, y]) // not okay, unless we have constants x&y in scope
if (expr case [var x, var y]) // ok, x&y are variables.

This inconsistency feels strange. This is probably too late but I think maybe just thinking about all patterns as refutable patterns in the uniform way was a possibility.

match [var x, var y] = expr; // okay if pattern is guaranteed to always match

match [var x, var y] = expr else { // okay if pattern is not guaranteed to always match
  throw '';  // if pattern is irrefutable then warning due to unreachable else
}

if (match [var x, var y] = expr) {
  // warning if pattern always matches
}

switch (expr) {
  match [var x, var y]: ... // okay
  match ...: // error if one of the patterns above always matches.
}

Is my understanding correct that the current proposal introduces the difference between bindings in patterns purely to save on typing var (or other variable markers) in the irrefutable destructuring?

@munificent
Copy link
Member Author

Is my understanding correct that the current proposal introduces the difference between bindings in patterns purely to save on typing var (or other variable markers) in the irrefutable destructuring?

Yes, that's right. We considered it, but it's really hard to get excited about:

var (var x, var y) = (1, 2);

When most other languages let you simply write:

let [x, y] = [1, 2]; // JS.
let (x, y) = (1, 2); // Rust.
let (x, y) = (1, 2) // Swift.
val (x, y) = Pair(1, 2) // Kotlin.
val (x, y) = (1, 2) // Scala.

We could introduce a new keyword for both destructuring variable declarations and pattern-based cases like you have with match here. Aside from unfamiliarity and redundancy (since we already have keywords for variable declarations and switch cases), the main problem is:

switch (expr) {
  match [var x, var y]:
    match [var a, var b] = list;
}

Here, the first match is a case and the second is a variable declaration inside that case. It's probably technically not ambiguous, but it requires unbounded lookahead to find the = or : to distinguish. And I think it's pretty hard to read for anyone skimming the code.

@mraleph
Copy link
Member

mraleph commented Oct 21, 2022

Yes, that's right. We considered it, but it's really hard to get excited about:

I agree that var (var x, ...) is somewhat hard to read.

Let's take a look at languages that have both pattern matching and destructuring declarations (JS and Kotlin don't).

Rust

  • Pattern syntax is uniform between destructuring and pattern matching.
  • Identifiers which don't refer to in-scope constants introduce variables which will shadow other variables with the same name already declared in scope.
  • Patterns can be used in if condition, compiler will issue a warning if irrefutable is used in if.
  • No direct equivalent of guard let, but if is an expression which allows for some emulation
const foo:i32 = 1;
const bar:i32 = 2;

let (x, y) = (1, 2);

match (1, 2) {
  (x, foo) => ..., // foo refers to constant foo above
  (x, bar) => ..., // bar refers to constant bar above
  (x, y) => ...
} 

if let (x, foo) = (1, 2) {
  // ...
}

let x = if let (x, foo) = (1, 2) { x } else { return };

Swift

  • let is needed in the case-pattern to designate variable bindings. let (x, y) is accepted as a shorthand for `(let x, let y)
  • Identifiers that are not prefixed with let/var are referring to in-scope variables.
  • There is an irregularity between let and case pattern syntax.
// can't write let (let x, let y) = ...
let (x, y) = (1, 2)

let bar = 1
let foo = 2

switch (1, 2) {
    // Bind x and y to the elements of point.
case (x, bar):
    print("[1] The point is at (\(x), \(bar)).")
// case let (x, foo) declares variable foo 
case (let x, foo):
    print("[2] The point is at (\(x), \(foo)).")
case let (x, y):  // same as (let x, let y)
    print("[3] The point is at (\(x), \(y)).")
}

if case (let x, foo) = (1, 2) {
  print("inside if");
}

// can't say guard (let x, bar) or guard let (let x, bar) = ... 
guard case (let x, bar) = (1, 2) else {
  exit(1)
}

Scala

  • Uniform syntax of patterns
  • Identifiers are always introducing new bindings, unless escaped with backticks.
  • No support for pattern as a condition (neither if nor guard let direct equivalent), but you can still use refutable patterns in val/var, they will simply throw MatchError in runtime.
val (x,y) = (1,2)

(3, 2) match {
   case (x, `y`) => ... // y refers to y above, x declare new binding
}

val (z, `y`) = (1, 3);  // will throw in runtime 

Analysis

I think syntactically we are close to Swift with our inconsistency.

That being said I honestly prefer simplicity and uniformity of Rust. It gives constants a special treatment - which we could also do, and then asks developers to use guard clauses for any comparisons which are not comparisons with constants. I do also like guarded bindings that Swift provides.

So if I were to mix and transplant this to Dart, we would get the following:

const foo = 1;
const bar = 2;

final (x, y) = (1, 2);
switch ((1, 2)) {
  case (x, bar): 
}

if var (x, bar) = (1, 2) {
}

var (x, bar) = (1, 2) else {
  return; 
}

@lrhn
Copy link
Member

lrhn commented Oct 21, 2022

I really, really worry about introducing a new variable with no var, final or type in front, which is what case(x, bar) does.
It's a choice, but not one I like.

I'd be OK with allowing var (x, y) to be a shorthand for (var x, var y) everywhere (meaning that any unqualified identifier in the pattern introduces a new variable), and then still requiring a leading var or final for destructuring declarations.

The argument against that is that users don't want options to choose between, they just want to know what to write.
If case (var x, var y): is the same as case var (x, y):, which one should you use? Why do we give you the other one, then? For consistency and ortohgonoality, even when nobody will use it?
(I'd use case (var x, var y): generally, because then I don't have to change anything if changing one of the fields to a different kind of pattern - which really just means a constant pattern, it's the only conflict, and I'd use var (x, y) = ... for declarations equally generally. I'd also recommend that to others. If that becomes the general style, that's exactly what our current grammar supports.)

@leafpetersen
Copy link
Member

I really, really worry about introducing a new variable with no var, final or type in front, which is what case(x, bar) does.
It's a choice, but not one I like.

Note that this is what our current syntax for functions uses, and it doesn't seem to cause problems.

@munificent
Copy link
Member Author

munificent commented Oct 24, 2022

Let's take a look at languages that have both pattern matching and destructuring declarations (JS and Kotlin don't).

Thanks for doing this analysis. I think it's fairly similar to what I wrote up here. As you note, the current design is based most heavily on Swift's approach.

I like Rust's approach too:

  • Pattern syntax is uniform between destructuring and pattern matching.
  • Identifiers which don't refer to in-scope constants introduce variables which will shadow other variables with the same name already declared in scope.

But in practice, I think this works largely because constants are idiomatically capitalized in Rust. That's not the case in Dart. In Rust, it's pretty easy to tell what's going on here:

if let (left, top, Width, Height) = rect {
  // ...
}

It matches a rectangle with a certain known size and extracts its position. (And, in fact, GitHub even syntax highlights the constants differently!) But with Dart's constant capitalization rules, I find this very opaque:

if (rect case (left, top, width, height)) {
  // ...
}

Whereas with the current proposal, it's pretty clear:

if (rect case (var left, var top, width, height)) {
  // ...
}

This is a big part of why I think a Swift-like approach is a better fit for Dart.

@mraleph
Copy link
Member

mraleph commented Oct 24, 2022

Whereas with the current proposal, it's pretty clear:

I must admit it is pretty clear. What about this then: we prohibit naked references to constants and instead require relational pattern to be used. Your examples becomes:

if (rect case (left, top, == width, == height)) {
  // ...
}

Here we ask users to type more for constants (likely uncommon case) rather than variables (common case).

It also does not require any additional syntax. == val pattern is already there in the specification.

@munificent
Copy link
Member Author

I have considered that too. It does address the ambiguity, but I think it makes the more common case (in switches) more verbose. Most switch cases today are switching on named constants. Even with destructuring, I expect it will be more common for switch cases to test values than bind variables. (I could be wrong about this. I know @leafpetersen thinks destructuring will end up more common in cases.)

If I'm right, then it's somewhat punishing to force users to write == before what will likely be the most common form. Also, it means most existing switch cases are no longer compatible with the new syntax. We'd need to migrate them all, which can be done mechanically, but still has a cost. Things like docs, StackOverflow, blogs, etc. would all need migrating. The current proposal is backwards compatible with existing switch cases in all but a few rare corners.

One way to think about the current proposal is that it presupposes:

  1. Variable bindings are more common in irrefutable contexts. (This must be true since you can't refer to constants at all here.)
  2. Constants are more common in refutable contexts.

And to make common patterns be terse in both contexts, it chooses a different default for each. In irrefutable contexts where binding is the norm, an identifier binds. In refutable contexts where constants are common, an identifier tests a constant and binders get an extra marker.

@leafpetersen
Copy link
Member

Also, it means most existing switch cases are no longer compatible with the new syntax.

Is this true? I thought most existing switch cases used prefixed identifiers, which would always resolve to constants.

@munificent
Copy link
Member Author

Is this true? I thought most existing switch cases used prefixed identifiers, which would always resolve to constants.

Slava's proposal was:

we prohibit naked references to constants and instead require relational pattern to be used.

Which I interpreted to mean that all constants in cases needed ==, even unambiguous ones like qualified identifiers. If we just say that only unprefixed identifiers need it, then, yes, most existing cases would still be compatible. That was essentially my unified proposal. Personally, it felt weird to have a place in the language where qualified identifiers were semantically different from unqualified ones.

@lrhn
Copy link
Member

lrhn commented Oct 31, 2022

The current patterns specification has the advantage that an identifier expression generally means what the identifier would mean outside of a pattern.
The one exception is when the entire pattern starts with var, as var (x, y) distributes the var onto the identifiers, the same way var x, y; does.

In all other cases, the identifier means what it means as an expression, we just have strong restrictions on which identifiers we allow, so that only constants are allowed in refutable patterns, only non-final variables are allowed in assignment patterns, and the var-declared variables are all new in a declaration pattern.

I like this because it might allow us to extend the rules in the future, say to (x, var y) = pair; which assigns to one existing and one new variable, or case x: which matches against a non-constant variable. And if we really want to, we can introduce var (x, y) as a sub-pattern in a refutable pattern. I'd just prefer to not go there unless we are sure it's something users will actually need.
The current definition does not feel like it contains any pitfalls. It looks like it's very special-cased, but it actually isn't at the grammar level. The special cases come from restrictions on which kind of expressions and variables are allowed in which contexts.

@mraleph
Copy link
Member

mraleph commented Nov 1, 2022

After I have spent more time talking to @lrhn and going through @munificent's arguments I am got kinda sold on the current syntax. I understand the idea behind the current choices and I concede that it comes together cohesively.

I still have some lingering concerns around how if-case looks and whether it will be possible to generalise it nicely into a guarded form, but I think those are probably minor.

@munificent
Copy link
Member Author

I still have some lingering concerns around how if-case looks and whether it will be possible to generalise it nicely into a guarded form, but I think those are probably minor.

For what it's worth, I have some of those lingering concerns too. I'm fairly confident that it will work out, but I wouldn't say I love it. I do think it's important to have some if-let like construct because in my testing, it comes up really frequently.

@munificent
Copy link
Member Author

Rust 1.65.0 just came out which adds support for let-else statements:

let PATTERN: TYPE = EXPRESSION else {
    DIVERGING_CODE;
};

As with Swift's guard-let, the DIVERGING_CODE body must exit.

@munificent munificent added patterns-later and removed patterns Issues related to pattern matching. labels Jan 19, 2023
@lrhn
Copy link
Member

lrhn commented Jun 22, 2023

We could have:

unless (patternClause) { 
  // must escape
}

and allow the variables captured in the pattern to flow into the following scope.
But then it's really just

if (patternClause) {
  ....
} else {
  // may escape
}

with less indentation.

I think

case pattern = value else { /* must escape */ }

is somewhat reasonable. It's a little weird that the value comes after the pattern for a refutable pattern (and after a = when it's not a declaration/assignment pattern).

The most general solution would be to allow:

(expression case patternClause)

as a boolean expression which provides pattern variables along its true branch until the end of the next statement block. That works inside ifs too, like today:

{
  if ((pair case (int x, int y) when x < y) && (computePair(x, y) case (int x2, int y2))) {
    // All variables in scope
  } else {
    throw "WAT?";
  }
  // All variables still in scope.
}
// No variables in scope any more.

Then it's just:

if (!(json case [int x, int y])) throw "nope";
print(x + y);

@Reprevise
Copy link

{
 if ((pair case (int x, int y) when x < y) && (computePair(x, y) case (int x2, int y2))) {
   // All variables in scope
 } else {
   throw "WAT?";
 }
 // All variables still in scope.
}
// No variables in scope any more.

I personally think this is hard to read because it's not clear that x2 and y2 are in scope here (are x and y in scope after the if statement too?).

@natebosch
Copy link
Member

But then it's really just

if (patternClause) {
  ....
} else {
  // may escape
}

with less indentation.

I think supporting this pattern with less indentation is worthwhile.

A while ago I wrote:

    if (element
        case LibraryImportElement(:final DirectiveUriWithRelativeUri uri)
        when uri.relativeUriString == expectedImport) {
      // Annotation is on the correct import
    } else {
      throw InvalidCheckExtensions(
          'must annotate an import of $expectedImport');
    }

I happened to not need uri in the following code, but if I did need it I'd be sad to have to indent the happy path and move the throw many lines away from the other guard clauses at the top of the method.

I would prefer

    unless (element
        case LibraryImportElement(:final DirectiveUriWithRelativeUri uri)
        when uri.relativeUriString == expectedImport) {
      throw InvalidCheckExtensions(
          'must annotate an import of $expectedImport');
    }

@TekExplorer
Copy link

Wouldn't this be resolved with the idea of negation patterns?

// something like this
// variable assignment would not be allowed in negation patters
// (you cant assign a variable if you're explicitly checking that it *isnt* what want)
if (data case SomeClass(value: !ValueSubType())) { 
// where SomeClass.value is of type ValueSuperType and ValueSubtype implements ValueSuperType
   throw 'data.value needs to be a ValueSubType!';
}

would this be a good example?

@munificent
Copy link
Member Author

the idea of negation patterns?

I don't know what you mean by a "negation" pattern. What would this do:

if ('string' case !(int i)) print(i / 2);

@nate-thegrate
Copy link

I really like the case! idea that @Wdestroier suggested, especially since it's analogous to the is! syntax already in place:

// parameter
void foo(value) {
  if (value is! double) return;
  print(value.round());
}

// non-local variable
void foo() {
  if (_value case! double x) return;
  print(x.round());
}

@lrhn
Copy link
Member

lrhn commented Mar 12, 2024

That's more cumbersome, and less general, than necessary.

What if we allowed (expr case pattern when expr) as a boolean expression, requiring parentheses in most contexts, where the pattern variables are available on the true path if the value is used as a branch condition.

Then this is just:

if (!(value case double x)) return;
// Use x 

@munificent
Copy link
Member Author

where the pattern variables are available on the true path if the value is used as a branch condition.

It's feasible, but I worry that this scoping rule would be very subtle and confusing for users.

Then this is just:

if (!(value case double x)) return;
// Use x 

I have to say that the double parentheses don't fill me with joy.

@mateusfccp
Copy link
Contributor

Alternatively (or complementarily) we could have unless:

unless (value case double x) return;
// Use x

@natebosch
Copy link
Member

natebosch commented Mar 14, 2024

What if we allowed (expr case pattern when expr) as a boolean expression, requiring parentheses in most contexts, where the pattern variables are available on the true path if the value is used as a branch condition.

Requiring parenthesis might resolve the parsing ambiguity concerns that originally made us dismiss this idea.

This idea was also brought up in #2181 (comment).

I think it would satisfy the request in #3062

@lrhn
Copy link
Member

lrhn commented Mar 15, 2024

I think the expression grammar would be something like:

<expression> ::= 
    <assignableExpression> <assignmentOperator> <expression>
  | <conditionalExpression>
  | <cascade>
  | <throwExpression>
  | <patternExpression>   -- new!

<patternExpression> ::=
   <expressionWithoutCascade> 'case' <guardedPatternWithoutCascade>

<guardedPatternWithoutCascade> ::= <pattern> ('when' <expressionWithoutCascade)?

Maybe rename <expressionWithoutCascade> to <expressionWithoutMoreStuff> ... or something.
The point is that neither <cascade> nor <conditionalExpression> allows a plain <expression> anywhere, so we can't nest a <patternExpression> inside anything which isn't a top-level expression, or the tail of one (after assignment or throw). Everywhere else it needs parentheses.

Then we'd have to figure out the scope of the variables bound by such a pattern.

They're definitely only available on the true path, which suggests they're only available if the expression is used in a branch position, so an operand of && or ||, probably not ??, and after an if or while condition, or the negated branch of a ! operator.
(I'd say the variables should be available until the end of the surrounding statement, where dominated by a true result. That's what'll allow if (!(e case int x?)) return; x.foo();.)

@munificent
Copy link
Member Author

Alternatively (or complementarily) we could have unless:

That's one of the syntaxes I considered in the initial description for this issue. :)

@mateusfccp
Copy link
Contributor

mateusfccp commented Mar 19, 2024

Alternatively (or complementarily) we could have unless:

That's one of the syntaxes I considered in the initial description for this issue. :)

Sorry, it has been a year since I last read it! 😅

@mcmah309
Copy link

mcmah309 commented Mar 31, 2024

I'll repost this idea here, since #3506 was closed in favor of this thread. But given Dart already supports object deconstruction.

final Foo(:bar) = Foo(1);

and if-case statements

ABC abc = A(1);
if(abc case A(:final a)){
    // can now use "a"
}

The logical extension of this is "object deconstruction or else statements"

ABC abc = A(1);
A(:final a) = abc else {
    // break, continue, return, or throw
}
// can now use "a"

I believe this fits well into the current syntax. Currently you have to do this

ABC abc = A(1);
int a;
if(abc case A(a:final innerA)){
  a = innerA;
}
else {
  return;
}
// use a

While ideally we should be able to do this

ABC abc = A(1);
A(:final a) = abc else {
  return;
}
// use a

Adding another keyword like unless seems unnecessary if we can just use an else and have the same effect.

@Sominemo
Copy link

Sominemo commented Mar 31, 2024

ABC abc = A(1);
A(:final a) = abc else {

While a cute idea, I am honestly having a hard time interpreting this syntax. You have to jump around while reading it out as:
We have an instance of ABC in abc initialized as A(1). We set a from abc. Oh, unless if we fail, we do something else, and that branch of the code doesn't get the variable initialized (implicitly).

I like the idea with case! or unless more in terms of code readability, though I must admit your suggestion gives quite some flexibility as syntax sugar.

@mcmah309
Copy link

mcmah309 commented Mar 31, 2024

That is the syntax that Rust uses https://doc.rust-lang.org/rust-by-example/flow_control/let_else.html , I find "deconstruct or else" the logical complement to "if can deconstruct" (if-case).

Another benefit of this syntax is that if we decide to loosen the restriction that the branch has to end with a control flow, we could have default values.

A defaultVal = A(2);
ABC abc = A(1);
A(:final a) = abc else defaultVal;
// use a, a is 2 if abc is not an A.

Not saying defaults should be adopted but the form allows this type of syntax in the future.

@gnprice
Copy link
Contributor

gnprice commented Mar 31, 2024

A similar "let-else" syntax was discussed earlier in this thread. The short version of @munificent's comment on that was in #2537 (comment):

That syntax looks nice, but is confusing because of how patterns are contextual in Dart (and in Swift). See here for more context. We may have painted ourselves into a corner syntax-wise.

and that links to the long version.

I think the same points apply to this most recent version. (Like @Sominemo I find the A(:final a) = … confusing to the eye; but imagine final A(:a) = … instead.)

@mcmah309
Copy link

mcmah309 commented Mar 31, 2024

I too agree final A(:a) = is better if we started from scratch, but I think A(:final a) = is more appropriate since it is the syntax that if-case statements use and this is the logical other side of the coin. You should be able to do A(:a) = though.

Also A(:final a) allows for renaming vars A(a:final newAName) slightly cleaner IMO. Rather than final A(a:newAName)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
patterns Issues related to pattern matching.
Projects
None yet
Development

No branches or pull requests