Skip to content

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

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open

Conversation

eernstg
Copy link
Member

@eernstg eernstg commented Sep 2, 2019

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 modifier exactly 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> {} where A is a legacy class (say, class A<X> { foo(X x) {}}), which means that we can have a variable b of type B<num>, and we can call b.foo(1.23), and it will throw at run-time.

We could also say that the method foo is inaccessible in B (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 prohibit covariant parameters.


- _T_ is of the form _S<sub>0</sub> Function&lt;X<sub>1</sub> extends B<sub>1</sub>, ...&gt;(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&lt;X<sub>1</sub> extends B<sub>1</sub>, ...&gt;(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:
Copy link
Member

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?

Copy link
Member Author

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_.
Copy link
Member

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?

Copy link
Member Author

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.

Copy link
Member

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_.

Copy link
Member

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.

Copy link
Member Author

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
Copy link
Member

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).

Copy link
Member Author

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.

Copy link
Member Author

@eernstg eernstg left a 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
Copy link
Member Author

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_.
Copy link
Member Author

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_.

Copy link
Member Author

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?


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.*
Copy link
Member

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.

Copy link
Member

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

@plammens
Copy link

plammens commented Aug 1, 2024

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 Filter<Fruit> should be a Filter<Apple>, because if the predicate accepts fruits it certainly accepts apples.
But as written above it would be illegal according to the spec because E appears in an out position in the return type of the apply method.

However, we know that the apply method will never alter the actual runtime type of the given elements, as it is just a filter, so indeed a Filter<Fruit> would return Apples with apply if you give it Apples, and not any other type of Fruit.
This is probably not something the type checker can determine, so, would there be a way of ignoring this illegal "out" position, allowing E to be contravariant?


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.

@plammens
Copy link

plammens commented Aug 1, 2024

Also I think MyClass<SomeTypeArgument> should always be a subtype of MyClass<dynamic>, regardless of variance of the type parameter, because dynamic is not some kind of root supertype but instead it just means disable static type checking for this parameter.

With the current variance experiment, I can't assign for instance a Filter<int> to a Filter<dynamic>.

@lrhn
Copy link
Member

lrhn commented Aug 7, 2024

because dynamic is not some kind of root supertype

It is, actually. The dynamic type is a top type, so it's a supertype of all Dart types.

If you have class Filter<in T> { void add(T value) {}}, and we allowed assigning a Filter<int> to Filter<dynamic>, then we get unsoundness:

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 f4.add("banana") to not do extra runtime type checks, because soundness ensures that any value assigned to Filter<String> will definitely accept a String as argument to add.

@plammens
Copy link

plammens commented Aug 8, 2024

It is, actually. The dynamic type is a top type, so it's a supertype of all Dart types.

Yes, yes, my bad, I realized this later.

In any case the type system is still unsound because of covariant type parameters by default 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants