dart-lang / dart-lang/language
Should use-site variance include covariance and contravariance?
- Dominant language
- TeX
- Stars
- 2.9k
- Forks
- 239
- Avg merge
- 2d 18h
- Merged PRs (30d)
- 14
Description
We have considered a simple form of use-site variance where only invariance can be expressed. This produces a subtype of the base type (e.g., `List <: List`), or it makes no difference (when `exactly` is applied to a type argument where the corresponding type parameter is `inout` already).
This is always a restriction of the set of objects with the given type, which allows developers to consider use-site invariance as a mechanism that makes types more precise.
However, we could also use the "interface filtering" mechanism that Kotlin type projections (and Java wildcards) applies for other variances: Even when `C` has an `inout` type parameter, we could allow `C` to be the common supertype of all `C` where `S <: T`. Along with this relaxation we would prohibit member accesses for any member whose signature would be an error if the type parameter had been declared as `out` (e.g., with `List`, invocations of `add` would be an error, so this is relevant for both legacy and `inout` type parameters).
If we do this, we would presumably use `out`, `in`, `inout` also for use-site variance, because the confusion created by having 6 different modifiers would be unbearable. This makes it a lot less obvious that declaration-site variance and use-site variance are two very different mechanisms, so we will need to communicate this distinction clearly.
The treatment where the set of accessible members is "filtered" corresponds precisely to the rules about member accesses with type projections in Kotlin and on wildcarded types in Java. However, it can be used for additional purposes in Dart.
This would be particularly useful in order enforce a safe (read-only) usage for a receiver whose type uses legacy parameters:
```dart
class C {
final List xs;
C(this.x);
}
```
In the body of `C`, and for clients of `C`, all usages of `xs` would then have to use only the members where `X` occurs contravariantly, i.e., `xs` will be used in a read-only manner. Similarly, `D` could be used to enforce a write-only usage for some class `D`.
Just like the similar mechanisms in Kotlin and Java, this would allow instances with type arguments that differ as specified to be the value of such a variable, even though this is a more relaxed constraint than that of the class itself:
```dart
class C {
X x;
C(this.x);
}
main() {
C c1 = C(2); // OK.
num n = c1.x; // OK.
c1.x = ...; // Compile-time error: The setter is "filtered out".
C c2 = C(Object()); // OK.
c2.x = n; // OK.
print(c2.x); // Compile-time error.
}
```
However, in the case where the variance at the declaration contradicts the use-site variance, the resulting semantics may be too confusing to be helpful:
```dart
class C {
final X x;
C(this.x);
}
class D {
set x(_) {}
}
main() {
C c = C(2);
// Cannot use `c.x` getter: Filtered away by `in`.
D d = D(2);
// Cannot use `d.x` setter: Filtered away by `out`.
}
```
In this case the "writer" signatures are errors in the declaration of `C`, and the "reader" signatures are filtered out in the use-site type, so only the signatures that don't use the type parameter at all are available.
This could make sense (say, on a list someone might only want to get its length), but it makes little sense to specify that the value of `c` must be an object whose type argument at `C` is `num` or a supertype thereof (as would be implied by `in num`), because all expressions of type `C` for any `U` would allow for type arguments that are subtypes. It would actually be sound to allow _any_ type argument (so `C` corresponds to `C` in Java or `C<*>` in Kotlin), and that is not obvious for a reader who is looking at `C`.
So we could make it an error to obtain bivariance ("_any_ type argument is OK") implicitly by combining `in` and `out`, and instead introduce an explicit term that denotes bivariance.
This means that we would have use-site variance where the declaration-site constraints can be _strengthened_ (as with `exactly` or `inout` in a type annotation), and they can be _relaxed_ (as with `out` or `in` where the type parameter is `inout`), or they can maintain that the type contains the same set of objects but with different static checking (where the type parameter is legacy, and the use-site type uses `out`, which means "only safe members can be accessed").
Types with use-site variance could be used in superinterfaces (so `implements List` would require only the "reader" methods from `List` to be implemented). One interpretation of this usage would be that the actual type argument used for that superinterface is still `X` (so there's no notion of it being "some supertype of `X`), and the `out` modifier will _only_ remove some methods from the interface.
Another interpretation would be that the set of members is filtered, but we will furthermore consider the actual type argument given to the superinterface as constrained rather than determined.
```dart
class B {
X x;
}
class C implements B {
// Interface has `X get x`, but no setter. Except that the actual type argument
// passed to `B` as a superinterface may be some subtype `S` of `X`, so it's
// more like `\exists S <: X. S get x`. But `X get x` is still a sound typing of that.
}
class D implements C, B { // Settled: `S` is `int`.
int get x => 24;
}
```
Something like this could be allowed because `D` only implements `B`, it doesn't have two different generic instantiations of `B` as superinterfaces. The superinterface graph also contains a path to `B`, but that just specifies that there exists a type `S`, supertype of `X` which is `num` in this case, and `C` then implements `B`.
However, this requires a distinction between the usage of variance modifier in a superinterface which introduces an underconstrained type argument to a superinterface, and the location where the actual value is selected, in the example: `B`.
So the use of use-site variance in superinterfaces would reasonably be constrained to mean that the set of members is filtered, but it seems unreasonable to let it mean that any actual type arguments are underconstrained and will be chosen by some other superinterface declaration. And we'd need default rules to handle the case where no final choice is made (so we take the existing constraints and find a default solution).
But this is a lot of complexity with not so much motivation, so I'd recommend that we do not allow any kind of use-site variance in superinterfaces. That also matches the general rule that use-site variance cannot be used in prescriptive situations: When we create a new object (or define a class), we are determining exactly how that object should be structured, so we cannot just specify some constraints, we must specify the object completely.
Contributor guide
Research direction
Start with the issue's proposed use-site variance examples and compare the referenced Kotlin type projections and Java wildcards. Work out the intended rules for `in`, `out`, `inout`, bivariance, member filtering, and whether variance is allowed in superinterfaces. Done means the language design is resolved and documented with consistent behavior for the examples.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart
- Domain
- compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100