-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Torn off methods with covariant parameters need to have parameter types replaced with Object
#31305
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
Comments
The tear-off of _NativeSocket.multiplex needs to have runtime type `(Object) -> void` in order to be passed to the RawReceivePort constructor. Once issue #31305 is fixed, we should be able to fix this by marking _NativeSocket.multiplex's argument as "covariant". Until then, we have to type the argument as `Object` and then assign it. Change-Id: I1c9b7fb77dd3b0a71037459206f8de30ad77f73e Reviewed-on: https://dart-review.googlesource.com/20822 Reviewed-by: Sigmund Cherem <[email protected]> Commit-Queue: Paul Berry <[email protected]>
Regis, would you have chance to work on this? |
Slava, sure, I can work on this, but probably not this week. I am still editing kernel status files (a nightmare) after inserting argument type checks in strong mode and I hope to be done today or Friday (I am OOO tomorrow). I should be able to get to it early next week. |
I am just looking briefly at this issue. I do not understand why c.h should have a covariant parameter, since T of I can only be num, seen from C. But I have not followed all discussions about covariant. In strong mode (which does not make much sense in the VM without type inference), the VM parser is already changing the parameter type to Object, since we do not keep the covariant information in the ast. So the VM in strong mode prints: I guess this is beside the point and this issue is really about kernel providing a modified function signature to the runtime, so that the runtime test uses Object instead of num. Am I correct? I do not mind having a look, but so far, I have not worked on that code, except for the portion passing type arguments to generic functions. I'll try next week. |
@crelier, to answer your question about why main() {
I<Object> i = new C(); // Ok because C implements I<num>, which is a subtype of I<Object>
i.h('oops'); // Legal at compile time because I<Object>.h has type (Object) -> void
} The front end already sets As to your other question, "this issue is really about kernel providing a modified function signature to the runtime, so that the runtime test uses Object instead of num", unfortunately we can't fix the bug that way, because the kernel representation needs to retain the type that was explicitly provided by the user so that it can generate the appropriate compile-time errors, e.g.: main() {
C c = new C();
c.h('oops'); // Compile-time error; C.h requires a num
} If the kernel representation were changed so that To put it another way, This means that the VM has to be responsible for replacing the types of covariant parameters with |
@stereotype441, thanks for the explanations. However, the VM has never been in the business of static checks (analysis, inference, ....), so I still do not understand why As to my other question, I should have been clearer. In the VM, a tear-off gets a signature type, which may be distinct from the original function signature type. In this case, we would not modify the type of the original function, but only that of the tear-off. |
Agreed, the VM should not be in the business of static checks. And I mostly agree that The informal spec at https://github.com/dart-lang/sdk/blob/master/docs/language/informal/covariant-from-class.md seems to back me up on this. It says "In the remainder of this section, a parameter which is covariant according to the definition given in covariant overrides is treated the same as a parameter which is covariant due to class covariance as defined in this document; in both cases we just refer to the parameter as a covariant parameter.", and then later "For each covariant parameter However, the informal spec is not entirely consistent. It also has this example: // Here is the small part of the core List class that we need here.
abstract class List<E> ... {
// The reified type is `(E) -> void` in all modes, as declared.
void add(E value);
// The reified type is `(Iterable<E>) -> void` in all modes, as declared.
void addAll(Iterable<E> iterable);
...
}
typedef void F(num n);
typedef void G(Iterable<num> n);
main() {
List<num> xs = <int>[1, 2];
F myF = xs.add; // Statically safe, yet fails at run time
// in strong mode and Dart 2.
G myG = xs.addAll; // Same as above.
} I believe this is incorrect; since main() {
List<num> xs = <int>[1, 2];
F myF = xs.add; // Safe
G myG = xs.addAll; // Safe
myF(1.5); // Fails at runtime in strong mode and Dart 2 (1.5 is not an int)
myG(<num>[]); // Fails at runtime in strong mode and Dart 2 (<num>[] is not an Iterable<int>)
} @leafpetersen can you confirm that this is correct?
Sounds good, thanks! |
I can confirm that. Basically, a class C {
void foo(covariant num x) {}
}
main() {
var c = new C();
var f = c.foo; // Static type of c.foo is void Function(num), that is the type inferred for f.
void Function(Object) actual = f; // This succeeds, because at runtime it's actually `Object` + cast.
} Or in other words, trust the source statically, but at runtime, the void foo(Object x) { x as num; } // which should also promote x to num in the body if there was one. Being covariant is inherited by overriding methods in subclasses. So, if the VM wants to implement |
The motivation section of that informal spec contains four snippets of code, and the one you mention is the fourth one. The 2nd one (right after 'Here is an example why it would not work to reify the declared parameter type directly') illustrates why we would not have expression soundness for tear-offs with the existing (reify-as-declared) rules. The 3rd one (after 'this is how it works') shows how the reified type of tear-offs are determined using the new (reify-to-Object) rules when we have an explicitly The 4th one corresponds to the 2nd one, that is, it illustrates why the old rules must be changed also for the situation where the parameter is covariant due to class covariance. So it's not inconsistent, but for clarity I'll add a 5th snippet corresponding to the 3rd one, and add a loud comment in the 2nd and 4th snippet saying "this goes by the old rules and illustrates why they won't work". ;-) CL is here. |
Thanks for the clarification @eernstg! |
Did all the open questions get resolved here? |
I was just reading all the comments on this issue, as well as the referred informal spec. Yes, it makes more sense to me now. Thanks for the explanations. What is still unclear is the exact definition of a "covariant position", when considering the type of a formal parameter x of a method declared or inherited by a generic class. Only a couple of examples are given in the informal spec without much explanation. I understand the example of a formal declared as I guess that in What if X shows up in both a covariant position and in a contravariant position as in My understanding is the following: when you consider whether a formal type F that depends on the class type parameter X, let's note it F(X), is in a covariant position or not, you should consider the type F(Y), where Y is a subtype of X. If F(Y) is a subtype of F(X), then F is in a covariant position. Do I understand this correctly? Now what if you add function type parameters to the mix? |
No, I think it's the opposite. Consider:
The above code has no static errors, yet at runtime it causes Note that it is not necessary to put any special logic in the VM to figure out whether the type variable appears in a covariant position. The front end does this for you. Simply look at the booleans |
Since In any case, this confirms that the meaning of "covariant position" should be better specified. |
correct.
No, unfortunately not. If a type variable appears covariantly in a parameter type anywhere, then the reified type of that parameter is
The actual tearoff line is fine statically, no matter what, and there is no implied runtime check there so it's fine dynamically. The first statement will print true, whether we make the reified type be But what should the second print statement print out? If we make the reified type of We could put a read check on tearoff sites and fail the check there, but that doesn't seem useful. We could try to be more granular and use a type like Agreed that we should specify covariant positions if we haven't anywhere (I think @eernstg did write it up in one of the informal proposals?). I believe that to be precise, we actually care about "not contravariantly", which means "covariantly" or "invariantly". Inductively, a type variable
Inductively, a type variable
Inductively, a type variable
|
@leafpetersen, I actually haven't defined variance positions anywhere in our informal specs (and it isn't in dartLangSpec.tex because it wasn't needed before now). I agree on your definition, except that I feel a need to think a little bit more about invariance. It seems likely to me that the lack of a subtyping relation between generic function types with non-identical type parameter bounds ensures that we won't have to mention invariance arising from these bounds. Basically, that's just a non-issue here. An aside: We may need to reconsider declarations like class C<X> {
Function<Y extends X>(Y) f;
} because instances typable as As always, we can also just decide that it must have a specific type and throw if it doesn't (say, we could require the type Back on track: We could define that a type variable occurs invariantly in a type if it occurs both covariantly and contravariantly, just like many other languages do. However, I suspect that this is not necessary: If a type parameter occurs covariantly in an instance method parameter type and it also occurs contravariantly in the same type, we can make the choice to protect that parameter by generating checks for the declared type in the body of the method. This will trivially ensure soundness (no matter what is required it will be enforced). Conversely, if a type parameter occurs only contravariantly then the top level type (that is, the actual type annotation for the given parameter) will be a supertype of the type that we see in the statically known type of the receiver, and in this case an invocation will be just as safe as it would have been with no variance in the type at all. Hence, I believe that we can rely on the covariance check alone. Let's reconsider the example that you mentioned, @stereotype441: class C<X> {
void foo(X x(X arg)) {
assert(x is X Function(X)); // soundness should guarantee this
}
}
Object f(Object value) => ...;
main() {
C<Object> c = new C<int>(); // permitted because C<int> <: C<Object>
c.foo(f); // permitted because C<Object>.foo has type ((Object) -> Object) -> void
} I agree on 'soundness should guarantee this', I would consider that property to be part of the wider property known as heap soundness (even though The ability to initialize For That's not a practical type. However, the response could be "just stop mixing covariance and contravariance in your type declarations!" The limited usefulness of these types might nudge developers in the direction of keeping structural and nominal types more separate. Another possible development could be to support invariance (preferably use-site, I'd say: We could return to the "claim a type and enforce it" approach (that I don't like so much, but which may be tempting because it is simple, presumably because it relies so much on the "naive" static analysis where we just compute derived types as if there were no variance). With such an approach, the parameter type of invocations of the form With this approach, the type checker would tell us that an (... the relevant comparison would be Finally, if we tear off |
Agreed. However, you should get a dynamic error even before getting to that second print statement. The assignment |
class C<X> {
Function<Y extends X>(Y) f;
} I think this is handled by the existing covariance checks. This code throws on the read from c.f in DDC: class C<X> {
Function<Y extends X>(Y) f;
}
void main() {
C<int> ci = new C<int>();
ci.f = <Y extends int>(Y y) => y.isEven;
Function<Y extends int>(Y) f = ci.f;
C<Object> c = ci;
Function<Y extends Object>(Y) g = c.f;
} Am I missing your concern? It's certainly true that if you use covariant type variables in the bounds of generic functions in the signatures of methods, then those methods are going to be very rigid: you can't tear them off at a super type, etc. because there is no subtyping on bounds. But I think (hope?) there's no soundness issue that I'm missing.
Sorry, this isn't really the sense of invariant that what I was trying to get at. Bounds on generic functions are invariant. That is, for class C<X> {
void f(Function<Y extends X>(Y) x) {}
} there is an implied covariance check on void test() {
C<Object> c = new C<int>();
void Function(Function<Y>(Y)) f = c.f;
} Again, if we want the property that the runtime type of an object is a subtype of its static type, then we want the runtime type of Does that make sense, or am I missing something again? |
This has been a difficult line to walk, and I'm not entirely sure what the right answer is. In our existing approach, I don't think it's reasonable to do what you propose, because it makes it pretty much impossible to work with these sorts of functions, even in a safe way. If we relax our approach slightly as I suggest here: #31391 , then it would get a bit better, since you could at least tear things off safely without casting to dynamic first. In that case it might be possible to take the approach you propose? |
I have not been involved in these discussions and I do not know what the "existing approach" is. I am not proposing any change, but just trying to understand what I am asked to implement in this issue. |
Yes, fair enough. Just to be sure, I think that all of the information that you need to do the actual implementation is in kernel? If not, we should definitely fix that. I think though that your question though is more around understanding the overall structure of the covariance checks: what they are, and why. Is that right? Assuming so... here's some comments that can maybe help? Roughly speaking, the covariance checks fall into two categories: parameter checks (or write checks), which are checks on the actual arguments provided to methods for which covariant subtyping of generics allows the static type discipline to be subverted; and read checks which are checks on the result of reading a property out of an object where again covariant subtyping allows the typing discipline to be subverted. Focusing on the last one, what I was calling "the existing approach" is to require a caller side type check on the result of reading a field (calling a getter) when the return type is "unsafe", and specifically, to do that check relative to the interface type through which the field is read. That is, the check is induced simply by reading the field, regardless of the type at which the result is intended to be used. Example of existing approach: class C<X> {
X Function(X) f;
}
void test(C<Object> c) {
c.f; // Implied cast to Object -> Object, will fail
c.f("hello"); // Implied cast to Object -> Object, will fail
c.f == null; // Implied cast to Object -> Object, will fail
(c.f as dynamic) == null; // Implied cast to Object -> Object, will fail
c.f(3); // Implied cast to Object -> Object, will fail
(c.f as int Function(int))(3); // Implied cast to Object -> Object, will fail
(c as dynamic).f(3); // ok
}
void main() => test(new C<int>()..f = (int x) => x); This is quite restrictive, as you can see. With tear-offs, we can be less restrictive because we adjust the reified type, and I think this is valuable (again, given the current strategy). The proposal in #31391 would ease this somewhat by allowing many of the reads of I'd still be somewhat concerned though. It's hard to say what Dart 2.0 code will look like going forward, but certainly in porting Dart 1.0 code to strong mode there were patterns where people covariantly specialize class hierarchies in ways that are safe, but seem like they might break if we were more eager with these checks. |
Yes, flags are provided by kernel.
Exactly right. It is easy to read those kernel flags, but understanding their exact meaning is what I am after. I appreciate your lengthy replies to my short questions (thanks to @eernstg and @stereotype441 as well). |
No problem, feedback and questions are always welcome and useful. It's valuable to walk through the reasoning and examples again, since assumptions change and other eyes see things we didn't.
Agreed. |
class C<X> {
Function<Y extends X>(Y) f;
}
class C<X> {
Function<Y extends X>(Y) f;
}
void main() {
C<int> ci = new C<int>();
ci.f = <Y extends int>(Y y) => y.isEven;
Function<Y extends int>(Y) f = ci.f;
C<Object> c = ci;
Function<Y extends Object>(Y) g = c.f;
}
The point I'm making is that we have a special typing situation when a type variable X from a class is used as or in a bound for a function type F in the body of that class, because the set of types yielded by the possible values of X form a set of types with no subtyping relationship to each other, they're just a bunch of unrelated type whose least upper bound (for once, it exists ;-) is a plain For comparison, we often have a situation where a type variable X from a class is used as or in a type argument in a generic class type. As long as we stay in the universe of generic class types we have covariance everywhere, and this means that the computed types that I usually call "naive" will work nicely: class C<X> {
List<X> xs;
} For an expression If we evaluate If we switch variance and consider the kind of expression that I've called "contravariant expressions" we get a different situation: class D<X> {
void Function(X) f;
} Assume that The current approach would be to say "You must have type As I've mentioned earlier, the most relevant static typings for So we can express sound types for "contravariant expressions". The motto, however, would now be "detect contravariant expressions, and maintain the variance using an existential type, or use the approximation where contravariantly placed type variables are replaced by bottom". The situation where the type variable is used as a bound for a type parameter of a function type is one more step crazy: class E<X> {}
class F<X> implements E<X> {
Function<Y extends X>(Y) f;
} Assume that The motto is now "detect expressions whose type does not satisfy any variance constraints, and express the effects of variance on type variables therein using an existential type; alternatively, choose a sound approximation, i.e., for covariantly placed function types where a bound varies at all (any direction) use (PS: "does not satisfy any variance constraints" means that for some type operator Also, the strategy where we just require the value of The point is that it is really, really tricky for developers to maintain a discipline of invariance for any given set of types, in a world where covariance is pervasive. So we shouldn't have a type system that essentially requires developers to maintain such a discipline.
Sure, the main point of my 'aside' paragraph was that variance doesn't exist for a type expression where a class type variable X is used as a bound in a function type: We cannot soundly consider such an expression to have a type which covaries with X (in which case the naive type is fine), nor that it contravaries with X (in which case a sound type would be explicitly existential, or we could replace X in contravariant locations by bottom). We could still use an existential type, but there is no reasonable approximation which isn't existential (those function types would always degenerate to
class C<X> {
void f(Function<Y extends X>(Y) x) {}
}
About static checks on invocations of The actual type requirement on the argument expression I don't think we have any disagreement at all on these dynamic checks in the bodies of methods: They will always check for the actual type required for the given parameter, never any static approximations thereof. So I'm fine with that. About tearing off Assume that We have the notion of covariant parameters that comes to mind, but the parameter So we tear off the function, and we note that the type annotation on Sticking with the already-specified treatment of covariant parameters we could naively assign the
void test() {
C<Object> c = new C<int>();
void Function(Function<Y>(Y)) f = c.f;
}
Right, but we can do that by assigning justified types to the expressions in the first place, or we can assign types that embody unjustified requirements on the run-time semantics and then simply throw when those requirements aren't satisfied. I'm arguing that we should do the former. |
I've added a definition of variance positions in covariant-from-class.md, using Leaf's definitions including invariance (and mentioning that it differs from invariance as it is traditionally defined), and adjusted the notion of being a covariant parameter such that it takes the function-type-formal-type-parameter-bound case into account. Here's the CL. Note that these changes are orthogonal to the discussion about whether we should choose a sound static type for expressions that are contravariant or invariant or both, or we should insist on giving them the naive type statically, and throw when they don't have that. (And I still think that we should find a better term than 'invariant' to describe the property that a type operator |
Above-mentioned CL was landed as 062e5d6. |
@eernstg I'm not sure if I addressed all of your concerns/points from above or not - if not maybe we can revisit when I'm in the office there next week? |
Sure! |
Consider the following code:
The reason
c.g is void Function(Object)
should betrue
is becauseg
's parameter is declared with thecovariant
keyword, and tear-offs of methods with covariant parameters need to have their parameter types replaced withObject
.The reason
c.h is void Function(Object)
should betrue
is becauseh
's parameter is covariant due to its use of a generic parameter in the interface class; again the torn off parameter type should be replaced withObject
.When this program is run using kernel, it prints:
The VM should look at the kernel flags
VariableDeclaration.isCovariant
andVariableDeclaration.isGenericCovariantImpl
to see which parameters should have their types changed toObject
.There should be a similar behavior associated with
TypeParameter.isGenericCovariantImpl
. I'll file a separate issue for that because there are further problems with it.I'll submit
language_2
tests illustrating this problem as soon as I can.The text was updated successfully, but these errors were encountered: