-
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
Control-flow in expressions #2025
Comments
OK, so I think the request here is to treat We already did the same thing many years ago for someShortMethod() => throw 'Unimplemented.'; I don't think there's anything technically problematic about making this change. I do have a lot of concerns about readability and whether it is worth the complexity of changing it. Many readers have a strong preference that control flow is easy to notice and generally appears at the beginning of a line. This would reduce that significantly. |
This is true, and while many users like that feature, some definitely do not. |
I agree with the sentiment -- it's not like having the extra line of code impacts product performance for the end-user. Separating the control flow out to its own line makes it clear what's happening, and aside from the occasional |
The It requires a completely new way of thinking about the I'd probably write it as: IntTuple? parseIntTuple(ParserContext context) {
int? left, right;
if (context.char($lparen) == null ||
(left = context.parse('left integer', parseInt)) == null ||
context.char($comma) == null ||
(right = context.parse('right integer', parseInt)) == null ||
context.char($rparen) == null) {
return null;
}
return IntTuple(left!, right!);
} But that's just that example. |
Fwiw I prefer the original This is how I think most would write this today, very explicit and easy to follow and no nesting: IntTuple? parseIntTuple(ParserContext context) {
if (context.char($lparen) == null) return null;
var left = context.parse('left integer', parseInt);
if (left == null) return null;
if (context.char($comma) == null) return null;
var right = context.parse('right integer', parseInt);
if (right == null) return null;
if (context.char($rparen) == null) return null;
return IntTuple(left, right);
} |
The precedence of x >= 0 || throw ArgumentError.value(x); I think this would nice because the checked precondition is written directly, the same as in an To get this to work one has to use parentheses, with makes it less attractive: x >= 0 || (throw ArgumentError.value(x)); The conventional alternative of using an if-statement requires one to negate the precondition. if (x < 0) throw ArgumentError.value(x); or if (!(x >= 0)) throw ArgumentError.value(x); Like the OP, if I was to change the method using I hope the precedence problem of |
@rakudrama this is right way to do: if (x < 0) {
throw ArgumentError.value(x);
} for me, the fact that throw can be used in expressions is not acceptable, even this: Never error() => throw Error(); |
I agree with @munificent that it might not be a big problem technically to support return/break/continue/rethrow as expressions. I spelled out the details a bit more in issue #2099, and then discovered that there's a lot of overlap with this one, so I closed #2099 as a duplicate, and put the details in this comment: So the point is that we already have the ability to abruptly terminate an ongoing expression evaluation using The semantics would be to immediately terminate the evaluation of the enclosing expression, and then give rise to the same data transfer and control transfer as the corresponding This proposal may seem quite disruptive at the syntactic level, but an experimental change to returnExpression // Updated, was `returnStatement`.
: RETURN expression?
;
breakExpression // Updated, was `breakStatement`.
: BREAK identifier?
;
continueExpression // Updated, was `continueStatement`.
: CONTINUE identifier?
;
rethrowExpression // Updated, was `rethrowStatement`.
: RETHROW
;
// Delete `returnStatement`, `breakStatement`, `continueStatement`,
// `rethrowStatement` from `nonLabelledStatement`, they are now
// covered by `expressionStatement`.
throwExpression // Should be renamed, e.g., `abruptCompleteExpression`.
: THROW expression
| breakExpression
| continueExpression
| returnExpression
| rethrowExpression
; A grammar update with some improvements beyond the ones shown above can be found at https://dart-review.googlesource.com/c/sdk/+/231949. @johnniwinther, @scheglov, @mkustermann, @sigmundch, how disruptive does this feature seem to be from your point of view, for the static analysis, code generation, or execution of programs? [sorry about the duplicate question, I just asked the same question in #2099, but #2099 has been closed as a duplicate of this issue.] My guess is that the static analysis can just give the new expressions the type But during code generation and execution there might be local state (say, a non-empty expression evaluation stack) that calls for further operations before jumping, and I have no idea how hard it would be to handle that, or how much of the existing functionality concerned with throw expressions could be reused with the new expressions. |
@rakudrama wrote:
We had that discussion a while ago (but I can't find the relevant issue right now). One possible remedy was ifNullExpression
: logicalOrExpression
(('??' logicalOrExpression)* ('??' expressionWithoutCascade))?
; This would allow This update is included in the grammar update in https://dart-review.googlesource.com/c/sdk/+/231949. |
The problem with the precedence of So, instead it means Basically, a control flow should always be in tail position in an expression, because you shouldn't do anything after a control flow operation. Using precedence to determine "tail position" isn't really working for us. |
@lrhn wrote:
I think it will be a useful improvement to allow This should serve to motivate the not-so-impressive generality of the |
About the readability issue (which seems to be the main argument against this feature): I think we should put the main emphasis on useful and readable forms like final myField = this.myField ?? return; and not worry so much about the ability to write obscure code. Honestly, we can write obscure code with any language mechanism. ;-) |
@lrhn said:
From my personal experience, a definitely throwing expression in argument position (or some other non-tail position) is not actually useless. I often find it really helpful, when making changes to code I'm less familiar with, to replace various critical expressions with a temporary When I do this, the analyzer's "dead code" hint (which is based on flow analysis) does a pretty good job of demonstrating the absurdity of my temporary code, which prevents me from accidentally commiting it to source control. So I actually think we are doing pretty well already at preventing misuse with our existing diagnostics. |
That still means allowing all the useful and readable forms, because otherwise it's surprising when something that looks useful and readable doesn't work. I'm guessing anything that might shortcircuit to avoid the control flow, because an unconditional control flow operation doesn't need to be inside an expression.
Which brings up the example (I don't think |
That would mean I'd suggest that we allow all the constructs that follow naturally from the grammar (so there wouldn't be any special rules about syntactic contexts where a |
My bad, I meant |
Yep, I think
is a rather natural rule: Anybody who wants to do anything other than "take it all" will need the parentheses, and that will be once in a blue moon anyway, and we'll probably benefit from having those parentheses when reading code simply because it is going to be tricky stuff when they are needed. We might need to note that we're relying on the disambiguation that the Dart grammar has always relied on: The first rule that yields a successful parse is the one that we will use (which is what top-down parsers will naturally do anyway), so this is cementing an already deep property of syntactic disambiguation in Dart. However, anyone who attempts to write a Dart grammar for yacc or any LR/LALR parser generator will immediately notice that there is a lot of work to do in order to eliminate shift/reduce and reduce/reduce conflicts. So this isn't new, it's just one more step in a direction where we have already taken many steps. |
I like "return is always greedy" too. And there is even a precedent for it: expression function bodies have always been greedy in precisely this way, so for example Personally I would be comfortable generalizing the rule to apply to |
From the static analysis POV I don't see any issue with adding these new expressions. |
Yeah, I definitely think that if we do this, If we wanted to really open the can of worms... it would be great if |
I think making One thing we lose is parser recovery. Currently, I really like this example (and the throw/break/continue variants), since the named value is non-nullable: final myField = this.myField ?? return; I also like I'm not sure the other cases carry their weight. Usefulness
In a statement context e1 && return e2; is pretty much the same as if (e1) return e2; Not worth it. In contrast, I am quite taken with 0 <= index && index < list.length || return null; This is almost the perfect minimal code. It expresses the conditions which are true after the statement, and what happens when it is not true. It establishes an invariant that can be used to reason about the following code. The guard is exactly what you would write in an assert statement, making it easy to move between asserts and code that has to do checks. Clear, concise and malleable. There are alternatives, but they all feel a little heavier: // Python-like `unless`
unless (0 <= index && index < list.length) return null;
// if-not expression
if! (0 <= index && index < list.length) return null;
// infix `else` statement
0 <= index && index < list.length else return null;
// An new infix statement operator `|||`
0 <= index && index < list.length ||| return null; ParsingFor operators "return is always greedy" seems useful in converting expressions that contain dead code into expressions that do something useful. What worries me is that it treats Greedy parsing of function expressions has worked well, but I'm not sure the lesson translates. Greedy parsing of expression functions works well only because we have no operators on function. Consider if we added a function composition operator Rather than making The grammar change that incorporates this limited 'tail position' element looks like
"return is always greedy" is a small change:
|
if that's would be accepted, is it also possible to accept int value = try int.parse(input) ?? return; |
This would be expressed as int value = int.tryParse(input) ?? return; |
@mateusfccp of course, it's for example. |
I'm not particularly smitten with The places I see a use for control flow is inside larger expressions, not necessarily at the end. Something like var x = foo[0] ?? return false;
var y = foo[1] ?? return false;
return x + y < 5; Again, it's an early bailout in the case where you don't have a value. For booleans, it should be when you don't have a property needed for the following computation. if (x <= 0 || y <= 0) return;
doSomethingWith(x * y); As @stereotype441 pointed out, the I definitely do want We also need to restrict such control flow to places where it makes sense (inside function bodies).
We might also need to disallow control flow expressions in late local variable initializers. Or not, the access to the variable needs to happen in the same scope, so it's probably possible to execute the side effects. It's just not very readable that a variable reference like @ykmnkmi I've considered expression level exception handling, but haven't found a good model. Catching any error and turning it into I'd also accept |
The "small change" of The alternative would be rules like
(all the way down the grammar hierarchy) which ensures that the diversion can only be at the end of an |
@rakudrama wrote:
I certainly think it's worth considering a minimal model where only a few constructs allow for the new expressions that introduce non-trivial control flow, as you propose here. However, we would presumably also be able to hint/lint the keyword salad, because it gives rise to something that is similar to "unreachable code"? It would be more like "non-normal completion can never occur" if we have I understand that this is extra work, but the overall outcome could be an improvement.
Or they give rise to a non-normal completion ('returning with an object'). How do we know that this is never useful? We often try to avoid introducing arbitrary restrictions on any given language construct, because there might be legitimate and helpful usages that we haven't considered. Car? getElectricCar(Customer customer) {
// Compute number of electric cars that we can produce today, not already sold.
int eCarCount = ...;
var car = Car(
isElectric: eCarCount > 0 || return null,
isHybrid: customer.wantsHybrid() && return null,
... // Other constructor arguments.
);
... // Register `car` as sold, count down available materials.
return car;
} So the idea is that we can bail out of arbitrary computations. We might then use Obviously, it is possible to write completely unreadable code using these constructs, just like it's possible to write unreadable code using any other construct, but if we as a community can foster a set of useful idioms using the new constructs then I believe it can be used in a readable manner, and I would tend to assume that it's better to avoid arbitrary restrictions. The approach that I mentioned here would provide the more general syntax with |
We could introduce a rule of "don't use a value of type Any use of a That would prevent That also automatically restricts use of |
Wouldn't this also prevent expressions like |
No, the static type of The tricky part here is to define what it means to "use" a value. Basically, it's having the value assigned to anything (RHS of assignment, parameter of invocation, operand of non-short-circuiting operator), or being used as receiver (of method or operator, which implicitly assigns the value to Here Any operation where the value of the expression can either be found without evaluating one operand, or will be the value of that operand, effectively puts the operand in "tail position". That's all the short-circuiting operators and |
Only if you disagree with @eernstg that the grammar is already ambiguous and conflicts are resolved by being greedy. I was taking that as a premise. |
I think it is a bad idea to make e.g. Consider when you are in the editor and typing a statement just before an existing return-statement: x = z + ▂
return; The analyzer has to make sense of that partial state which is now a valid expression. Should a completion be offered? /cc @bwilkerson |
Right, it is unlikely to be useful to use the new 'non-normal completion' expressions as operands of operators that do not have a conditional element in their semantics, except of course for expression statements. So the immediate candidates are the following:
Several of these may look silly (e.g., a By the way, |
Not sure if this is fundamentally different, but it can be noted that it is currently valid to write |
I think this feature would be particularly welcome now with the introduction of switch expressions. I'd argue that allowing control-flow in switch expressions is arguably an easier to read solution that what we have today. For example: Node? someFunc(Node node) {
final err = switch(node) {
case Valid(cond: bool b) when b => return node;
case HasErr(err: String err) => err,
_ => 'default error',
};
handleErr(err);
return null;
} |
Not to be confused with "control flow as expressions", which is a different feature.
I've been writing a lot of NNBD Dart recently, and I've been running into this messy construct a lot when it comes to field promotion:
I would love to write:
This construct is very common in languages that treat statements as expressions, like Kotlin and Rust.
Proposal
Like
throw
, other control flow statements likereturn
,break
, andcontinue
should be allowed in expression position as an expression that results in the typeNever
.There's still an open question whether the precedence should be corrected to flow more naturally, but this might open up some syntactic ambiguities. The examples presented here assume there is no precedence issue.
Examples
Summing up all non-null integers in a list:
Parsing data without using exceptions as control flow:
Concerns
Most to all syntax highlighters highlight control flow keywords with a contrasting bold style.
Both
throw
andawait
are allowed in expressions.throw
will always jump execution to the nearest exception handler, which may be way outside of the current function.await
may never return, if the waitedFuture
never completes or throws an error.Other Languages
Rust
Rust allows the use of the "try-operator" to implement this in a way that is less syntatically-intrusive. While it does allow for statements in expression position, the lack of a ternary or elvis operator means the above forms cannot be used. Still, we can rewrite the examples in Rust:
The text was updated successfully, but these errors were encountered: