-
Notifications
You must be signed in to change notification settings - Fork 213
Feature spec of explicit variance #557
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
base: main
Are you sure you want to change the base?
Conversation
|
||
- _T_ is of the form _S<sub>0</sub> Function<X<sub>1</sub> extends B<sub>1</sub>, ...>(S<sub>1</sub> x<sub>1</sub>, ... S<sub>k</sub> x<sub>k</sub>, [S<sub>k+1</sub> x<sub>k+1</sub> = d<sub>k+1</sub>, ... S<sub>n</sub> x<sub>n</sub> = d<sub>n</sub>])_ or of the form _S<sub>0</sub> Function<X<sub>1</sub> extends B<sub>1</sub>, ...>(S<sub>1</sub> x<sub>1</sub>, ... S<sub>k</sub> x<sub>k</sub>, {S<sub>k+1</sub> x<sub>k+1</sub> = d<sub>k+1</sub>, ... S<sub>n</sub> x<sub>n</sub> = d<sub>n</sub>})_ where the type parameter list and each default value may be omitted, and _S_ occurs in a covariant position in _S<sub>j</sub>_ for some _j_ in 1 .. _n_. | ||
|
||
We say that a type _S_ occurs in an _invariant position_ in a type _T_ iff one of the following conditions is true: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should there be a case here for when T
is C<exactly B>
and S
appears in B
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not think we should do that. It would imply that other type variables could not occur in B, and that's exactly one of the cases where exactly
can be used on both the receiver type and the member signature to obtain a type with exactly known type arguments.
We would then rely on the erasure step to preserve soundness.
So, basically we would allow exactly
even in the case where it is applied to a type in a member signature which is not known to be invariant, but then we're erasing it whenever we cannot show that it is invariant, when that member is used.
|
||
*The variance for each type parameter of a type alias is restricted based on the body of the type alias. Explicit variance modifiers may be used to document how the type parameter is used on the right hand side, and they may be used to impose more strict constraints than those implied by the right hand side.* | ||
|
||
We say that a type parameter _X_ of a type alias _F_ _is covariant/invariant/contravariant_ if it has the variance modifier `out`/`inout`/`in`, respectively. We say that it is _covariant/contravariant_ if it has no variance modifier, and it occurs only covariantly/contravariantly, respectively, on the right hand side of `=` in the type alias (*for an old-style type alias, rewrite it to the form using `=` and then check*). Otherwise (*when _X_ has no modifier, but occurs invariantly or both covariantly and contravariantly*), we say that _X_ _is invariant_. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the case that X
does not occur in the RHS?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So far we have considered such a type variable to have no variance. This may mean that no cases are applicable at some point during instantiation to bound, and this means that the i2b fails with an error. Dmitry put in an explicit representation of "not having any variance" in order to make it work this way. I think it's a meaningful choice, but we could of course switch over to use some default value for an unused type variable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Either way, this definition needs to account for it. As written, there's a contradiction between the parenthetical comment in the last sentence and the rest of the last sentence: the non-parenthetical part says "Otherwise we say that X is invariant", but the parenthetical part says "when X... occurs invariantly or both covariantly and contravariantly". The bi-variant case is covered by the first, but not the second.
Let _D_ be a class or mixin declaration, let _S_ be a superinterface of _D_, and let _X_ be a type parameter declared by _D_. | ||
|
||
It is a compile-time error if _X_ has no variance modifier and _X_ occurs in an actual type argument in _S_ such that the corresponding type parameter has a variance modifier. It is a compile-time error if _X_ has the modifier `out`, and _X_ occurs in a non-covariant position in _S_. It is a compile-time error if _X_ has the variance modifier `in`, and _X_ occurs in a non-contravariant position in _S_. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need something around bounds? It feels like we should.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm relying on existing checks for being regular-bounded / well-bounded.
Note that I removed the support for super
bounds (lower bounds), which means that even an in
type variable can only have an upper bound. That is still meaningful, because (e.g.) an instance method accepting an argument of type X
(where we have the declarationin X extends T
) still needs to be useful in the body of that instance method.
But what else do you think we should say about bounds here?
*Note that there is no way to make it statically safe to pass an actual argument to a covariant formal parameter of a given member `m`. Any receiver may have a dynamic type which is a proper subtype of the statically known type, and it may have an overriding declaration of `m` that makes the parameter covariant. So, by design, a modular static analysis cannot guarantee that any given invocation will not cause a dynamic error due to a dynamic type check for a covariant parameter.* | ||
|
||
|
||
### Member Access Typing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I need to think about this more. It feels slightly risky to me (I think it's sound, but am not sure), and a little under-motivated. It would help to have some solid examples motivating why this is a useful feature to have (instead of just not allowing it at all).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main point is that it allows for exactly
on a receiver type to propagate through a member access. So if Iterable<T>.toList()
returns a List<exactly T>
then the caller of toList
can preserve the exact knowledge about the type argument with this rule, and otherwise there would be a downcast.
Added a comment to emphasize this point, just before the section Dynamic Semantics
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review response. Resolved several threads (please reopen if needed); a couple of threads are still open.
I haven't made any changes in the direction of supporting other variances than invariance in the use-site part, I'd expect that to be handled in another iteration.
*Note that there is no way to make it statically safe to pass an actual argument to a covariant formal parameter of a given member `m`. Any receiver may have a dynamic type which is a proper subtype of the statically known type, and it may have an overriding declaration of `m` that makes the parameter covariant. So, by design, a modular static analysis cannot guarantee that any given invocation will not cause a dynamic error due to a dynamic type check for a covariant parameter.* | ||
|
||
|
||
### Member Access Typing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main point is that it allows for exactly
on a receiver type to propagate through a member access. So if Iterable<T>.toList()
returns a List<exactly T>
then the caller of toList
can preserve the exact knowledge about the type argument with this rule, and otherwise there would be a downcast.
Added a comment to emphasize this point, just before the section Dynamic Semantics
.
|
||
*The variance for each type parameter of a type alias is restricted based on the body of the type alias. Explicit variance modifiers may be used to document how the type parameter is used on the right hand side, and they may be used to impose more strict constraints than those implied by the right hand side.* | ||
|
||
We say that a type parameter _X_ of a type alias _F_ _is covariant/invariant/contravariant_ if it has the variance modifier `out`/`inout`/`in`, respectively. We say that it is _covariant/contravariant_ if it has no variance modifier, and it occurs only covariantly/contravariantly, respectively, on the right hand side of `=` in the type alias (*for an old-style type alias, rewrite it to the form using `=` and then check*). Otherwise (*when _X_ has no modifier, but occurs invariantly or both covariantly and contravariantly*), we say that _X_ _is invariant_. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So far we have considered such a type variable to have no variance. This may mean that no cases are applicable at some point during instantiation to bound, and this means that the i2b fails with an error. Dmitry put in an explicit representation of "not having any variance" in order to make it work this way. I think it's a meaningful choice, but we could of course switch over to use some default value for an unused type variable.
Let _D_ be a class or mixin declaration, let _S_ be a superinterface of _D_, and let _X_ be a type parameter declared by _D_. | ||
|
||
It is a compile-time error if _X_ has no variance modifier and _X_ occurs in an actual type argument in _S_ such that the corresponding type parameter has a variance modifier. It is a compile-time error if _X_ has the modifier `out`, and _X_ occurs in a non-covariant position in _S_. It is a compile-time error if _X_ has the variance modifier `in`, and _X_ occurs in a non-contravariant position in _S_. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm relying on existing checks for being regular-bounded / well-bounded.
Note that I removed the support for super
bounds (lower bounds), which means that even an in
type variable can only have an upper bound. That is still meaningful, because (e.g.) an instance method accepting an argument of type X
(where we have the declarationin X extends T
) still needs to be useful in the body of that instance method.
But what else do you think we should say about bounds here?
…tance member signature
|
||
We say that _X_ _is covariant_ if it has no variance modifier or it has the variance modifier `out`; that it _is invariant_ if it has the variance modifier `inout`; and that it _is contravariant_ if it has the variance modifier `in`. | ||
|
||
If _X_ has the variance modifier `out` then it is a compile-time error for _X_ to occur in a non-covariant position in a member signature in the body of _D_, except that it is not an error if it occurs in a covariant position in the type annotation of a covariant formal parameter (*note that this is a contravariant position in the member signature as a whole*). *For instance, _X_ can not be the type of a method parameter (unless covariant), and it can not be the bound of a type parameter of a generic method.* |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should probably make it explicit that this covers setters as well. Same for line 202.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also should make it explicit that covariant
on a field covers the setter, but not the getter. So we have that:
class C<in T> {
covariant C<T> f1; // Error, the C<T> appears in an invalid position in the getter
final covariant C<T> f2; // Error as above
}
class D<out T> {
covariant D<T> f1; // Ok: the getter is properly covariant, and the setter is covered by the covariant annotation.
}
cc @kallentu
With this specification, is there a way to manually "override" or ignore (in the sense of doing a dynamic type cast) a wrong in/out position? For example, consider the following class: class Filter<in E> {
Filter(this.predicate)
final bool Function(E) predicate;
Iterable<E> apply(Iterable<E> items) => items.where(predicate);
} Ideally the type parameter should be contravariant, because a However, we know that the With the current experiment enabled, I can get rid of this error by wrapping the return type of the method in a single-element record, but I doubt this is intended. |
Also I think With the current |
It is, actually. The If you have var f1 = Filter<int>();
Filter<dynamic> f2 = f1; // Not allowed today, and isn't sound.
Filter<Object?> f3 = f2; // Definitely valid, not way to disallow.
Filter<String> f4 = f3; // Allowed because of contravariance.
f4.add("banana"); // Not sound. The point of sound variance is to allow a call like |
Yes, yes, my bad, I realized this later. In any case the type system is still unsound because of covariant type parameters by default 😅 |
This is a first draft version of the feature specification of explicit variance support in Dart: (1) Explicit variance modifiers
out
/inout
/in
that generic type declarations can use on their type parameters, and (2) an explicit invariance modifierexactly
on type arguments that client code can use in order to obtain statically safe use of legacy classes (using the current kind of covariance which requires dynamic checks).It is rather permissive, in the sense that it allows for things like
class B<out X> extends A<X> {}
whereA
is a legacy class (say,class A<X> { foo(X x) {}}
), which means that we can have a variableb
of typeB<num>
, and we can callb.foo(1.23)
, and it will throw at run-time.We could also say that the method
foo
is inaccessible inB
(just like Java wildcards: we could "filter" out the part of the API whose member signatures violate the given variance). But I suspect that it will work better in practice to catch such cases with a lint.Member declarations involving type parameters that have explicit variance modifiers are checked strictly (so
class B<out X> { void foo(X x) {...}}
is a compile-time error), but it is of course always possible to check such properties in the body and throw (void foo(Object? o) { X x = o as X; ...}
), and, with that, I saw no reason to prohibitcovariant
parameters.