-
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
Support for Swift style if-let and guard-let constructs. #2074
Comments
Some syntax brainstorming. A fairly direct translation could look something like the following, assuming a variable // If let
if! var x = s { // x in scope here }
// guard let
var y = s else! { return; }
// y in scope here This is compact, but limited to null checks. It would be good to also support instance checks in Dart. Possible generalization: // if let
if (var x = s) != null { // x in scope here }
(var y = s) != null else { return; }
// y in scope here
if (var a = s) is int { // a in scope here }
(var b = s) is! int { return; }
// b in scope here In each case, the semantics is roughly to bind The above is general, but somewhat verbose for the null checks, so the first syntax above could be added as a shorthand for the null check variants. An alternative shorthand syntax: // if -let
if (var x = s)! { //x in scope here }
(var y = 3)! else { return; }
// y in scope here An alternative would be to decompose this uniformly into a variable binding and a continuation: // x is in scope in the condition and the block
(var x = s) if (x != null) { // Block executed if condition is true, with promotion
}
// y is in scope in the condition and the continuation, block must exit
(var y = s) else (y == null) { return;
}
// y is in scope here cc @munificent this sounds fairly similar to what you've been exploring with having a pattern syntax for null checks? |
Yeah, this reminds me of if-vars (#1201), especially @lrhn's comment at the top of that thread:
I like that version, I feel it's more Dart-y, with a nice mix of familiarity and verbosity, but also tersely conveying the point: final int? s;
// Declaring x as an if-var
if ((var x = s) != null) { /* x is non-nullable here */ }
// Declaring y as a negative if-var such that it is in-scope *after* the block.
//
// It did receive some pushback (mainly from me, I'll admit) due to it being slightly unintuitive and
// not following some other Dart patterns. For more, see my comment:
// https://github.com/dart-lang/language/issues/1201#issuecomment-754972984
if ((var y = s) == null) { return; }
// y is in scope here, and is non-nullable
// Declaring a as an if-var
if ((var a = s) is int) { /* a is non-nullable here */ }
// Declaring b as a negative if-var
if ((var b = s) is! int) { return; }
// b is in scope here, and is non-nullable With such a short general form, I'm not sure null-checking needs a specific shorthand |
Fair enough and so noted, but to cut off too much divergence here, I have quite strong objections to declarations as expressions without explicit scope delineation, that I have elaborated on in the relevant issues. It's still an option, but there's a lot of work to convince me that it's not a terrible idea (and the relevant issue to do that convincing on is one of the issues covering it... :) ). |
That's hardly a fair comparison. If-let isn't supposed to be about squishing your code down to less lines, it's about conveniently assigning a new, short name to an already existing value for convenience and readability, none of which are in your example. That'd be much better off as several lines as we're used to: final int first = int.parse("4");
final int second = int.parse("42");
if (first < second && second < 100)
print("$first < $second < 100"); Just because a feature can be abused to be unreadable, doesn't mean it always will or it shouldn't be done that way. (Keep in mind you can technically write all of the above in one line thanks to semicolons. Does that mean semicolons inherently imply bad design?) |
I'm not fond of the Swift syntax. It's too implicit for my taste, and, as stated, it only works for The idea works - introducing a binding in an I'd prefer the syntax we've previously discussed for introducing variables in tests: Object? foo;
if (var x = foo; x is int) { // Declaration prior to `;`, boolean condition after, scope is entire `if`.
// x in scope here as int
} else {
// x in scope here as Object?
}
// x not in scope here That too makes the variable declaration part of the (Do notice that it can be used as a let expression in collection literals, as |
We have to have the ideas before we can get data on them, and this issue tracker is where we first begin discussing a potential feature. Gathering user data is expensive and time-consuming so it's not something we can do freely for every feature idea. We rely on feedback from users here and the taste and judgement of the language team to decide how to spend that UX research budget wisely. One can't simply UX study their way into a good language design. UX studies are great analytical tools, but they don't create features.
If you find yourself writing "this thread is not the right place", in the future please consider just not writing that comment. |
This is roughly inline with what I've been noodling on too. As Lasse says, I'm not super excited about adding a form like Pattern-based ifWith pattern matching, I would already like to find some kind of syntax to use a single refutable pattern in an // The `if let` construct reads: "if `let` destructures `number` into
// `Some(i)`, evaluate the block (`{}`).
if let Some(i) = number {
println!("Matched {:?}!", i);
} Adapting something like that to Dart is tricky because the pattern proposal I have for Dart follows Swift in that it splits refutable and irrefutable patterns into two different grammars. I think there are good reasons for that, but it means jamming a refutable pattern in an if statement looks weirder. If we were to just do: if ( <refutable pattern> = <expr> ) ... Then it works nicely in cases like: // Here, `int x` is a pattern that refutes if
// the value is not an int or binds it if it is:
if (int x = someExpression) But also allows strange code like: // Literals are valid refutable patterns that refute if not equal:
if (123 = someExpression)
// Identifiers are valid refutable patterns that refute if not equal:
if (math.pi = 3.14) I think the refutable pattern grammar in the proposal works well in switch cases: switch (someExpression) {
case 123: // Literal.
case math.pi: // Constant.
case int x: // Type test and bind.
case [var x, var y]: // Destructure.
} It just looks odd stuck right inside if (case int x = someExpression) ...
if (case 123 = someExpression) ...
if (case math.pi = 3.14) I like the symmetry with switch cases, but I don't know if it's too unusual. Pattern-based guardI really like guard-let in Swift and think it would be very nice to extend the above to also support a form that binds in the rest of the block. I think it's a good fit for Dart since we already do reachability analysis for promotion so "must exit" blocks are natural to compute. I don't have any great syntax ideas yet. Maybe: unless (case int x = someExpression) {
// This block must exit.
return "not an int";
}
// x is in scope here and has type int. Null patternsOf course, all of the above doesn't get us very far for the "check if not null" case if the pattern for doing that is verbose. Right now, you'd have to do a type test pattern, which can be very verbose for long types:
That's pretty bad. My current idea is a "null-check" refutable pattern. Syntactically, it's just:
So a postfix SomeExtremelyLong<AndAlso, GenericType>? maybeThing = ...;
if (case var thing? = maybeThing) // thing has non-nullable type here. It also composes to let you do other nice stuff like: List<int>? maybeList = ...
if (case [var x, var y]? = maybeList) {
// Get here is list was non-null and contained two elements.
} The corresponding guard form could be something like: SomeExtremelyLong<AndAlso, GenericType>? maybeThing = ...;
unless (case var thing? = maybeThing) { return 'null!'; }
// thing has non-nullable type here. TL;DR:
|
One thing that I liked in my syntax noodling above was that the guard-let moved the binding to the front, which made it syntactically easier to see that the variable binding was in the enclosing block, rather than the guarded block. Perhaps the same here? (case int x = someExpression) else {
// This block must exit
return "not an int";
} It also feels heavy to me to have to use both extra keywords and parentheses. If we do use the if case int x = someExpression {
// x bound here
}
case int x = someExpression else {
// This block must exit
return "not an int";
} |
Yeah, I agree. It feels weird to put the binding inside parentheses because that reads to me like it should be scoped to the else block and not the rest of the surrounding block.
I toyed with this syntax too. I like that it doesn't require a block because the switch (foo) {
case 123:
case int x = someExpression else {
// This block must exit
return "not an int";
}
} It's probably not technically ambiguous, especially if we require an Perhaps we could use with int x = someExpression else {
// This block must exit
return "not an int";
} That looks... OK... to me. I'm a little hesitant to claim Maybe we shouldn't have a guard-let like construct. It makes a lot of sense in Swift because Swift doesn't do control flow based type promotion. But having a promotion feature that changes the types of existing variables in the rest of a block and a feature that binds new variables in the rest of the block might be too much to pack into a single language. |
Is this really too ambiguous? int x = someExpression else {
// This block must exit
return "not an int";
} Ugly parsing, but it feels like it should be doable? Alternatively, what about using a variant of if (int x =~ someExpression) {
}
int x =~ someExpression else {
// This block must exit
return "not an int";
} Or re-use instance checks? if (someExpression is int x) {
}
someExpression is int x else {
// This block must exit
return "not an int";
} |
@munificent your comment, did you mean to say at least two elements? Or do you really mean 2 and only 2 elements, and you'd need to do something like: List<int>? maybeList = ...
if (case [var x, var y, ...]? = maybeList) {
// Get here is list was non-null and contained at least two elements.
} |
It's not ambiguous but it's very confusing in the context of the current proposal. Right now, the proposal follows Swift in that there are two sets of patterns: refutable and irrefutable. They each have their own grammar and the same syntax in one can mean something different in the other. In particular, identifiers are treated as constants in refutable patterns and variable binders in irrefutable ones. The proposal adds irrefutable pattern-based variable declarations for destructuring like: var (a, b) = ("a", "tuple"); If we were to use refutable patterns in variable declarations that have var [a] = [123]; // Destructure 123 from the list and bind to "a".
var [a] = [123] else return; // Return if 123 is not equal to the constant "a". I think that's probably a bad thing. I really like taking Swift's approach to patterns because it works well in a lot of common cases, and makes it possible to have patterns that check for equivalence to named constants, which is a very common thing to do in switches today. But it means we have to be careful to telegraph to users which kind of pattern is expected in any given construct that builds on patterns. Having two very similar flavors of variable declaration where one uses refutable patterns and the other irrefutable will confuse users. That's why I've suggested using
We could do that, but I think I would expect that to mean a different kind of equality method being called (maybe for stuff like regex match) and not an entirely different syntactic operation.
Maybe, but that probably doesn't generalize to nested patterns and destructuring well. |
Why is this specific to var [a] = [123]; // Destructure 123 from the list and bind to "a"
unless (case var [a] = [123]) {
// Return if 123 is not equal to the constant "a".
} I agree that the ambiguity is confusing, but it doesn't feel substantially less so just because I put the keyword(s) before instead of after. |
Because that form looks a lot more like a variable declaration to me than a control flow construct.
It does to me. In the syntax here, there is a |
If we were to have a guarding Say we have Then we could define Or maybe just let |
This doesn't really seem to be solving the same problem to me. Here's some code: class C {
Iterable<String>? maybeStuff;
int test() {
var stuff = maybeStuff else (stuff == null) {
return -1;
}
for(var s in stuff) {
var contents = readFile(s);
// more stuff here
}
return 0;
}
} How does an expression level let help with this? And what problem with the above code does making the null result (I'm not sure what that means) explicit in the typing help with? I'm fine with an expression level |
It's true that if we want the scope to continue after the test, and we do not want expressions to introduce variables that survive the expression, then we need this to be a statement. (I am known to disagree on the latter condition.) Am I right that (var x = s) if (x != null) {
// Block executed if condition is true, with promotion
}
// and
// y is in scope in the condition and the continuation, block must exit
(var y = s) else (y == null) {
return;
} are equivalent to var x = s;
if (x != null) {
// Block executed if condition is true, with promotion
}
// and
var y = s;
// y is in scope in the condition and the continuation
if (y == null) {
return;
} The new syntax buys noting except moving the declaration onto the same line as the test, but at the cost of not having an explicit That's why I prefer It's exactly the same operation, the only difference is that the first keyword is (Also, using |
For what it's worth, expression-level variable bindings feel very weird to me in Dart. Dart is not an expression based language. It's got statements, for better or worse. If we want to lift variable binding out of statements and into expressions, then it really feels to me like we should convert everything to be expression based including |
The critique in your first paragraph applies equally to your second paragraph no? I'm somewhat in agreement with the critique taken broadly (applying both to this proposal and to your second paragraph), but as a counterpoint, we've heard from a number of programmers coming from Swift that they miss guard let quite a bit, and from various folks on these issues that splitting apart the constructs into a block level declaration + a normal |
I think the point is that if it's just moving the declaration onto the same line, it ought to help with readability. After all, you can always just write: var y = s; if (y == null) { return; } I suppose that's probably why in #1201, there was a lot of support for if-let, but some pushback against guard-let, which messes with scoping. I feel that just sacrificing a line to the declaration is often more intuitive. I'll concede that I've seen some intuitive examples of the guard-let form, but for the most part, I think the proposal still lacks a clear way to indicate "this is an if statement, and the variable declared here is visible outside of that. Don't let the fact that it's trapped in parenthesis confuse you". Even my code snippet above gets that point across clearly, and you can read it as "here is a declaration, and here is a separate if statement that can help with promotion". I don't see any confusion with this: class A {
int? x;
void local() {
// regular promotion happens after this
var y = x; if (y == null) return;
print("Is not null");
print(1 + y); // perfectly fine
}
} Actually, I'm starting to like this more than |
@munificent Dart's scope is already chimera-like in that it pretends to declare variables in statements, but really all variables are block scoped, not statement scoped. If they are declared somewhere later inside the block, their scope is still the entire block, you just can't refer to the variable until after the declaration. var x = 0;
{
print(x); // Invalid, `x` (the one below) is not declared yet.
var x = 2;
} If we embrace that design, which we have so far, I see no inherent problem with declaring variables inside expressions. The variables can still be block scoped, and still cannot be referenced from code not dominated by the declaration (which is determined by the exact same flow analysis that we already use to detect definite assignment). It might be a little more complicated for users to decide whether a variable can be accessed or not, but I don't actually think it's a problem in practice. People will tend to follow specific patterns which are known to work, and the rest will likely understand the error message ("that variable declaration may not have been executed before reaching here."). On the other hand, an expression declaration would be extremely useful for some of our more specialized expression-level language features, like the initializer list, forwarding constructors, collection literals and selector chains. Those are places where Dart already allows people to make large and complicated expressions, but does not provide a good way to do abstraction or reusing values. |
I understand that you like to think of that way but, 1) it is not the only way to think about it, and 2) if you ask users whether the second declared
Why do you propose to make expression level variables block scoped and not expression scoped? Note that doing so deeply breaks your analogy to existing variable declarations. That is, the scope of block scoped variables end at the termination of the block. If you want an analogous construct for expressions, you should make the scope end at the termination of the expression (this is how standard {
{ var x = 3; }
print(x); // Still in scope, since it is dominated by the declaration?
} The reason, of course, is that 1) it is useful for people to think of scopes via a syntactic hierarchy, rather than through dominance, as anyone who has spent a lot of time debugging SSA code can tell you... :), and 2) that there is value in a variable going out of scope. The fact that scopes are well-delimited is what allows you to avoid having to make all of your variable names globally unique, and that is valuable. And once you see this, then it should be completely clear that making variable level declarations be block scoped is a bad idea, because it extends the scope outside of the syntactic hierarchy. |
Swift supports two special statement forms for working with optional values (nullable values in Dart): if-let and guard-let. Each construct is a variant of a conditional that scrutinizes an optional (nullable in Dart) value, and binds a value to the underlying value in one of the two continuations if a value is present (not null in Dart). The if-let construct binds the variable in the body of the if, and the continuation has no binding for the variable. The guard-let construct binds the variable in the continuation, and requires that the body of the if never complete (i.e. return, or throw, on all paths). Examples:
These constructs are used in Swift to make working with optional (nullable) values easier. In Dart, for local variables we lean on promotion, which allows null checks to "promote" the type of a nullable variable to its non-nullable form. However, this cannot be done for properties on objects in general without implicit runtime checks, which is a pain point in working with null safe code. We have been exploring various approaches to make working with nullable properties easier in Dart. This issue is to track the idea of adding constructs equivalent to if-let and guard let to Dart. It is likely that this might be subset of a larger pattern matching feature, but for completeness this issue tracks the specific use case separately.
cc @munificent @lrhn @eernstg @jakemac53 @natebosch @stereotype441 @kevmoo @mit-mit
The text was updated successfully, but these errors were encountered: