dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Define how variance and late fields in NNBD interact #648

Open leafpetersen opened 4 years ago

leafpetersen commented 4 years ago

The NNBD spec adds a notion of late final fields which do not need to be initialized in the constructor. This implies the existence of a hidden setter, which in turn implies that there is an interaction with the definition site variance proposal being prototyped by @kallentu . Concretely, in the following code, there is an implied covariance check on the implicit late setter:

class A<T> {
  late final T x;
}

void main() {
  A<Object> a = A<int>();
  a.x = "hello"; // This must throw in the setter
}

Consider the sound variant version of this:

class A<out T> {
  late final T x;
}

void main() {
  A<Object> a = A<int>();
  a.x = "hello"; 
}

Should this code be rejected based on the implicit setter, which has a hidden contra-variant use of the type variable?

Or should this code be accepted, and be compiled with a runtime check type check on the write?

Note that if we choose to make this a static error, then adding the covariant modifier to the late field could allow it. Currently we issue an error on covariant final fields though, so we would probably want to eliminate that restriction.

class A<out T> {
  covariant late final T x;
}

void main() {
  A<Object> a = A<int>();
  a.x = "hello"; 
}

cc @munificent @lrhn @eernstg @kallentu

eernstg commented 4 years ago

Should this code be rejected based on the implicit setter

I would consider that to be a nice, well-defined approach, and covariant final would be a consistent way to allow developers to make the opposite trade-off.

We can safely include a couple of additional cases:

The variance of a type parameter matters when it is not denotable (that is, for clients), but at a location where the type parameter is in scope it does not matter: If we have X x; and e has type X then we can allow x = e, and it doesn't matter that X is out.

So we could allow the setter to be called from the body of the class when the receiver is this (and, potentially, from any scope where some notion of an 'existential open' on a stable reference to a receiver has given named access to the relevant type parameters).

We could also allow a client to call the setter when the receiver type implements A<inout S> for some S.

So, basically, we could filter the setter just like any other method which is not safe to call in the general case, and then we should perhaps not outlaw the declaration in the first place.

We have already had similar situations, e.g., when a "new" class C (using explicit variance modifiers for all type variables) has an "old" superclass B, and C inherits a method m which could not be declared in C (because it accepts an argument whose type cannot be enforced, and that would be an error):

class B<X> { void m(X x) {}}
class C<out X> extends B<X> {}

main() => (C<int>() as C<num>).m(42.l); // Dynamic check, throws.

However, I still tend to prefer an approach where it is simply an error to use a co/contra-variant type parameter in a non-co/contra-variant position in a member signature.

If we maintain such a strict rule then the declaration in the original example would have to be covariant final, and we would miss out on the typing precision in a couple of situations where calls can be made in a statically safe manner. But we would maintain an approach which is more easy to understand and remember, and that's likely to be helpful as one goes about writing some code.

leafpetersen commented 4 years ago

So we could allow the setter to be called from the body of the class when the receiver is this

It's very tempting to allow this in the constructor body, but it feels hard to explain to users.

lrhn commented 4 years ago

A late final instance variable with no initializer expression has the same interface (getter/setter) as a non-final instance variable, and it should probably behave exactly the same. The difference is in what happens when you call the setter, but that's dynamic behavior, not static. Statically it's just a non-final field, and you can assign to it from anywhere, and it should behave exactly the same as a non-late non-final field of the same type.

A late final instance variable with an initializer should not have a setter. It's always an error to assign to it, so it's just a lazy initialized final instance variable.