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

Let #2703

Open
munificent opened this issue Dec 9, 2022 · 21 comments
Open

Let #2703

munificent opened this issue Dec 9, 2022 · 21 comments
Labels
patterns Issues related to pattern matching.

Comments

@munificent
Copy link
Member

An aspiration with pattern matching is to help when working with nullable types. A pattern can test an expression to see if it's not null and, if not, bind the result to a variable whose type is now non-nullable.

Solving that problem well requires nice syntax.

Null-check patterns

The current proposal addresses this using null-check patterns. When the subpattern is a variable, it gives you a concise way to test for null and bind the non-null value to a variable:

int? maybeInt() => ...

switch (maybeInt()) {
  case var n?:
    print(n + 1); // "n" is non-nullable.
}

Or inside an if:

if (maybeInt() case var n?) {
  print(n + 1); // "n" is non-nullable.
}

The postfix ? syntax is terse, but that seems to be the only thing it has going for it. I don't think anyone has liked it or understood it the first time they encountered it.

Null-check subpatterns

The powerful thing about null-check patterns is that you can have any kind of subpattern inside, not just a variable. But, in practice, this extra power is useless. All of the other kinds of patterns either do a type test or type assertion, so the null check becomes redundant if you wrap it around anything other than a variable.

<pattern> != null patterns

@leafpetersen proposes we instead allow subpatterns on the left hand side of == patterns. That would look like:

if (maybeInt() case var n != null) {
  print(n + 1); // "n" is non-nullable.
}

I think that's more approachable, but seeing a variable declaration inside an infix expression feels kind of strange to me. The precedence looks weird, almost like it's missing an = after the variable name or something.

Let

Here's a strawman for another approach (mostly borrowed from Swift) that might work or might be a bad idea. It also might help in other ways:

  • Provides a shorter syntax for declaring final local variables.
  • Provides a shorter possibly more intuitive syntax for the "check if an expression is null" if-statement like form.
  • Make it clearer in code where null might appear.

We introduce let as another (contextual) keyword for declaring a variable with an inferred type. Like final, the variable can't be assigned. Unlike final and var, the variable's inferred type is the non-null type of the initializer.

Let variable patterns

This means it can only be used in contexts where the initializer evaluating to null can be gracefully be handled. The first obvious place is as another kind of variable pattern. Like other variable patterns, it matches when the incoming value has the variable's type. Since the variable's type is inferred as the non-null type of that value, it effectively does a null check and binds otherwise.

So the first example becomes:

switch (maybeInt()) {
  case let n:
    print(n + 1); // "n" is non-nullable.
}

You can read "let" as sort of "permissive" here. It means "if we actually did get a value".

Let in if

You could also use a let pattern in an if-case statement, of course:

if (maybeInt() case let n) {
  print(n + 1); // "n" is non-nullable.
}

This is very similar to Swift's if-let-case. Like Swift, we could also support a more direct form of using let inside the if:

if (let n = maybeInt()) {
  print(n + 1); // "n" is non-nullable.
}

Note that here we don't have any sort of general pattern on the left of the =. It's a special form that is just let <var> = <expr>.

Guard let

Also like Swift, we could support an inverted form where you have an else clause which must exit which is run when the expression is null. Maybe something like:

let n = maybeInt() else {
  throw 'Oh no!';
}

Let variables

Since let is shorter than final, users who prefer single-assignment variables might want to use it for normal local variable declarations.

main() {
  let hello = 'hi';
  print(hello);
}

As long as the initializer's type is non-nullable, this can't fail, so we can safely allow it. And, in fact, now this code tells you something useful. By using let, you know the variable is non-nullable even though you didn't write a type annotation. This can make it clearer where null might flow through code using type inference on local variables.

In code that consistently uses let for all non-nullable variables, then seeing var or final sends a signal that the variable may hold null.

I'm hesitant to introduce a third keyword for variables, but maybe there's something to this? Thoughts? @mit-mit @lrhn @stereotype441 @natebosch @jakemac53 @kallentu

@munificent munificent added the patterns Issues related to pattern matching. label Dec 9, 2022
@lrhn
Copy link
Member

lrhn commented Dec 9, 2022

I don't like the implicit null check. (I don't like implicit, m'kay.)

I get that null checks are only really needed for binding patterns, because all other patters require a type (and almost always a non-null type) anyway. Specializing the null-check to the binding makes sense.
We also only need null checks in refutable patterns, because they are refutable.

But let does not say "null check" to me.

The if (e case let x) ... new syntax isn't more readable to me than if (e case var x != null) .... It's shorter, which is nice, but actually feels less immediately readable than if (e case var x?) ..., and much less than != null.

I can see let x = e; being a nice short way to do final variables, but I'm worried about mixing it with nullability.
If let x = nullableExpression; makes x be a final nullable variable,
and let x = nullableExpression else {block}; makes it non-nullable,
then else is just a block-version of ??. It's not about the declaration.
And that's fine, but then I'd want it everywhere I use ?? today,
so I can do bar(getFoo() else {return null; }) without needing a declaration (the block still needs to always escape).

I really want to use the word let for an expression-level declaration at some point, expression ::= let decl = expr in expr, instead of the longer switch (e) { case irrefutablePattern => expression } that we can already do with the current patterns. (#1420 would be sufficient, but it's been a hard sell, so I'm hoping for let decl in expr instead.)
Then I'd be fine with making let x = e1 in e2 be a final variable by default (maybe you can write let var x = e1 in e2 if you want a variable, let is just a declaration pattern starter like var, but with a different default for finality), and allowing let x = e; as final declaration in statement/declaration context.)

However, I'm actually attracted to the "guard-let" declaration form: let pattern = expression else { must escape statement} }.
I just wouldn't tie it to non-nullability.
It looks generally useful, if it allows you to use any refutable pattern in a declaration, and throw if the pattern doesn't match, and also control how you throw (or break/continue/return), or even do side effects first.
We could even allow for plain declarations, without changing to let.

var x? = expression else { return 0; }

If we allow it in assignment expressions too, LHS = RHS else BLOCK as an expression, with the usual else-binds-to-the-latest-possible priority, then we won't need the (RHS case LHS-pattern v) ? v : (throw "Bad") rewrite, which itself requires #2664. (It's [if (RHS case LHS-pattern v) v else throw "Bad"].first without #2664. And it only works with throw, not break/continue/return.)

It's like a generalization of as casts, which does an is validation check and throws if it fails. If we had e as T else {e-must-escape-statement} we could control the failure branch, but still keep it in-line, and avoid repetition and needing a variable like (x is T ? x : escape).
The guard-let would check if a value matches a pattern, which promotes and binds as usual, and if not, do the else branch which must escape. And it could apply to assignments too. In both cases it would mean allowing refutable patterns in declaration position, so we'd need to expand our "pattern's expected type" algorithm to refutable patterns too.

Then we'd still need a way to make a variable bind only non-null values in the pattern syntax. We just have an else clause for when it doesn't match.
The var (x != null, y != null) = expr else { throw "bad"; }; works for me.
A var x != null = expr else {throw "bad";} looks weird, but you could also just do var x = e ?? (throw "bad"); to rule out nulls on the top-level value before even going into the pattern. You don't need the pattern for that.

Or maybe the else clause should be like a when clause, and go after the pattern like:

var (x != null, y != null) else { throw "bad"; } = expr;

A when clause makes any pattern refutable. An else clause goes on a refutable pattern, maybe except in a switch, and makes it irrefutable. It allows using an otherwise refutable pattern in a declaration or assignment position.
So:

var (x != null, y != null) when x < y else { return false; } = e;

You'd get a warning (or error) if you add an else to an irrefutable pattern, like var x else {throw "never happens";} = 42;.

(Then we could also do expr else {block} as a null-aware escape in expressions, but that might be overloading else a bit much. Also #loop-else.)

Not sure about the readability, but definitely very general and orthogonal.


I'll just ponder some more, feel free to ignore the rest.

This is actually interesting to think about. We have two approaches to type checks in the language: is which forces you to handle both cases, and as which throws on the failure case, like an is with default throwing false-branch. And we'll have patterns which can fail or succeed. We use them in irrefutable or refutable positions, but the pattern-match else throw format allows you to use a refutable check in an irrefutable position, without having to nest it inside an if statement and worry about block scope.
And in library code, we either throw or return null to represent a failure, where null allows you to configure what happens afterwards using ??. The null is like a very common "checked" exception with an in-line catch syntax. (It's also a value, which makes everything harder. C'est la vie.)

Maybe we could allow else on any non-guaranteed assignment or cast - an operation with a known risk of runtime failure. So foo(e as RequiredType else throw "boo") would allow an e which is not assignable to the static type RequiredType of the parameter of foo to be downcast, and if it fails, we control the error. Which can be shortened to e else throw "boo", which is an implicit downcast (only, not sidecast) to the context type.
Heck, we can allow the else branch to supply a value too. LHS = RHS else defaultValueExpression where RHS's type is a supertype of the required type of the LHS, but defaultValueExpression is assignable to the LHS type (or pattern). Can even chain them LHS =RHS1 else RHS2 else RHS3 else guranteedValidValue;.
(Probably a little too speculative, but putting it out there as an idea.)

@stereotype441
Copy link
Member

I'm with @lrhn about let not meaning "null check" to me. Also, I really hope that one day we can have let-expressions in Dart (they're so useful that we even use them in pseudocode in the spec!) so I want to leave that part of the syntax open.

However, I really like the idea of having an inverted form that must exit if the match fails, because I think that will make it easier for people to avoid deep nesting. In your example you wrote it as:

let n = maybeInt() else {
  throw 'Oh no!';
}

I'm wondering if we can think of some other syntax for this that doesn't require let, and maybe also keep the presence of case as a reminder that the pattern is refutable. The best I can think of right now is:

if (maybeInt case n?) else {
  throw 'Oh no!';
}

In other words, if the ) of the if is immediately followed by else, then the else clause is required to exit, and the variables inside the pattern belong to the enclosing scope rather than to the non-existent "then" block.

Another possibility (which I might have suggested before, I'm not sure) would be to leave the grammar exactly as it is, but say that the variables declared inside an if-case's pattern always belong to the enclosing scope, and rely on flow analysis to ensure that they're only used if the match succeeds. Then the user could do:

if (maybeInt case n?) {} else {
  throw 'Oh no!';
}
// ... Ok to use `n` here because the `else` branch exits ...

(An open question about this idea is: if a variable in the if-case's pattern has a nullable type, is it allowed to be accessed if the match fails? My intuition is to say no.)

Another related idea would be to extend the if-case syntax to permit the special form "if (!( expression case guardedPattern )) ...", in other words allow this:

if (!(maybeInt case n?)) {
  throw 'Oh no!';
}

@eernstg
Copy link
Member

eernstg commented Dec 9, 2022

Wouldn't it suffice to say the following?:

Consider a local variable declaration declaring just one element (which could be a pattern declaring several variables, or a regular declaration declaring exactly one variable, but not a regular declaration declaring several variables). That declaration may end in 'else' '{' <statements> '}' rather than ;. It is a compile-time error if the block can complete normally.

In an 'else declaration' using a pattern, refutable patterns are allowed.

We could include implicit downcasts in the regular declarations (making them 'refutable'), or just downcasts that eliminate null.

int n = maybeInt() else {
  throw 'Expected an `int`, got null!';
}

var (x != null, y != null) = expr else {
  throw "bad"; 
}

var (x != null, y != null) when x < y 
    = e else { 
  return false; 
}

@munificent
Copy link
Member Author

Wouldn't it suffice to say the following?:

Consider a local variable declaration declaring just one element (which could be a pattern declaring several variables, or a regular declaration declaring exactly one variable, but not a regular declaration declaring several variables). That declaration may end in 'else' '{' <statements> '}' rather than ;. It is a compile-time error if the block can complete normally.

In an 'else declaration' using a pattern, refutable patterns are allowed.

Unfortunately, no. The syntax is carefully designed to route around the ambiguity of bare identifiers in patterns. The way we deal with that in the Dart proposal is that bare identifiers are variable binders in irrefutable contexts but constants in refutable contexts. Allowing a refutable pattern in a variable declaration would collapse that. Consider:

const c = 1;
var [c] = [2] else { throw 'Not a list of two.'; }

This could arguably either throw or not depending on how we interpret c in that pattern. If we really do treat this like a normal refutable pattern and keep the current syntax, then instead you would have to write:

const c = 1;
var [var c] = [2] else { throw 'Not a list of two.'; } // Doesn't throw.

var [c] = [2] else { throw 'Not a list of two.'; } // Does.

But I think that would be pretty surprising. Bolting pattern matching onto a non-ML language is hard. :)

@eernstg
Copy link
Member

eernstg commented Dec 12, 2022

@munificent wrote:

bare identifiers are variable binders in irrefutable contexts but constants in refutable contexts

That should not be a problem. #2714 reports a grammar ambiguity, but also proposes rules about how to disambiguate a pattern which is a plain identifier.

The proposed rule is that a plain id is a variable pattern (introducing a fresh variable whose type is inferred) when it occurs in the pattern of a local variable declaration; it is a reference to an existing local variable when it occurs in the pattern of a pattern assignment, and it is a reference to a constant variable when it occurs in case/=> context.

If you wish to specify a constant pattern in the pattern of a variable declaration denoting a constant variable c then you can use const (c).

const c = 1;

void main() {
  var [c] = [2] else { throw 'Not a list of two.'; }
  // Succeeds, binds the local variable `c` to `2`.
}

@lrhn
Copy link
Member

lrhn commented Dec 12, 2022

The ambiguity can be resolved, but it does change the story from "declarations/assignments are irrefutable patterns, and identifiers are binding in irrefutable patterns, cases are refutable patterns and identifiers are constants in refutable patterns", which only has two combinations, into declarations being able to be both irrefutable and refutable, so we get three combinations (declaration irrefutable-binding identifiers, declaration refutable-binding identifiers, case refutable-constant identifiers). Not as clear-cut a story for users.

Maybe it'll be enough to restate the story as "declarations-binding identifiers (must have else if refutable), case-constant identifiers", and treat irrefutable patterns as just a subset of all patterns.

@eernstg
Copy link
Member

eernstg commented Dec 12, 2022

Maybe it'll be enough to restate the story as "declarations-binding identifiers (must have else if refutable), case-constant identifiers", and treat irrefutable patterns as just a subset of all patterns.

This approach is exactly what I'm recommending in #2714: Treat an identifier pattern differently based on the context. We can then allow refutable patterns in declaration/assignment contexts iff there is an else clause.

@eernstg
Copy link
Member

eernstg commented Dec 12, 2022

@lrhn wrote:

The ambiguity can be resolved

Sure, we can always duplicate a subset of the derivation rules of the grammar and then have <patternInDeclarationContext> whose <variablePatternInDeclarationContext> doesn't derive <identifier>, along with a <patternInAssignmentContext> where there is no variable pattern at all, and <patternsInMatchingContext> whose <variableDeclarationinMatchingContext> does derive <identifier>, but <constantPattern> doesn't (that doesn't have to be <constantPatternInMatchingContext> if no other context can have a constant pattern).

However, we don't usually do that, because it causes the language grammar to explode in size (especially when we've done it several times).

So I'd prefer to make it a part of static analysis after parsing that an <identifierPattern> is treated as a <variablePattern> or as a <constantPattern> or as a plain <identifier>, based on the context.

@mnordine
Copy link
Contributor

Since let is shorter than final, users who prefer single-assignment variables might want to use it for normal local variable declarations.

I'd love it if that was the case. I feel like a lot of code is written using var when not needed, but they'd use let if it was available, simply because it's shorter.

@leafpetersen
Copy link
Member

Here's a strawman for another approach (mostly borrowed from Swift) that might work or might be a bad idea. It also might help in other ways:

  • Provides a shorter syntax for declaring final local variables.
  • Provides a shorter possibly more intuitive syntax for the "check if an expression is null" if-statement like form.
  • Make it clearer in code where null might appear.

This is growing on me. There's a nice compactness to combining non-nullability and finality. Final nullable variables certain have some uses, but mostly you want final things to be non-nullable except when you plan to immediately check and promote them, which this covers with the if and guard let variants. I definitely like how the code reads.

@eernstg
Copy link
Member

eernstg commented Dec 13, 2022

let could be treated as the new final!

It would be really nice if let declarations were the go-to form when declaring a local variable: It's just as short as var, and it yields a final variable (or several, via a pattern declaration), and the variable type is non-nullable (or a compile-time error occurs).

<finalConstVarOrType> ::= 'late'? 'final' <type>?
    | 'const' <type>?
    | 'late'? <varOrType>
    | 'late'? 'let' <type>?

<patternVariableDeclaration> ::= ( 'final' | 'var' | 'let' ) <outerPattern> '=' <expression>

The regular (non-pattern) variant of let declarations could be used with top-level variables etc. as well as local variables. It still means the same thing: Final, and non-nullable. The pattern variant would be local, just as it is now.

It could be used on formal parameters. Who knows, it might even be used in practice, too, in organizations where mutation of formal parameters is considered bad style, but final is considered too verbose. So why not?

The word 'let' seems to occur as code in about 13 libraries in a substantial amount of internal code. In other words, it doesn't seem to cause much breakage to change let to a built-in identifier, based on a completely unscientific measurement. ;-)

The ability to use an else { <statements> } tail on a local let declaration would carry over smoothly from the idea which was mentioned here.

const c = 1;

void f() {
  switch ([2]) {
    case [c]: print('Not reached'); // `c` refers to a constant variable.
    case [let d]: print('Got list of $d!'); // 'of 2!'.
  }
}

void main() {
  let [c && > 0] = [2] else { // Succeeds, creates local variable `c`, bound to `2`.
    throw 'Not reached.'; 
  }

  c = 3; // Compile-time error, `c` is final (we can't say that `c` is "let")).

  let [d] = <int?>[null]; // Compile-time error, type of `d` is not non-nullable.
}

@munificent
Copy link
Member Author

munificent commented Dec 13, 2022

#2714 reports a grammar ambiguity, but also proposes rules about how to disambiguate a pattern which is a plain identifier.

Issue #2714 suggests specifying the grammar differently but unless I misread it, the language you get as a result is the same as the current proposal: a bare identifier is always a variable binder in an irrefutable context and always a constant in a refutable context.

If we allow patterns in let, then there are three flavors of patterns:

  • var/final declarations and assignments: Bare identifier is a variable. Only irrefutable patterns allowed. Can't use var or final inside for inferred variables.
  • let declarations: Bare identifier is a variable. Refutable patterns are allowed. To refer to a named constant, have to do const foo. Can't use var or final inside for inferred variables?
  • Switch cases and if-case: Bare identifier is a constant. Refutable patterns are allowed. To bind a variable, have to annotate it.

I know we already had a lot of concerns about having two flavors of patterns and relying on context to know what a piece of syntax means. Having three could possibly be a bridge too far.

I admit, though, that it would feel weird to simultaneously allow patterns in existing variable declarations while adding a new variable declaration form that doesn't allow patterns. And I can certainly see the value in:

  1. Having a nicer syntax like if (let pattern = expression) instead of the fairly odd if (expression case pattern).
  2. Being able to use patterns in a guard-let like let pattern = expression else exit; form.

I do worry that we're going too far out on a limb here. Maybe the right way to approach this is to remove null-check patterns from the proposal and ship what we feel solid about in 3.0. And then leave better handling for nullables as a further refinement once we get a feel for how users get used to patterns and the current level of contextuality they have.

@eernstg
Copy link
Member

eernstg commented Dec 14, 2022

the language you get as a result is the same as the current proposal

The two grammars can derive the same programs. But the current grammar is ambiguous, and the parser cannot make the decision. The grammar I proposed does not have this ambiguity.

This means that it is possible to create the abstract syntax tree in the parser, with identifierPattern nodes here and there, and it's up to the subsequent static analysis to look at each identifier pattern node and mark it up as a newly declared variable (declaration context), a reference to a constant (matching context), or a reference to a local variable (assignment context).

a bare identifier is always a variable binder in an irrefutable context and always a constant in a refutable context.

This is exactly what I'm suggesting we should change: We should let the context determine the meaning of an identifier pattern, and it doesn't matter which kind of pattern it occurs in (refutable/irrefutable). So we'll talk about 'declaration context', 'assignment context', and 'matching context', just like the patterns spec does today.

However, 'refutable context' would now be declaration and matching context, and 'irrefutable context' would be assignment context.

The point is that this allows a declaration context to contain refutable patterns (in which case there must be an else {<statements>}). They just follow the rules and make each identifier pattern introduce a new variable. So (1, x) || (x, 2) in a declaration context will introduce a variable x, just like (1, var x) || (var x, 2) would introduce a variable x in a matching context. Conversely, (1, x) || (x, 2) in a matching context contains 4 constant patterns and doesn't introduce any new variables, and the same thing would be written (1, const (x)) || (const (x), 2) in a declaration context.

This does not introduce new kinds of patterns: A plain identifier in a pattern can have 3 meanings today, and that is still the case with my proposal. The reader of a snippet of code that includes a pattern could think about it like this:

  • If we have var, final, let as the very first token then it's a declaration, and identifiers introduce new variables.
  • If we have case in front or => at the right end then it's a matching pattern, and identifiers are references to constant variables.
  • If we have a pattern assignment, identifiers are references to local variables (and treated like <assignableExpression>s).

If we allow patterns in let, then there are three flavors of patterns:

We currently have three different treatments of plain identifiers (new variable declaration, assignment target, reference to constant variable), that doesn't change if we adopt my proposal.

  • var/final declarations and assignments: Bare identifier is a variable. Only irrefutable patterns allowed. Can't use var or final inside for inferred variables.

They are different (today, and in my proposal): The bare identifier is a new variable declaration with declarations and an assignment target (referring to an existing variable) in assignments.

There is no need to restrict declarations such that they can't have refutable patterns, we just need to enforce that there is an else if there is a refutable pattern. We could allow the var and final keywords in a declaration pattern as long as they don't contradict the topmost keyword in the declaration, but it's no problem if we keep rejecting them (they're redundant anyway).

  • let declarations: Bare identifier is a variable. Refutable patterns are allowed. To refer to a named constant, have to do const foo. Can't use var or final inside for inferred variables?

The bare identifier is a variable declaration. Refutable patterns are allowed if there is an else. Constants are denoted by const (foo). We should probably treat var/final/let the same as in other declaration patterns (that is, they're not allowed).

  • Switch cases and if-case: Bare identifier is a constant. Refutable patterns are allowed. To bind a variable, have to annotate it.

That's true in my proposal as well.

The main point here is that we already have three different meanings with a bare identifier, and my proposal doesn't change that. It simply allows refutable patterns in declarations (in which case there must be an else), and it ties the interpretation of a bare identifier to the context (declaration, assignment, matching).

We could consider allowing refutable patterns in a pattern assignment, too, but I didn't go there (i.e., that would be 'patterns-later').

@lrhn
Copy link
Member

lrhn commented Dec 14, 2022

We could allow the var and final keywords in a declaration pattern as long as they don't contradict the topmost keyword in the declaration, but it's no problem if we keep rejecting them (they're redundant anyway).

Or we could allow them to contradict the topmost keyword, if you want most, but not all, variables to be final:

final (x, y, z, var w) = computeQuard();
w += 2;

I'm beginning to like the idea. It's all about interpretation of bare identifiers, and it's only because of declaration patterns that we need a distinction - we don't want to write multiple vars for those (especially since they are likely prefixed by one too).

  • Bare identifiers in declarations patterns are variable declarations. In other patterns you need to prefix with final/var to declare a variable.
  • In other patterns, identifiers refer to what they resolve to. We can then either allow or disallow some of those references (no refutable constants patterns in assignments, no non-constant variables in match patterns, since we won't assign to them). You can writes const x to force x to be a constant pattern for the value of x.

That's what we have today.
The distinction is not strongly linked to being refutable or irrefutable, more to the context - because assignment patterns and match patterns agree, and they differ in refutability.

It looks like three cases, because we have

  • declaration: irrefutable, identifiers declare
  • assignment: irrefutable, identifiers refer (only to assignable variables)
  • match: refutable, identifiers refer (only to constants)

but for interpreting of identifiers it really is only two cases (and it's not an accident, it was a deliberate design choice, because it was more likely to allow us to expand things in the future, say to non-constant value comparisons.)

If we allow refutable patterns in declarations, with a required else branch, then it seems reasonable to use the declaration approach, not the "refutable pattern" approach, because it is still a declaration.
You can still refer to constants with a const prefix. You can still not refer to assignable variables.

If we'd also allow refutable patterns in assignments, with an else clause, we'd want identifiers to refer to existing variables or constants, and the meaning would depends on which it is! (We have no assignable and constant identifiers. If we ever start allowing non-constant value comparisons in patterns, you'd probably have to use == x for that.)

@stereotype441
Copy link
Member

If we decide to move forward with let pattern = expression else statement;, we'll need to revert #2639. That PR removed the definitions of pattern type schema for logical-or, null-check, constant, and relational patterns, on the grounds that those patterns were only allowed in refutable contexts, and the pattern type schema was only used in irrefutable contexts.

But with let, that will no longer be the case; the pattern is refutable, but we still want to use its context type schema to type analyze the expression.

And we can't just blindly revert #2639, we have to fix the underlying issue that led to it, namely that the pattern type schema for constant patterns was defined circularly (it depended on the result of running type analysis on the constant, using a context that came from the matched value type; but the matched value type comes from the type of the expression, and in order to compute that we need the pattern type schema of the pattern first).

The situation is complicated by the fact that both the analyzer and front end have design assumptions that prevent type analysis from being performed on a subexpression more than once.

I don't think this issue is insurmountable, though. We just need to think carefully about how we define the pattern type schema for constant patterns to make sure there's no circular reasoning.

@eernstg
Copy link
Member

eernstg commented Dec 15, 2022

I think this points us in the direction of dealing with let and declarations-with-else during 'patterns-later'.

@stereotype441, how do you think we can be in a good position to generalize context type schemas (that is, to revert #2639) in the future?

We can always break a circular dependency by saying that the information flow simply doesn't occur—for instance, the context type schema for a pattern that contains refutable subpatterns could have an _ in every position where the context type schema of a refutable pattern would otherwise be needed.

We could also say that there is no context type schema if the pattern contains any refutable subpatterns. Or we could single out some constant patterns as inference-trivial (like 1 and const C(1) where C is non-generic: We can immediately see that type inference will not change anything), and say that there is no context type schema if the pattern contains any inference-non-trivial refutable subpatterns.

In any case, the lower bound (in terms of developer convenience) will be the current treatment of matching contexts: The scrutinee of a switch statement/expression or an if-case statement doesn't get any context type schema, either, and a declaration-with-else is somewhat similar to those statements (especially if-case).

I tend to think we're in an OK position to handle this generalization. Not easy, not necessarily totally beautiful, but possible. ;-)

@stereotype441
Copy link
Member

Yeah, I think we're in a good position to generalize context type schemas in the future. We just need to be careful how we define it.

Regarding your idea of using _ for the context type schema for a pattern that contains refutable subpatterns, that's certainly an option. And I like it as an "existence proof" that there is a possible definition that will work. But I think we can do much better.

Of the context type schemas that were removed in #2639, the only one with the circular reference problems was the one for constant patterns. So we could just restore the other ones to the form they took before #2639.

As for constant patterns, I think we could specify rules based on the constant's form, e.g. something like:

  • If the constant is a numeric literal (or negation thereof), the context type schema is num. This allows for int-to-double conversion if the corresponding type on the RHS winds up being double.
  • If the constant is a boolean literal, null literal, numeric literal (or negation thereof), string literal, or symbol literal, then the context type schema is simply the type of the literal (since this is known based on the form of the literal alone).
  • If the constant is an identifier or qualified name, the context type schema is the type of the thing the identifier or qualified name refers to. This works for everything that doesn't undergo implicit conversions, and I personally think that's good enough (we can afford to be a little sloppy because it doesn't cause a compiler error or break soundness if an expression's type doesn't match its schema, it just limits the ability of type inference to work its magic).
  • If the constant is a constObjectExpression (like const C(1), then:
    • If the corresponding type is non-generic, or explicit type arguments are given, then use that as the context type schema
    • If the corresponding type is generic and no type arguments are given, I suspect the best we can do is fill in _ for the type pararmeters. Again, I think that's good enough.
  • If the constant is a map, list or set literal:
    • If explicit type arguments are given, then the context type schema is the type of the literal (because, again, this is known based on the form of the literal alone).
    • If no type arguments are given, then I think the best we can do is Map<_, _>, List<_>, or Set<_>.

@eernstg
Copy link
Member

eernstg commented Dec 15, 2022

@stereotype441, that's great, thanks!

@bernaferrari
Copy link

I personally enjoy Kotlin's let a lot more than Swift's let. It creates a new non-nullable context inside:

https://kotlinlang.org/docs/scope-functions.html#let

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)
Person("Alice", 20, "Amsterdam").let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

@munificent munificent added patterns-later and removed patterns Issues related to pattern matching. labels Apr 7, 2023
@ds84182
Copy link

ds84182 commented May 8, 2023

+1 to Guard Let. One thing that irks me about patterns in for loops is how refutable patterns don't result in an implicit "continue". Guard let would at least streamline this slightly.

for (var (nullableValue, otherValue) in nullableListOfRecords) {
  let value? = nullableValue else continue;
  // ...
}

Also, maybe this could allow access to the original value in the else statement.

let int i = thing.untypedValue else throw StateError('expected int, got $it');

let Ok(value) = somethingThatErrors() else return it;

// While the default identifier is `it`, it can be rebound or matched irrefutably.
var it = 'conflicting implicit declaration here';
let even && int(isEven: true) = value else as odd return (it, odd);

Syntactically I think this is

letGuardDeclaration ::= 'let' outerPattern '=' expression else ('as' outerPattern)? statement

@mcmah309
Copy link

mcmah309 commented Aug 8, 2024

I'd prefer let to be just used in "Let variables" and not have an implicit null check. That said, as one of the intended uses of let mentioned here is for pattern matching, adding let might be also be a good opportunity to allow shadowing of variables, which also is a common scenario for pattern matching. This would give let additional uses/semantic meaning compared to final. e.g. assume transformation returns a different type, and reusing x/y name is preferred.

let x = func();
let x = transformation(x);
// vs having to do
final y = func();
final yTransfomed = transformation(y);

Related issues:
#1514
#3322
#136

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

9 participants