-
Notifications
You must be signed in to change notification settings - Fork 213
Equality of Type
objects with NNBD types.
#444
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
[Edit: Corrected We discussed that and specified that
Type typeOf<X>() => X;
typedef F2 = void Function(int);
typedef void F3(int i);
bool mustBeTrue4 = F2 == F3;
class C<X> {
Type get typeArgument => X;
}
C<C<dynamic>> c = C<C<int>>();
main() {
print(typeOf<List<int>>() == typeOf<List<int>>()); // 'true'
print(typeOf<List<F2>>() == typeOf<List<F3>>()); // 'true'
print(typeOf<Function(double)>() == typeOf<dynamic Function(double d)>()); // 'true'
print(c.runtimeType == C<C<int>>().runtimeType); // 'true'
print(c.runtimeType == typeOf<C<C<int>>>()); // 'true'
print(c.typeArgument == typeOf<C<int>>()); // 'true'
} So the starting point should be closer to " I'd hope that we could eliminate But then we have the remaining conflict: Should we let the subtyping relation dictate that This is of course not nice, because it breaks the transitivity of I tend to prefer the subtype rule: Both reified types and dynamic errors associated with type checks are run-time phenomena, and the latter are solely based on subtype requirements, so a strictly subtype based semantics will ensure a certain level of consistency. |
The actual implementation is more like "equal if same type" than "equal if mutulal subtype". It just matters what the implementation in question considers to be the "same type". Examples: import "dart:async";
Type typeOf<X>() => X;
main() {
print(typeOf<dynamic>() == typeOf<Object>()); // false
print(typeOf<dynamic>() == typeOf<void>()); // false
print(typeOf<FutureOr<Object>>() == typeOf<Object>()); // false
print(typeOf<FutureOr<Future<Object>>>() == typeOf<Future<Object>>()); // false
print(typeOf<FutureOr<Null>>() == typeOf<Future<Null>>()); // false
print(typeOf<void Function<T>(T) Function()>() == typeOf<void Function<T>(T) Function()>()); // false VM, true dart2js
} All of these are examples of types that are mutual subtypes, but are also "different" types. |
So, what worries me here is that if we try to make implementations use "mutual subtype" as the rule (which they don't now), then we will have non-transitive behavior for NNBD legacy |
That's a very nice list of cases to test! ;-) Given that the implementations do not agree completely, it is not an option to just say "this is what we have, let's put that into the specification". I think the model which is based on mutual subtyping is simple, consistent, and conceptually well motivated; so it would be nice if we can get that without too much breakage. Is there a simple and consistent rule which would justify all those 'false' results as shown in the example?
I think we'd want to ensure some kind of equality and normalization such that reified types will never need to have an unbounded number of modifiers. Then we will never have |
Flutter with Using such code, if we replace |
We cannot allow |
See #428 for some discussion around what to do with the @rrousselGit There are essentially two situations we need to consider: the migration period, in which we allow non-migrated and migrated code to interact freely; and the post-migration period (strong checking turned on) in which we use the final NNBD semantics. In the migration period, note that In the post-migration period (strong checking turned on), I think my tentative thinking here is that:
|
Keeping the current behavior is also an option: Then That would mean that (I'm more worried about |
If the definition of Switch case expressions are compared with the case expression's Would equal types in case expressions be some kind of error or warning? typedef F1 = void Function();
typedef F2 = dynamic Function();
typedef F3 = FutureOr<dynamic> Function();
...
case F1: ...
case F2: ...
case F3: ... Collections (Maps, Sets) require a coherent |
tl;dr Type Equal cases@rakudrama wrote:
I cannot find any location in the language specification where it is specified to be an error to have several switch case expressions that are equal. I also don't get any errors from the analyzer nor from the CFE on the following: main() {
var i = 42;
switch (i) {
case 1: break;
case 1: break;
default: break;
}
} So it would make sense to indicate that there's something fishy in this situation, but it would be a general switch thing, not just a type literal-in-switch-case thing. Also, it could be at any level (it could be hint or lint), because the semantics is well-defined, and no language invariants are violated. hashCodeI agree that it may not be easy (nor cheap, in terms of run-time performance) to perform this computation for a complex type in a way that matches the implementation of Can we project the issue away?
Maybe we want structural equivalence of the original representation instead?, such that we could evaluate Evaluate I think we could find such a projection, but it might not help much — it's just a different way to ask for the same computation. Do we want the specified semantics?I would consider it more important that a switch statement behaves in a meaningful way (e.g., that it doesn't decide that
and I agree that it would be less meaningful and less useful if we drop that property. So the implementation effort matters, but otherwise I think the specified behavior is preferable over any alternatives. Breaking change? Alternatives?With that, the main issue here is that this was specified in commit dart-lang/sdk@4816b60 (Sep 2018), but there has been a communication glitch, and now this rule appears to come as a surprise. Whenever we have a property which is specified but not implemented after a while, we need to consider whether it should be revisited as a breaking change. If so, we'd need to consider some alternatives:
@rakudrama is this a reasonable description of the situation as you see it? The treatment of @lrhn, @leafpetersen, @munificent, WDYT? |
I'm all for making Changing the implemented behavior to the specified behavior is technically a breaking change—It's possible that there exists a program which will change behavior when it can no longer distinguish the runtime type of a We originally decided that there is no However, that would still not help us for situations like: Type typeOf<T>() => T;
switch (typeOf<FutureOr<Object>>()) {
case Object:
print("Did I get here?");
break;
default: throw "Nope";
} Here the Also: So, what is the currently implemented behavior? Both VM and dart2js consider For switch cases, we know that the case expressions are constant, so they are literal expressions, but the switch expression is not constant, so we can't use Another option is to specify the current behavior. With NNBD in mind, we should worry about whether So, the options I see are:
I think the second option is the most realistic. It is still not trivial, but we pretty much already do it correctly, we just have to specify the same thing. We can still erase |
No matter what else we are doing, I assume that we agree to start by expanding type aliases, cf. dart-lang/sdk#32782. Option 1For option 1 we could use type normalization to make We'd want normalization to normalize all top types to That kind of normalization goes one step further than the kind that we would want to use during static analysis. For instance, we would want static normalization to replace This approach makes sense conceptually: Mutual subtypes are "the same type" according to any soundness consideration (because it would always be possible to switch from using one or the other type for an expression via an upcast). Option 1.1An option 1.1 could be obtained as a variant of option 1: It would again be based on type normalization, but in this case we would use exactly the same normalization at run time as the one that we would be using during static analysis. This normalization would preserve the distinction between This option is a refinement of option 1 (whenever option 1 considers a set of types as the same, this option could further divide them into smaller sets), and that's also soundness preserving. We need the distinction between atomic top types during static analysis, and it may be considered as "noise" to preserve a distinction which goes beyond that which is needed for soundness, but it will hardly be considered very strange to keep it at run time as well. Option 2Option 2 corresponds to having a structural equality test where equality only needs to consider different syntactic forms denoting the same entity. So I agree that this one is quite practical. If we say that it's 'the current behavior' then it's non-breaking by definition, but I also think that we can specify this in a manner which is both reasonable and non-breaking in practice (and if there is an esoteric corner case where the current behavior amounts to a surprising exception then we should be able to handle the discrepancies as bugs). The conceptual justification here would be that types that look different are just different (except for type aliases and prefixes). Can we have it both ways?If we settle on option 2 because that's the non-breaking path ahead, and it's relatively justifiable conceptually, do we then make it harder to use normalization during static analysis? Would that imply that we need to have a dynamic representation of class C<X> { List<X?> xs = []; }
main() {
C<int?> c = ...;
print(c.xs.runtimeType); // `List<int??>`.
} I think it could be a source of serious confusion if we make a distinction between such types as |
I have a proposal to resolve this here. The proposal is essentially as follows:
|
I've landed my proposal in the feature spec, closing this. |
The equality (
==
) ofType
objects is currently undefined, but generally implemented as equal only if it is the same type. It distinguishes "equivalent" types (mutual subtypes) if they are not the same type, soObject == dynamic
is false, and theType
objects forList<Object>
andList<dynamic>
are also distinct.This is the least equivalence that is still useful. It does not attempt normalization.
With NNBD types, we get further ways to have different-but-equivalent types, like
int?
andint??
(etc.). Should that affect the behavior ofType.==
?The
runtimeType
of any object is either a non-nullable type orNull
, so runtime types will never be a?
type or a*
type, or even aFutureOr
type.The runtie type might contain one of those union types, though.
Given a
C<int?>
and aC<int??>
, keeping the current behavior and retaining the??
type will cause the runtime types to differ. Do we want that?A similar problem can happen with
*
types. If I pass aC<int?>
instance and aC<int*>
instance, their runtime types will not compare equal. That's probably for the best, since we can't have equality here, and forC<int>
andC<int*>
too, and not break transitivity. So, making those types distinct is the right thing.The safest approach is probably to keep the current behavior, and not try to be clever in any way.
It makes
Type
equality less useful in the presence of non-migrated libraries, butint*
really is a different type than bothint
andint?
.The text was updated successfully, but these errors were encountered: