-
Notifications
You must be signed in to change notification settings - Fork 205
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
Comments
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. But The I can see I really want to use the word However, I'm actually attracted to the "guard-let" declaration form: var x? = expression else { return 0; } If we allow it in assignment expressions too, It's like a generalization of Then we'd still need a way to make a variable bind only non-null values in the pattern syntax. We just have an Or maybe the var (x != null, y != null) else { throw "bad"; } = expr; A var (x != null, y != null) when x < y else { return false; } = e; You'd get a warning (or error) if you add an (Then we could also do 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: Maybe we could allow |
I'm with @lrhn about 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 if (maybeInt case n?) else {
throw 'Oh no!';
} In other words, if the 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!';
} |
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 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;
} |
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 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. :) |
@munificent wrote:
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 If you wish to specify a constant pattern in the pattern of a variable declaration denoting a constant variable const c = 1;
void main() {
var [c] = [2] else { throw 'Not a list of two.'; }
// Succeeds, binds the local variable `c` to `2`.
} |
The ambiguity can be resolved, but it does change the story from "declarations/assignments are irrefutable patterns, and identifiers are binding in irrefutable patterns, Maybe it'll be enough to restate the story as "declarations-binding identifiers (must have |
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 |
@lrhn wrote:
Sure, we can always duplicate a subset of the derivation rules of the grammar and then have 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 |
I'd love it if that was the case. I feel like a lot of code is written using |
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 |
It would be really nice if <finalConstVarOrType> ::= 'late'? 'final' <type>?
| 'const' <type>?
| 'late'? <varOrType>
| 'late'? 'let' <type>?
<patternVariableDeclaration> ::= ( 'final' | 'var' | 'let' ) <outerPattern> '=' <expression> The regular (non-pattern) variant of 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 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 The ability to use an 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.
} |
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
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:
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. |
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
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 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:
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.
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
The bare identifier is a variable declaration. Refutable patterns are allowed if there is an
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 We could consider allowing refutable patterns in a pattern assignment, too, but I didn't go there (i.e., that would be 'patterns-later'). |
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
That's what we have today. It looks like three cases, because we have
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 If we'd also allow refutable patterns in assignments, with an |
If we decide to move forward with But with 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. |
I think this points us in the direction of dealing with @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 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 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- I tend to think we're in an OK position to handle this generalization. Not easy, not necessarily totally beautiful, but possible. ;-) |
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 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:
|
@stereotype441, that's great, thanks! |
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)
} |
+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
|
I'd prefer let x = func();
let x = transformation(x);
// vs having to do
final y = func();
final yTransfomed = transformation(y); |
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:Or inside an
if
: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: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:
We introduce
let
as another (contextual) keyword for declaring a variable with an inferred type. Likefinal
, the variable can't be assigned. Unlikefinal
andvar
, 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:
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:This is very similar to Swift's
if-let-case
. Like Swift, we could also support a more direct form of usinglet
inside theif
:Note that here we don't have any sort of general pattern on the left of the
=
. It's a special form that is justlet <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 variables
Since
let
is shorter thanfinal
, users who prefer single-assignment variables might want to use it for normal local variable declarations.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 wherenull
might flow through code using type inference on local variables.In code that consistently uses
let
for all non-nullable variables, then seeingvar
orfinal
sends a signal that the variable may holdnull
.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
The text was updated successfully, but these errors were encountered: