-
Notifications
You must be signed in to change notification settings - Fork 213
[extension-types] Use the extension type erasure during flow analysis? #3534
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
Comments
There are two places where this matters. The exhaustiveness algorithm, which just gives a plain result of exhaustive or not. It has to use the erased type. The flow analysis, which includes both control flow analysis, type inference and type promotion. Then there is the static type analysis. extension type ET(int i) {}
extension type TO(int i) implements ET {}
int f() {
ET x = ET(1);
switch (x) {
case int y:
x.expectStaticType<Exactly<ET>>;
y.expectStaticType<Exactly<int>>;
case TO z: // Unreachable, I know.
x.expectStaticType<Exactly<TO>>;
z.expectStaticType<Exactly<TO>>;
}
} Here the I think the static types should just fall out of the current definition, so it really is only control flow analysis that needs to care about the erased type. Including "can this pattern match that value" logic. So, in conclusion: Flow analysis must use erased type, because it must match runtime behavior to be sound. I don't see any reasonable alternative to using the erased types. |
@lrhn this is concerning if true, but I don't think it is demonstrated by your example or discussion. Can you elaborate? |
I'm really just using the definition of "sound" as "static analysis correctly predicts runtime behavior". If the analysis doesn't actually predict runtime behavior, it's not a sound approximation of the runtime behavior. Whether that actually leads to type-unsoundness depends on what we use the analysis for. For flow analysis, that's usually whether some code may run or not. If we end up incorrectly assuming that all code can potentially run, then that's probably safe. It's the conservative fallback position. If we incorrectly assume that some code definitely cannot run, or definitely will run, then it's probably possible to escalate that to type- or definite-assignment-unsoundness. Maybe the incorrect assumption will just end up making a switch non-exhaustive, where it should be exhaustive, which stops itself from ever running. Or prevent promotion that would otherwise be correct, but not be usable to do an unsound promotion. Maybe. It's not that I have concrete examples. Let's try. Let's say that flow analysis assumes that a type check for extension type const Ex(int value) {}
void main() {
int i = someInt();
int x;
switch (i) {
case >0: x = 1;
case Ex(value: var n): print("$n here");
case _: x = -1;
}
// x definitely assigned here?
} If flow analysis predicts that If it doesn't predict that case won't be executed, then it's because we don't actually use the analysis result, which is trivially safe. But then it doesn't matter what we do. (I can't come up with an example where analysis could predict a match that cannot happen in practice, which it wouldn't predict on the erased type too. If we have such a case, it would be the kind that could potentially cause an incorrect exhaustiveness result.) |
This is not true at all. Essentially all static analyses will fail to predict some runtime behaviors. All non-trivial static analyses must over-approximate, which means they fail to predict some runtime behaviors. Soundness in general is the property that all actual possible executions are included in the approximation (along with possibly others), but by its nature, over-approximation means that the analysis may be failing to predict things (e.g. which path through a switch is taken). The inferences that you draw from the analysis must account for over-approximation if it is relevant.
Right. There are lots of places where we choose not to promote (e.g. because not a subtype), and therefore include in the set of executions some that are in fact not possible.
Right. This would be unsound, because the approximation of the feasible execution paths computed by the analysis would not include some of the actual execution paths. We shouldn't do this. This isn't really a new example for extension types though - we can already have a switch where one of the cases is an object pattern which is not subtype related to the scrutinee but the case is still reachable. The following example is correctly rejected, since it is possible for the two classes to share a hidden subtype. class A {}
class B {}
void test(A a) {
int x;
switch (a) {
case B(): print "hello";
case _ : x = 1;
}
print(x);
} For essentially the same reason, a use of So I don't think I see anything that we need to do here - as best I can tell, flow analysis is behaving soundly, which is the main thing. Now it's certainly true that we could choose to do something differently here, but I'm not sure I see the motivation. |
We can always give a less precise analysis. Look at the example in #54444. We've accepted that there are done differences between the two algorithms, where the flow analysis can't be as precise, but this difference seems unnecessary. It can be just as precise here, by using the erased type. |
(Joining this thread late because I was on vacation) When it comes to flow analysis, my definition of "sound" is something like: "flow analysis is sound if and only if, for all possible program inputs, at every point in program execution, the source code associated with the program counter is at a location that flow analysis considers reachable, and the value in each local variable has a runtime type that's a subtype of its promoted type." Under that definition, if flow analysis fails to promote a variable that it could have promoted, or fails to notice that a certain code path is unreachable, then that isn't a soundness issue. So, from the standpoint of soundness, I agree with Leaf that we don't need to do anything here. But of course, soundness isn't the only goal of flow analysis. Another goal of flow analysis that we took into consideration during the development of pattern support is the goal of reducing the mismatch between the exhaustiveness checker and flow analysis's notion of reachability. That's important because whenever these two algorithms disagree, it tends to lead to user confusion. The example code in dart-lang/sdk#54444 is exactly the kind thing we ran into while we were implementing patterns--a switch statement that is considered exhaustive by the exhaustiveness algorithm but not by flow analysis, so the user gets a confusing error message (the analyzer says the code after the switch statement is reachable, but adding a The mechanism we already have in place to try to reduce this sort of confusion is that when flow analysis is handling any sort of pattern match that implicitly performs an (Incidentally, this is the mechanism that allows flow analysis to understand that a switch statement ending with This mechanism is specific to pattern matching, and is independent of the rest of the logic of flow analysis. I believe it would be fairly straightforward to update it to perform type erasure. That would mean that this code (from @eernstg's initial example) would be allowed: extension type ET(int i) {}
int f() {
switch (ET(1)) {
case int _: return 0;
}
} As would this example from @lrhn's comment: extension type ET(int i) {}
extension type TO(int i) implements ET {}
int f() {
ET x = ET(1);
switch (x) {
case int y:
x.expectStaticType<Exactly<ET>>;
y.expectStaticType<Exactly<int>>;
case TO z: // Unreachable, I know.
x.expectStaticType<Exactly<TO>>;
z.expectStaticType<Exactly<TO>>;
}
} (and flow analysis would understand that the Since we're past the cutoff for Dart 3.3 Beta 3, this change would have to be implemented and cherry-picked before the release deadline (Feb 14), so we should take the tight schedule into account when deciding what to do. |
In today's language team meeting, some folks asked for clarification about the catch-22 that a user might run into due to differences in behavior between the exhaustiveness checker and flow analysis. I've written up an example. It's not quite as bad as I thought. Imagine the user writes this code (which I would argue is somewhat contrived but not absurd): extension type FancyString(String s) implements String {
String fancify() => '**$s**';
}
String describeFancily(String s) {
switch (s) {
case '':
return 'The empty string';
case FancyString fs:
return 'The string ${fs.fancify()}';
}
} Attempting to run this code results in a compile-time error:
(This is because detecting a missing If the user tries to fix the error by changing the switch statement to this: switch (s) {
case '':
return 'The empty string';
case FancyString fs:
return 'The string ${fs.fancify()}';
case _:
throw 'UNREACHABLE';
} Then the analyzer reports:
(This is because detecting an unreachable case is the responsibility of the exhaustiveness checker, and the exhaustiveness checker is capable of figuring out that So the catch-22 is, if the user doesn't include the unreachable However, it's not quite as bad of a catch-22 as I previously thought, because for some reason, |
Note: I've prototyped this change. It turned out to be fairly straightforward (3 lines of code, plus moving an abstract method from one interface to a super-interface). It's also fairly targeted (the only logic affected is the computation of the I still need to clean up the CL, add some test coverage, and verify that no other tests are broken by the change; I'll work on that today. |
I know I said the opposite in the last language meeting, but my feeling is that exhaustiveness checking should not erase the extension type and used the representation type when the matched value type or any of the case types are extension types. Using an extension type in a switch that does type tests is a deeply weird operation so it's unlikely that anything particularly elegant will fall out. It's as weird and problematic as using But, in general, the user experience we try to offer for extension types is that the representation type is as encapsulated as we can make it without sacrificing the fundamental performance goal of erasing the extension type. So if the user hasn't chosen to make an extension type reveal the representation type, then as many operations as possible should treat the extension type as opaque. To me, that extends to exhaustiveness checking. Even though, yes, the runtime type tests will use the representation type, the user did choose to wrap them in extension types for some reason, so the static analysis should work on that wrapped type. It's annoying weird if they get stuck with an error without a default case and a different error with one. But I think the odds of running into that are relatively low. When they do run into it, they should be able to avoid it by either not using the extension type in the case, or wrapping the matched value. So: String describeFancily(String s) {
switch (FancyString(s)) { // Make matched value a fancy string too.
case '':
return 'The empty string';
case FancyString fs:
return 'The string ${fs.fancify()}';
}
}
String describeFancily(String s) {
switch (s) { // Make matched value a fancy string too.
case '':
return 'The empty string';
case var s:
return 'The string ${FancyString(s).fancify()}';
}
} One way to look at this is that the origin sin is extension types can establish a subtype relation with their representation type but not equivalence to it. In: extension type FancyString(String s) implements String {
String fancify() => '**$s**';
}
FancyString f = 'hi'; You get an error because extension type FancyString(String s) implements String {
String fancify() => '**$s**';
}
String describeFancily(String s) {
switch (s) {
case '':
return 'The empty string';
case FancyString fs:
return 'The string ${fs.fancify()}';
}
} Exhaustiveness thinks that You could imagine extending the language so that if an extension type declares that it implements its representation type, then we treat the extension type as equivalent to the representation type. That would allow assignment in both directions: String s = FancyString('hi');
FancyString f = 'hi'; And then exhaustiveness checking would know that a case of an extension type covers all values of the matched value type. |
@munificent wrote:
Please note that this is a different topic: The exhaustiveness analysis has been specified to use extension type erasure since more than 6 months ago (it's there in the first version of the feature specification where the feature is called 'extension types'; I haven't tracked it down to the time where it was added to the inline class feature specification, or even earlier proposals). It has been implemented and tested for quite a while. The question here is just whether we should also perform extension type erasure at the relevant points in the flow analysis, such that we avoid getting catch-22 advice from the two analyses, as described by @stereotype441 here.
I think some extension types have this encapsulated nature (they are "closed"), and other extension types are essentially interface extension devices: They are adding a bunch of members to an existing type, or shadowing an existing member by another one (a forwarder) whose member signature is considered better for some reason (these extension types are "open"). I actually included keywords like So what we have today is a distinction which is based on implemented types: extension type FancyString(String it) implements String {...} // Open.
extension type SecretString(String it) {...} // Closed. The criterion is that the extension type implements a non-extension type (directly or indirectly). So Note that it's a compile-time error unless that implemented non-extension type is a supertype of the representation type. In other words, this We're using this lint to flag each location in code where a dynamic inspection is used to transition a given object from one type to another (e.g., by promoting a variable, casting an expression ( For instance, it would lint To avoid the lint, call a constructor. If you can't do that then it is probably a hint from the developer that you shouldn't do it. In contrast, no lint message is emitted at The situation is somewhat convoluted, but I still think it's worth considering the "soft encapsulation" support which is already coming (that is, the lint and the reliance on |
If we don't let an extension type exhaust its representation type, or vice versa, it becomes impossible to exhaust an extension type at all, except by the type itself or a wildcard. That would prevent exhaustiveness for a use like: extension type const Option<T>._(({T value})? _) {
const factory Option.some(T value) = Some<T>;
static const Option<Never> none = None._(null);
}
extension type const Some<T>._(({T value}) _) implements Option<T> {
const Some(T value) : this._((value: value));
T get value => _.value;
}
extension type const None._(Null _) implements Option<Never> {
const None._opt() : this._(null);
} which you can currently exhaust as: void main() {
var b = DateTime.now().millisecondsSinceEpoch.isEven;
Option<int> maybeInt = b ? const Option.some(42) : Option.none;
var intWithDefault = switch (maybeInt) {
Some(: var value) => value,
None() => 0,
};
print(intWithDefault.toRadixString(16));
} (I know I keep dragging up this example. I don't have a lot of example, but something like this is not unreasonable. I'll say that it's a likely use-case, maybe not precisely this, but then something else on top of a nullable type.) The code above works today. It's exhaustive, and detected as such. If we say that a That feels like taking away a useful feature. I know it feels like looking below the abstraction, but it's the author of all three types that is doing that. So, is there some way we can restrict this to only types that are designed together (and already know each other's representation types), and not necessarily allow just anybody to add a type from the side? Consider: If E is an extension type with representation type S.
(If that makes sense at all. I think it does. I can be wrong.) That is, an "open" extension type like A parallel hierarchy like extension type E1(bool? b) { ... }
extension type E2(bool b) implements E1 { ... } // Parallel hierarchy
extension type E3(E1 _) implements E1 { ... } // Adding more members.
extension type E4(E2 _) implements E1 { ... } // Not included! Does not count towards exhausting E2 or E1. It's complicated, but it does allow some reasonable use-cases, but also doesn't allow just any unrelated type to exhaust another. There needs to be a direct implements relation, which either introduces a subtype/subspace of the representation type, or introduces more members on essentially the same type. I'd rather keep the current "just use erased representation type" approach than completely prevent sub-extension types from being able to exhaust supertypes. |
Please note again that this is a different topic: The exhaustiveness analysis has been specified to use extension type erasure since more than 6 months ago, and It has been implemented and tested for quite a while. This issue is about the flow analysis, about whether or not it should be aligned more closely with the existing exhaustiveness analysis. |
The fullly cleaned-up CL is here: https://dart-review.googlesource.com/c/sdk/+/345822. In addition to the 3 lines I mentioned earlier, and moving an abstract method from one interface to another, I had to change the analysis of cast patterns; it was previously relying on the I still believe it's worth moving ahead with this change (assuming we retain the decision to use extension type erasure in exhaustiveness analysis), and cherry-picking this CL to the beta branch. But I'll wait for a consensus from the language team before moving forward with this. |
My read on this is that unless we want to revisit using the erased type for exhaustiveness, then the consistent thing to do is to use the erased type in flow analysis here as well. Does anyone feel that, given no time/implementation constraints, we would prefer in fact keep the current yes/no split? Changing exhaustiveness to not use the erased type feels like a big and risky change to make now. If we feel strongly that this should be done, I might be inclined to do this as a breaking change in a follow up release (or at least a patch release). It feels like a dangerous change to be making with such a short run up. So the best options to me seem to be to either:
Does that seem right? Thoughts, disagreement? |
Just to make it explicit: The yes/no split may cause a catch-22-ish situation with the static analysis, as described by @stereotype441 here. |
On Wednesday, @leafpetersen said:
I didn't respond last week because I wanted to give others a chance to weigh in, and I think my opinions are clear from my previous comments on this issue (I favor accepting the specified behavior and fixing flow analysis in a cherry pick, which is what https://dart-review.googlesource.com/c/sdk/+/345822 does). Since we haven't had any further discussion (other than @eernstg's helpful clarification), and the ship date for Dart 3.3 is in 3 weeks, I'm inclined to move forward with my fix. If I don't hear objections by tomorrow, I'll send out https://dart-review.googlesource.com/c/sdk/+/345822 for review, and then, when it lands, I'll start the cherry-pick process. |
Ship it! |
…ity. Whenever a pattern performs an implicit `is` test, flow analysis attempts to determine whether the `is` test is guaranteed to succeed; if it is, then flow analysis considers the code path in which the `is` test fails to be unreachable. This allows flow analysis to recognize switch statements that are trivially exhaustive (because one of the cases is guaranteed to match), avoiding spurious errors such as "variable must be assigned before use" or "missing return statement". This change upgrades the logic for computing when an `is` test is guaranteed to succeed, so that it accounts for type erasure of extension types. This brings flow analysis's treatment of switch statements into closer alignment with the exhaustiveness checker, which should reduce the risk of confusing error messages. For more information see dart-lang/language#3534 (comment). Fixes dart-lang/language#3534. Bug: dart-lang/language#3534 Change-Id: Ib73d191e04e7fa8c0e6888c2733dae73d8f389da Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/345822 Reviewed-by: Chloe Stefantsova <[email protected]> Commit-Queue: Paul Berry <[email protected]>
Since the fix has landed (dart-lang/sdk@97edad1), I'm going to close this issue now. The only remaining work is to cherry-pick the fix to the beta branch; that work is tracked in dart-lang/sdk#54720. |
…achability. Whenever a pattern performs an implicit `is` test, flow analysis attempts to determine whether the `is` test is guaranteed to succeed; if it is, then flow analysis considers the code path in which the `is` test fails to be unreachable. This allows flow analysis to recognize switch statements that are trivially exhaustive (because one of the cases is guaranteed to match), avoiding spurious errors such as "variable must be assigned before use" or "missing return statement". This change upgrades the logic for computing when an `is` test is guaranteed to succeed, so that it accounts for type erasure of extension types. This brings flow analysis's treatment of switch statements into closer alignment with the exhaustiveness checker, which should reduce the risk of confusing error messages. For more information see dart-lang/language#3534 (comment). Fixes dart-lang/language#3534. Bug: dart-lang/language#3534 Change-Id: Ic3f840bbc5793aecf4f6aa4e569526e8482181fc Cherry-pick: https://dart-review.googlesource.com/c/sdk/+/345822 Cherry-pick-request: #54720 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/348200 Reviewed-by: Konstantin Shcheglov <[email protected]> Reviewed-by: Chloe Stefantsova <[email protected]>
Thanks to @sgrekhov for bringing up this issue. See also dart-lang/sdk#54444 where a similar situation is mentioned.
Consider the following program:
This program is rejected by the analyzer and the CFE, reporting that
f
might reach the end of the function body (in which case it would return null, which is not type correct because the return type is non-nullable).The reason for this behavior is, presumably, that the flow analysis considers the type of the scrutinee to be the extension type
ET
, and it does not consider the extension type erasureint
.However, the extension type erasure is the most precise information we have about the situation at run time, and the sub/super-type relationships of extension types to other types is not helpful during flow analysis.
I'd recommend that flow analysis should use the extension type erasure to determine whether or not a case like
case int _
is guaranteed to match the scrutinee. The erasure should be applied to the type of the matched value as well as to any object patterns whose type is an extension type.Note that there is a certain overlap between this issue and #3524.
@dart-lang/language-team, WDYT?
The text was updated successfully, but these errors were encountered: