Skip to content
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

Feature: Statically checked declaration-site variance #524

Open
eernstg opened this issue Aug 14, 2019 · 88 comments
Open

Feature: Statically checked declaration-site variance #524

eernstg opened this issue Aug 14, 2019 · 88 comments
Labels
feature Proposed language feature that solves one or more problems variance Issues concerned with explicit variance

Comments

@eernstg
Copy link
Member

eernstg commented Aug 14, 2019

This is the tracking issue for introducing a statically checked mechanism for declaration-site variance in Dart.

Background: The original request motivating this kind of feature is dart-lang/sdk#213; the initial proposal for declaration-site invariance is dart-lang/sdk#213. The initial proposal for the related feature known as use-site invariance is dart-lang/sdk#229, and the corresponding tracking issue is dart-lang/sdk#753.

Note that this issue does come up in practice. Here are a few examples gathered since April 2023:

The text below describes properties of this feature which are good candidates for being adopted. Many things can still change, and a full feature specification will be written and used to manage the discussions about the final design.

Variance in Dart Today

As of Dart 2.4 or earlier, every type variable declared for a generic class is considered covariant. The core meaning of this is that a parameterized type C<T2> is a subtype of C<T1> whenever T2 is a subtype of T1. Other subtype rules can then be used to show subtype relationships like List<int> <: Iterable<dynamic> and Map<String, String> <: Map<Object, Object> <: dynamic.

This type rule is not sound; that is, in order to maintain heap soundness it is necessary to check certain types dynamically. This means that a program with no compile-time errors can fail with a type error at run time.

For instance, with the declaration List<num> xs and some expression e with static type num, it is necessary to check during evaluation of xs.add(e) that the value of e actually has the type which is required by xs: It is possible that it is a List<int> or even a List<Never>, and it would then be a dynamic type error if the value of e is a double, even though the expression had no type errors at compile-time.

Dynamically checked covariance enables many software designs that would be rejected by a traditional statically checked approach to variance (e.g., as in Java or C#). This allows developers to make a trade-off between more flexible types (e.g., a variable of type List<num> is allowed to refer to a List<int>) in return for accepting the potential dynamic type errors (a List<int> will work safely under the type List<num> in a lot of ways, just not all).

We want to enable a statically checked typing discipline for variance as well (rejecting more programs, but providing a compile-time guarantee against the run-time type errors described above). This feature is concerned with the provision of support for that.

Declaration-site Variance

Declaration-site variance can be used to declare a strict and statically checked treatment of variance for each type variable of a generic class.

Syntactically, declaration-site variance consists in allowing each type parameter declaration of a generic class declaration to include one of the following modifiers: out, in, or inout. We say that such a type parameter has explicit variance.

The use of type parameters with explicit variance in the body of the enclosing class is restricted. It is a compile-time error for a type variable marked out to occur in a non-covariant position in the signature of a member declaration; and for a type variable marked in to occur in a non-contravariant position. For example:

abstract class Good<out X, in Y, inout Z> {
  X get m1;
  void set m2(Y value);
  Z m3<U extends Z>(List<Z> zs);
}

class Bad<out X, in Y> {
  Y get m1; // Error.
  void set m2(X value); // Error.
  Y m3<U1 extends X, U2 extends Y>(List<X> xs); // Error.
}

Here are some core properties of declaration-site variance:

We obtain the following subtype relationships: Let C be a generic class with one type parameter X. Assume that S is a subtype of T. If X is marked out then C<S> <: C<T>; if X is marked in then C<T> <: C<S>. Note that there is no subtype relationship between C<S> and C<T> if X is marked inout, unless S == T.

A type parameter with explicit variance can be used in the specification of a superinterface. For example:

class A<out X, in Y, Z> {
  X get m;
}

class B<out X, inout Y, in Z> implements A<X, Y, Z> {}

Soundness is ensured via a number of rules like the following: It is a compile-time error if a type parameter X marked out occurs in a non-covariant position in an actual type argument for a superinterface D when the corresponding type parameter of D is marked out; and if X occurs in a non-contravariant position in an actual type argument for D when the corresponding type parameter is marked in; and if X occurs at all in an actual type argument for D when the corresponding type parameter is marked inout.

The interaction with dynamically checked covariant type parameters is similarly guarded: It is a compile-time error if a type parameter X marked out occurs in a non-covariant position in an actual type argument for a superinterface D when the corresponding type parameter of D has no explicit variance.

In return for all these restrictions, we get static safety: For a class where all type parameters have explicit variance, every (non-dynamic) member access which is statically type correct is also dynamically safe (no type checks on parameter types etc. are needed at run time).

In general, declaration-site variance can be used for classes which are intended to be strictly type checked with respect to variance everywhere.

@eernstg eernstg added the feature Proposed language feature that solves one or more problems label Aug 14, 2019
@mit-mit mit-mit added this to Being spec'ed in Language funnel Aug 14, 2019
@eernstg eernstg added the variance Issues concerned with explicit variance label Aug 30, 2019
@eernstg
Copy link
Member Author

eernstg commented Sep 2, 2019

First draft of feature specification at PR dart-lang/sdk#557.

@lrhn
Copy link
Member

lrhn commented Sep 3, 2019

The "exact" type already exists in Dart in certain places - the type of a literal or object creation expression may have an exact type, which is why List<int> l = <num>[1]; is a compile-time error instead of a run-time downcast failure.
I assume those cases can be subsumed by exact types and still behave the same.

For use-site invariance, we will have both List<Foo> and List<exact Foo> as instantiations, with the latter being a subtype of the former. We don't actually introduce a nww type called exact Foo, just new syntactic forms for type arguments, and a suitably expanded type relation between instantiated generic types. We can say that we introduce exact Foo, but it's not a type, just a type pattern.

As stated, the type arguments can only be used in type pattern expressions (type annotations), not class expressions or literals.
That is, you can write List<exact Foo> x; as a type annotation, but not class C extends Iterable<exact int> or new List<exact int>(). Those make no sense, they are always "exact" in that they are run-time invocations with a single value.

The following should be allowed:

List<exact String> foo(List<exact int> list) => (list..add(42)).map((x) => "$x").toList();

It is meaningful and useful.

What about:

class C<T> {
  List<exact T> mkList() => <T>[];
}

Is this meaningful? Maybe. If you have a C<num> c, then you only know that the result of c.mkList is List<num>. If you have C<exact num> d; then the static type of d.mkList is (or should be) List<exact num>. If the code had not written exact T in the return type, then it would not have been true. The method:

  List<T> mkList() => <T>[];

will never have a static return type of List<exact anything>, not even for D<exact num>.mkList().

So, we need some substitution rules for type arguments to make this work.
exact T[exact S/T] -> exact S
exact T[S/T] -> S
T[exact S/T] -> S (?)

It also suggests that a lot of platform libraries should be changed to have exact in their return type, because they are really intended to be exact. Take: Set<T>'s Set<T> union(Set<T> other). Here we should make it Set<exact T> union(Set<T> other), so that if I have a Set<exact num> then the union returns another Set<exact num>.
(I just realized that I have written exact instead of exactly everywhere. I'm not particularly fond of all these keywords, but shorter seems better :)

That suggests a migration issue. If we change some platform libraries to return, say, List<exact String>, then any existing implementation of the same interface will no longer be valid when it returns merely List<String>, a super-type of the required return type. We may want to have an NNBD-like migration process where legacy libraries are accepted for a while, until everybody has migrated.

If we need migration anyway, then we don't need to make the syntax backwards compatible, and we could do something else, like:

class C<+T , -S, =U> {  // out T, in S, inout U
   List<+T> mkList() => <T>[]; // not exact T
   List<U> mkList() => <U>[]; // exact U
}

We do get the inherent complexity of a two-tier type system where it matters whether you write exact int or int on every type argument in your API. You have to get it right, otherwise you make lock yourself out of possible later changes.
If you say foo(List<exact num> arg) then you can't add elements to arg later, and you prevent anyone with merely a List<num> from calling you. They'll have to do list.cast<exactly num>() to convert their list.

I expect exact types in arguments to be rare (you really do need to do modification), and for them to be common in return types.

For declarations site variance, we already have something similar for function types. For those, the variance is automatically computable from the co-/contra-variance of the occurrences of the type arguments.
We could do the same for class parameters, but that would break all our existing unsafely covariant classes. We could do it anyway, and require migration which would effectively mean that all existing type arguments become covariant, and we write an explicit covariant on all existing contravariant occurrences (well, the ones in parameters, we can't help the ones in function return types).

@eernstg
Copy link
Member Author

eernstg commented Sep 3, 2019

The "exact" type already exists

Right. The notion of an exact type is not specified, but it is used in some cases by our tools (even to raise an error for certain "impossible" casts).

The notion of a type argument which is known exactly is a different concept (an object can have dynamic type List<exactly T> even though it is an instance of some proper subtype of List). I just checked the text above to make sure that it doesn't ignore that distinction, and adjusted a couple of sentences.

For use-site invariance, we will have both List<Foo> and List<exact Foo> as instantiations

Both of those are instantiations of the generic type List (where instantiation of a generic type means providing actual type arguments), but we will not have an object whose dynamic type is List<Foo>. If the dynamic type T of a given object o is such that List<Foo> is one of the superinterfaces of T then o has type List<exactly Foo>. In general, every type argument in the dynamic type of an object is exactly something, and the superinterfaces will carry this property with them.

class A<X, Y> {}
class B<X> implements A<X, int> {}

With that, B<String>() has dynamic type B<exactly String>, and it is also of type A<exactly String, exactly int>.

We may or may not want to let Type.toString() reveal this bit, but it must be present in the dynamic representation of types in order to maintain soundness.

we introduce exact Foo, but it's not a type, just a type pattern

I'd prefer to say that a type accepts type arguments, each of which may have the modifier exactly, which also implies that exactly Foo is not a type.

the type arguments can only be used in type pattern expressions (type annotations),
not class expressions or literals.

exactly can be used on type arguments, e.g., <List<exactly num>>[], but not on types, and we need to make the distinction that "type arguments" given prescriptively are types. So List<exactly num> is OK as a type annotation, but List<exactly num>() as an instance creation is not, and <exactly num>[] is not; but when exactly is nested one level deeper then it is again a type argument, which is the reason why <List<exactly num>>[] is OK.

class C<T> {
  List<exactly T> mkList() => <T>[];
}

That can be allowed, and would be safe, but the computation of the member access type must take into account that the statically known value of T is just an upper bound.

main() {
  C<num> c = C<int>();
  List<num> xs = c.mkList(); // Static type of `c.mkList()` is `List<num>`.
  List<exactly num> ys = c.mkList(); // Error (downcast).
}

Of course, with class C<inout T> we would have an uninterrupted chain of exactness, and c.mkList() would have static type List<exactly num>.

we need some substitution rules

I believe the most straightforward way to get this right is to consider the types of member accesses, with a case for each variance of the type parameters of the class. Type parameters with no variance modifier are the most complex ones, of course, because they are allowed to occur in any position in a member signature.

We may want to have an NNBD-like migration process where legacy libraries
are accepted for a while

Indeed.

we don't need to make the syntax backwards compatible

Right, but C<=X, -Y> x; may not be optimally readable (and in, out, inout isn't all that verbose). In any case, that's probably not more breaking than the keywords.

do get the inherent complexity of a two-tier type system where it matters
whether you write exact int or int on every type argument in your API

I think that wouldn't ideally be such a common situation: The declaration site variance modifiers are required to match the usage (so if you have a type parameter out E then it just can't be the type annotation of a method parameter), and a subtype receiver will have a subtype member (including: less specific parameter types and more specific return types).

The use of exactly in a member signature would be specifically concerned enhancing the type safety in the management of legacy types (with type parameters that have no variance modifiers).

I would expect the reasonable trade-off to be somewhere between just using the legacy types as they are (with the current level of type safety, which has been used in practice for years without complete panic) and teasing out the very last drop of static type safety, by adding exactly as many places as possible. In any case, there are rules for eliminating exactly from interface type members such that the resulting typing is sound.

@He-Pin
Copy link

He-Pin commented Dec 20, 2019

I more like the +T and -T syntax in Scala

@He-Pin
Copy link

He-Pin commented Dec 23, 2019

The current in out looks like a primary student and verbose.
Kotlin and C# is using in and out too,but reads badly.

@leafpetersen
Copy link
Member

Is it the AUTHOR of the Bad class, or the USER of said class? Sure, user will benefit, too, if the author makes fewer mistakes, but other than that?

The primary beneficiary is the USER. The List class is one of the primary examples of something that should be invariant, but is instead unsoundly covariant. We have numerous users who are frustrated that this results in unexpected runtime errors that could have been caught at compile time. Enclosed below is an innocuous looking test program that fails at runtime instead of at compile time. Sound variance allows users who strongly prefer to rule these errors out at compile time to do so.

void addToList<T>(List<T> target, List<T> src) {
  for(var x in src) {
    target.add(x);
  }
}

void test(List<num> nums) {
  addToList(nums, [3, 4, 5]);
}
void main() {
  List<double> doubles = [3.5];
  test(doubles);
}

@leafpetersen
Copy link
Member

Question 2:  you seem to be leaning towards the use of "inout" as a synonym of "exact", but I have difficulty understanding the reasoning leading from "inout" to "exact".

Reading between the lines of this question, I think you are missing the primary effect of this feature, which is that it changes the subtyping relation between types. Describing inout as exact is sensible because given class Invariant<inout X> ... we know that any variable of type Invariant<num> contains only objects that were allocated as Invariant<num> or a direct subclass thereof. Specifically, it will never contain an Invariant<int>, nor an Invariant<Object>. In this sense then, it contains objects whose generic parameters are exactly as described by the type. Hence exact. Does that help?

@mockturtl
Copy link

mockturtl commented Dec 23, 2019

Describing inout as exact is sensible

I think tatumizer is suggesting exact will make a better (easier to understand) syntax for invariance. (Compare: "let xy mean neither x, nor y.")

@leafpetersen
Copy link
Member

I find the word inout confusing. It's difficult to see how this word may carry the meaning of "exact".

Ah, sorry, I misunderstood your question. Here's the intution:

  • a type variable labelled in may only be used to pass things in to methods
    • void add (T x)
  • a type variable labelled out may only be used to pass things out of methods
    • T get first
  • a type variable labelled inout may be used to pass things in to and out of methods
    • T swap(T x)

Does that help?

(By the way, what combination of "in" and "out" characterizes the current default behavior in dart?)

Neither. Classes are allowed to use type variables everywhere in a method, which corresponds most closely to inout, but subtyping is covariant, which corresponds to out.

Let's consider your example with addToList. What will our new program look like to prevent this bad outcome? Where "in", "out" and "inout" should be added? And what line in the code will be flagged statically after we do these changes?

For completeness, here is the example rewritten to use an example of an invariant implementation of List. The call to test on the second line of main is statically rejected.

import 'dart:collection';

class Array<inout T> extends ListBase {
  List<T> _store;
  int get length => _store.length;
  void set length(int i) => _store.length = i;
  T operator[](int index) => _store[index];
  void operator[]=(int index, T value) => _store[index] = value;
  Array.fromList(List<T> this._store);
}

void addToList<T>(Array<T> target, Array<T> src) {
  for(var x in src) {
    target.add(x);
  }
}

void test(Array<num> nums) {
  Array<num> ints = Array.fromList([3, 4, 5]);
  addToList(nums, ints);
}
void main() {
  Array<double> doubles = Array.fromList([3.5]);
  test(doubles);
}

@leafpetersen
Copy link
Member

@tatumizer I think this is getting a bit far afield - variance is definitely a confusing subject, but I don't think we're going to fix that here. Is it fair to summarize your general take here as being that you find + - = more intuitive than out in inout? Or are you expressing a preference for something different than that?

@He-Pin
Copy link

He-Pin commented Dec 24, 2019

repeat the inout and inout here and there make me looks like a fool; why not make use of inputParameter T outputParameterR?

@He-Pin
Copy link

He-Pin commented Dec 24, 2019

@leafpetersen I think the - and + style are more concise and the in and out style are more easy to understand.

@eernstg
Copy link
Member Author

eernstg commented Sep 4, 2023

That's a very good example because StreamController (and several supertypes including StreamSink) are almost exclusively using their type parameter in contravariant positions, which means that the (unavoidable, standard) dynamically checked covariance is flat wrong almost every time.

However, we do have some covariant occurrences of the relevant type variables as well, so we'd need to make some classes invariant in order to get the statically safe typing, or we can keep them unchanged (using dynamically checked covariance) because they are already "mostly OK", and they are difficult to change (like Stream).

Here is an example (using some fake classes just to make the example small):

// Will probably continue to use dynamically checked covariance.
// Could also be invariant, but that's a massively breaking change.
class Stream<T> {
  Future pipe(StreamConsumer<T> _) => Future<Null>.value();
}

// Can be soundly contravariant (if `Stream` is dyn-covariant).
class StreamConsumer<in T> {
  Future addStream(Stream<T> stream) => Future<Null>.value();
}

// Needs to be invariant (to get a safe `add` and a safe `stream`).
class StreamController<inout T> extends StreamConsumer<T> {
  Stream<T> get stream => Stream<T>();
  void add(T t) {}
}

void main() {
  final StreamController<num> numbers = StreamController();
  final StreamController<double> doubles = StreamController();
  doubles.stream.pipe(numbers); // Sure, no problem! ;-)
  doubles.add(123);
}

With these changes, doubles.stream.pipe(numbers) type checks without errors, as it should.

As usual, we can emulate invariance (which is not quite as flexible as the real declaration-site variance feature where we can use contravariance where that's sufficient and invariance only where nothing else will do).

Here's a minimal example, where we only declare those few methods that we're actually calling here:

import 'dart:async' as async;

// Provide invariant types for some stream related classes.

typedef Inv<X> = X Function(X);
typedef Stream<X> = _Stream<X, Inv<X>>;
typedef StreamConsumer<X> = _StreamConsumer<X, Inv<X>>;
typedef StreamController<X> = _StreamController<X, Inv<X>>;

extension type _Stream<T, Invariance extends Inv<T>>(async.Stream<T> it) {
  Future pipe(StreamConsumer<T> consumer) => it.pipe(consumer.it);
  Stream<R> cast<R>() => Stream(it.cast<R>());
}

extension type _StreamConsumer<T, Invariance extends Inv<T>>(
  async.StreamConsumer<T> it
) {
  Future addStream(Stream<T> stream) => it.addStream(stream.it);
}

extension type _StreamController<T, Invariance extends Inv<T>>._(
  async.StreamController<T> it
) implements _StreamConsumer<T, Invariance> {
  _StreamController() : this._(async.StreamController<T>());
  Stream<T> get stream => Stream(it.stream);
  void add(T t) => it.add(t);
}

// Usage, in some other library (assume `import 'something';)

void main() {
  final StreamController<num> numbers = StreamController();
  final StreamController<double> doubles = StreamController();

  // doubles.stream.pipe(numbers); // Compile-time error.
  doubles.stream.cast<num>().pipe(numbers); // OK.
  doubles.add(123);
}

Note that cast on a stream is generally not safe (each data event on the stream has its type checked dynamically), but in this particular case it is an upcast, which implies that it is safe.

Finally, we should be able to use extension types together with statically checked declaration-site variance in order to be able to access the Stream related classes safely even without changing those classes themselves. However, that doesn't work at this time. Here's a hint:

// Provide soundly variant types for some stream related classes.

extension type StreamConsumer<in T>(async.StreamConsumer<T> it)
    implements async.StreamConsumer<T>;

extension type StreamController<inout T>._(
  async.StreamController<T> it
) implements async.StreamController<T> {
  StreamController() : this._(async.StreamController<T>());
}

[Edit, Sep 19, 2023: Add the bound Inv<T> on the Invariance type parameters, for improved typing of this.]

@dnys1
Copy link

dnys1 commented Sep 5, 2023

That's very cool, thanks so much for sharing! Are any of the same tricks possible for replicating contravariance and static covariance in current Dart?

@dnys1
Copy link

dnys1 commented Sep 5, 2023

I've been playing around with the experimental feature and it's awesome for these situations. I do, however, keep forgetting what in, out, and inout mean. I understand the argument that they're tied to the position of the type parameter in its usage, but the way I've been thinking about them is more about which types I want to allow T to be.

FWIW I would cast my vote for modifiers (as mentioned above) which make this explicit:

  • exact T -> invariant (inout)
  • super T/sup T -> T or any of its supertypes - contravariant (in)
  • sub T -> T or any of its subtypes - statically covariant (out)
  • dyn T -> dynamic covariance, same as T today but explicit

I have also stumbled into some situations where an in parameter can be used in a getter or method return. And, in general, there seems to maybe be some gray areas where the modifiers do not precisely map to the position of the type parameter's usage. Although, I could just be missing something here.

@eernstg
Copy link
Member Author

eernstg commented Sep 6, 2023

@dnys1 wrote:

Are any of the same tricks possible for replicating contravariance and static covariance in current Dart?

The invariance emulation won't work out of the box: Contravariance should allow subtyping relationships like MyConsumer<num> <: MyConsumer<int>, but the regular type parameter (which is needed because we need to use that regular type parameter in the body of the class) will remain covariant, so we just get invariance again:

typedef _Ctv<X> = void Function(X); // `_Ctv<num> <: _Ctv<int>`.
typedef MyConsumer<X> = _MyConsumer<X, _Ctv<X>>;
  
class _MyConsumer<X, Contravariance> {
  void m(X x) {}
}

void main() {
  MyConsumer<int> intConsumer = MyConsumer<num>(); // Error, should have been OK.
}

We can't express statically checked covariance, either, because it's just going to be dynamically checked covariance as usual, and there will not be any errors for things like class C</*out*/ X> { void m(X x) {}}.

It's possible that there is some other technique, but someone would have to invent it, and tells us about it. ;-)

I do, however, keep forgetting what in, out, and inout mean.
I understand the argument that they're tied to the position of the type parameter in its usage, but ...

Right, that's always a difficult point. inout is easy because it includes all constraints associated with in as well as the ones for out. The usage perspective would be that in is only used for things that we "put into the receiver", such as method parameters, and out is only for things that "the receiver gives us", such as returned values.

With respect to the subtype relationships, I tend to think that in is used with "inverse" subtyping, that is MyConsumer<SuperType> <: MyConsumer<SubType>, and then it follows that out is the other case.

same as T today but explicit

We've considered using covariant for that: It is already associated with a typing which is best described as "yes, this is not safe, but I know what I'm doing, and it's so convenient!". ;-)

an in parameter can be used in a getter or method return

Sure, the covariant/contravariant/invariant positions in a type can occur in a return type, because that return type can have enough structure on its own to have both covariant and contravariant positions:

class C<in X> {
  void Function(X) get myGetter => (X x) {...};
}

You could say that X is still used for things that we "put into the receiver" because we invoke myGetter, and then we put an X into the function that the getter returned, which is in some sense "a function which is owned by the receiver".

In general, the variance of a position is defined recursively (PDF, see section 'Variance'), so you can always stack enough function types on top of each other to obtain a covariant/contravariant position anywhere in a function signature.

So it's not really a grey area, it's just a situation where the "inversion" that occurs whenever we switch from a function type to one of its parameter types occurs in a type that occurs in a member declaration signature, rather than at the top level of the signature. We can also invert twice and get back to covariance:

class D<out X> {
  final X x;
  D(this.x);
  X Function() myMethod(void Function(X) arg) => () => x;
}

@mateusfccp
Copy link
Contributor

@eernstg

Regarding your proposed workaround, the following case won't work:

typedef Invariance<T> = T Function (T);

typedef Foo<T> = _Foo<T, Invariance<T>>;

final class _Foo<T, I> {
  const _Foo(this.bar);
  
  final _Bar<T> bar;
  
  void baz() => bar.baz(this);
}

final class _Bar<T> {
  void baz(Foo<T> foo) => print(foo);
}

I get the following error: The argument type '_Foo<T, I>' can't be assigned to the parameter type '_Foo<T, T Function(T)>'.

I could fix it by changing I so it extends from Invariance<T>:

-final class _Foo<T, I> {
+final class _Foo<T, I extends Invariance<T>> {

The error disappeared and I still get a static error if I try to pass covariant values to Foo.

Do you think this change is valid or can it introduce some other unexpected behavior?

@eernstg
Copy link
Member Author

eernstg commented Sep 19, 2023

@mateusfccp, you are right. I actually used that approach a while ago (March 2023: dart-lang/sdk#51680 (comment)), I just got lazy in the meantime because the actual examples did not require this extra bit of type information. ;-)

But we can do it: It is benign for clients to have the bound, because they should always be using actual type arguments of the form <T, Invariance<T>> (and the use of a type alias would enforce this for clients outside the declaring library). It is also benign for a class like _Foo itself, because the value of I will indeed be a subtype of Invariance<T> in every case where the desired invariant is maintained.

It would still be possible to violate that invariant inside the declaring library, which means that it isn't precisely equivalent to the real language mechanism, but the emulation is better when we include this bound.

I changed the comment where the idiom is introduced accordingly.

Thanks!

@mateusfccp
Copy link
Contributor

mateusfccp commented Sep 19, 2023

@eernstg

I am sorry if I am being annoying with this workaround, but I got to another problem while using it in my project.

Consider the following:

typedef Invariance<T> = T Function (T);

typedef Foo<T> = _Foo<T, Invariance<T>>;

abstract interface class _Foo<T, I extends Invariance<T>> {
  void baz();
}

mixin DefaultFoo<T> on Foo<T> {
  @override
  void baz() {}
}

final class Bar with DefaultFoo<Object> implements Foo<Object> {}

This code shows me two compile-time errors:

  1. line 9: 'T' can't be used contravariantly or invariantly in '_Foo<T, T Function(T)>'.
  2. line 14: 'DefaultFoo<Object>' can't be mixed onto 'Object' because 'Object' doesn't implement '_Foo<Object, Object Function(Object)>'.

I managed to fix the first error by also redirecting the mixin:

+typedef DefaultFoo<T> = _DefaultFoo<T, Invariance<T>>;
+
-mixin DefaultFoo<T> on Foo<T> {
+mixin _DefaultFoo<T, I extends Invariance<T>> on _Foo<T, I> {

However, the second error persists, and I couldn't find a way to circumvent this... I feel like Bar actually implements _Foo<Object, Object Function(Object)> because we explicitly use implement Foo<Object>, and Foo<Object> is the same as _Foo<Object, Object Function(Object)>, so I am not sure why the static analysis is talking about.

Is there a way or is it impossible to use this workaround with mixins?

@eernstg
Copy link
Member Author

eernstg commented Sep 19, 2023

I am sorry if I am being annoying with this workaround,

No problem, of course! Try this:

typedef Invariance<T> = T Function (T);

typedef Foo<T> = _Foo<T, Invariance<T>>;

abstract interface class _Foo<T, I extends Invariance<T>> {
  void baz();
}

typedef DefaultFoo<T> = _DefaultFoo<T, Invariance<T>>;

mixin _DefaultFoo<T, I extends Invariance<T>> on _Foo<T, I> {
  @override
  void baz() {}
}

final class Bar extends Foo<Object> with DefaultFoo<Object> {}

Perhaps you didn't actually mean that the mixin should be on Foo<T>, because an on type of a mixin is useless if you never call super.something(...). If implements Foo<T> is indeed sufficient for your purpose then you can proceed as follows:

typedef Invariance<T> = T Function (T);

typedef Foo<T> = _Foo<T, Invariance<T>>;

abstract interface class _Foo<T, I extends Invariance<T>> {
  void baz();
}

typedef DefaultFoo<T> = _DefaultFoo<T, Invariance<T>>;

mixin _DefaultFoo<T, I extends Invariance<T>> implements _Foo<T, I> {
  @override
  void baz() {}
}

final class Bar with DefaultFoo<Object> implements Foo<Object> {}

It's worth noting that we must use _Foo as a superinterface of _DefaultFoo because we need to pass on the actual type argument I rather than just use Foo<T> directly. This implies that the idiom won't work with private classes if you wish to create a hierarchy of classes in multiple libraries (e.g., if _Foo is declared in one library and _DefaultFoo is declared in a different library).

In that case you'll have to admit to the world that the phantom type parameter (I) exists, such that they can pass it on correctly. (So you'd have typedef Foo<T> = FooWithPhantom<T, Invariance<T>>; for clients who just want to use the type, and FooWithPhantom<T, I extends Invariance<T>> for clients who wish to use it as a superinterface).

However, that just illustrates that we do need declaration site variance. ;-)

@alexmarkov
Copy link

@leafpetersen wrote:

Third, because it makes things faster.

Covariance checks on classes have a cost (sometimes significant). [...]

In order to demonstrate this point:

  Micro-benchmark which measures the cost of covariant parameter type checks in Dart VM/AOT.
import 'package:benchmark_harness/benchmark_harness.dart';

const int N = 1000000;

class A {}
class B extends A {}

class C1<T> {
  @pragma('vm:never-inline')
  void foo(List<T> x) {
  }
}

class C2<T> {
  @pragma('vm:never-inline')
  void foo(List<T> x) {
  }
}

class TypeCheckBench extends BenchmarkBase {
  final C1<A> obj = int.parse('1') == 1 ? C1<B>() : C1<A>();
  final List<A> arg = int.parse('1') == 1 ? <B>[] : <A>[];

  TypeCheckBench() : super('TypeCheck');

  @override
  void run() {
    for (int i = 0; i < N; ++i) {
      obj.foo(arg);
    }
  }
}

class NoTypeCheckBench extends BenchmarkBase {
  final C2<A> obj = C2<A>();
  final List<A> arg = <A>[];

  NoTypeCheckBench() : super('NoTypeCheck');

  @override
  void run() {
    for (int i = 0; i < N; ++i) {
      obj.foo(arg);
    }
  }
}

void main() {
  final benchmarks = [TypeCheckBench(), NoTypeCheckBench()];

  for (var benchmark in benchmarks) {
    benchmark.report();
  }
}

In this benchmark, an empty instance method with one List<T> parameter is called in a loop. The inlining of the method is disabled so it would not be optimized out.

In one case (TypeCheck) the compiler cannot prove that a covariant parameter type check always succeeds, so the check is performed at run time. In another case (NoTypeCheck) the check is eliminated.

Results:

JIT, x64:

TypeCheck(RunTime): 50392.05128205128 us.
NoTypeCheck(RunTime): 25101.63953488372 us.

AOT, x64:

TypeCheck(RunTime): 41870.67796610169 us.
NoTypeCheck(RunTime): 17317.603603603602 us.

JIT, arm64:

TypeCheck(RunTime): 37042.545454545456 us.
NoTypeCheck(RunTime): 33444.29508196721 us.

AOT, arm64:

TypeCheck(RunTime): 21953.68085106383 us.
NoTypeCheck(RunTime): 9342.937219730942 us.

With statically checked declaration-site variance, if type parameter T of a class is declared as invariant, then runtime checks of the parameters involving T are not needed (because soundness is enforced statically). That would guarantee that parameters of generic types don't have any additional hidden performance overhead. I think this is also a very strong point towards making type parameters invariant by default.

@SandroMaglione
Copy link

Any updates on the status of this feature for release?

@mateusfccp
Copy link
Contributor

@SandroMaglione

I don't think the team has any ETA, but the feature is behind an experimental flag (--enable-experiment=variance).

It doesn't seems to be something the team is prioritizing, tho.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems variance Issues concerned with explicit variance
Projects
Status: Being spec'ed
Language funnel
Being spec'ed
Development

No branches or pull requests