Skip to content

In front end, do not relax override rules when overridden parameter is covariant #31596

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Closed
stereotype441 opened this issue Dec 9, 2017 · 29 comments
Assignees
Labels
front-end-missing-error legacy-area-front-end Legacy: Use area-dart-model instead. P2 A bug or feature request we're likely to work on

Comments

@stereotype441
Copy link
Member

Consider the following code:

class A {}
class B extends A {}
class C {
  void f(B x, B y) {}
}
abstract class I1 {
  void f(covariant A x, B y);
}
class D extends C implements I1 {}

As pointed out in #31580 (comment), the class D should be rejected at compile time because its implementation of f has type void Function(B, B), which is not a valid override type for the member type of f in the interface of D, void Function(covariant A, B).

I have not yet implemented the override checking rules in the front end (I will soon). This bug serves as a reminder that the case above should result in an error.

@stereotype441 stereotype441 added legacy-area-front-end Legacy: Use area-dart-model instead. front-end-missing-error labels Dec 9, 2017
@stereotype441 stereotype441 self-assigned this Dec 9, 2017
@eernstg
Copy link
Member

eernstg commented Dec 11, 2017

Based on an IRL discussion with Lasse: Considering this issue again, and again, it seems less disruptive, and less confusing, if we do allow the compilers to generate forwarders (and hence we consider D to be a correct declaration). We should then document this mechanism in the specification(s), and then we can tell developers that we know what we're doing, and it's intentional, if they ever report that "WOT?!" experience that I mentioned in #31580 (comment).

The argument that actually killed the idea of requiring explicit forwarders for me was that a plain forwarding declaration, foo(int x) => super.foo(x); doesn't work at all to document that that "Ah, of course, the argument x at this location is covariant, and the inherited one is not, so we declare this forwarder to explicitly state the commitment that x is covariant. And, obviously, we don't have to write covariant here, because it's implicitly covariant due to implements I<String>". It just doesn't have the intended documentational effect after all.

@leafpetersen, I suspect that it's OK with you to allow these forwarders to be generated implicitly; @lrhn, I believe that I heard you talk in favor of this approach earlier today?

I'm sorry about going back and forth on this topic, but I think we should make the right decision rather than just fixing one decision that seemed to be in favor at some point.

If so, it is no longer a 'front-end-missing-error'; it may be a no-op, but I don't know the implementation details well enough so that may be wrong.

@eernstg
Copy link
Member

eernstg commented Dec 15, 2017

@leafpetersen, @lrhn: Do you agree that we should allow generating these forwarders after all (and get them specified, too, of course)?

@lrhn
Copy link
Member

lrhn commented Dec 15, 2017

Reluctantly, yes.
The case:

class C {
  void foo(int x) { ... }
}
abstract class I<T> {
  void foo(T x);
}
class D extends C implements I<int> {
}

it just looks like it should work. It'll be very hard to explain to users why it doesn't.

@leafpetersen
Copy link
Member

lgtm

@eernstg
Copy link
Member

eernstg commented Dec 15, 2017

OK, cool! So, Paul @stereotype441, this is no more a "front-end-missing-error". Maybe this means that there is nothing to do, because dartdevc already generates these forwarders? I won't close the issue because I might be wrong, but I suspect that it can be closed.

@stereotype441
Copy link
Member Author

@eernstg I'm not 100% sure what the implementations currently do. I'll create a language_2 test verifying the behavior that I believe we want, and I'll send it to you, @leafpetersen, and @lrhn for review. Then I'll decide what to do with this bug based on whether that test passes or not :)

@eernstg
Copy link
Member

eernstg commented Dec 18, 2017

Cool!

@stereotype441
Copy link
Member Author

Ok, here's what I've found:

  • The analyzer currently rejects code like the example at the top of this bug.
  • dartdevc also rejects it, since it is based on the analyzer.
  • dart2js and the VM currently accepts the code above, but they only throw the appropriate TypeError when running in checked mode.

I've created a CL with a test demonstrating these behaviors: https://dart-review.googlesource.com/#/c/sdk/+/31750

All of these problems should theoretically take care of themselves when these tools are transitioned over to use the unified front end. I think the presence of the failing test should be a sufficient reminder to ensure that everything works properly once the various tools are transitioned over to the unified front end, so unless someone objects, I'll close this bug once the aforementioned CL lands.

@eernstg
Copy link
Member

eernstg commented Jan 3, 2018

One thing we hadn't discussed so far was the source of the signature of the induced forwarding method. I've argued in the CL that the forwarder should get the signature from the forwardee (in the example it is C.f: void Function(B, B)), rather than the signature from the interface (void Function(A, B)).

Apart from the technical reasons for doing so (given in the CL comment), the conceptual justification could be that the forwarder is a "generated corrected version" of the forwardee C.f (which will perform the dynamic type checks on the actual arguments that the forwardee could not know we need), it's not a "generated implementation" of the method signature from the interface I1.f. That makes sense, too, because an "implementation of I1.f" could do anything, but it's reasonable to expect a "corrected version of C.f" to forward to C.f.

@leafpetersen
Copy link
Member

@eernstg I'm a bit skeptical about using the less specific type for the interface of D.f there.

  • On a theoretical level, all other things being equal I'd like to have the property that nominal subtypes are structural subtypes as well. Choosing the C.f interface breaks this. Obviously covariant overrides in general break this, but still, all other things being equal I lean away from deviating here.
  • From a user intent perspective, my sense is that if someone writes extends C implements I1, they mean either "uses the implementation of C to implement I1" or "implements the combination of the interfaces of C and I1", but probably not "uses the implementation of C to implement the interface of C, ignoring the interface of I1". It's not quite as black and white as that presentation makes it sound - it's possible that the user is not really interested in the version of f from I1 and only gets it by happenstance, but this seems less likely than either of the first two interpretations.
  • It's slightly odd that promoting to the super type would give you fewer errors. We already have that with covariant generics and covariant overrides in general, but I still feel like erring on the side of sanity where possible.

I guess the counter-argument is that the user gets more accurate static errors if we choose the C.f signature, since the actual implementation signature is in fact that of C.f. And I suppose another way of phrasing the "user intent" argument is that the user intends instances of D to be usable at I1, but may want D to have a more precise signature - that's kind of the point of covariant overrides.

@stereotype441 brings up a very good point from my perspective: if C is abstract, then presumably we would choose the I1.f signature? If not, on what basis do we not? And if so, then I find it quite odd that we would choose a different signature depending on whether or not C is abstract. I don't know if it's possible, but it seems like a good principle that choosing the interface of a class should be done based on super-interfaces, not based on implementations.

@eernstg
Copy link
Member

eernstg commented Jan 4, 2018

nominal subtypes are structural subtypes as well

Let me see if I can say what this means, such that we can check that we understand it in a similar manner: A nominal subtype is introduced by a subtype related syntactic clause (extends, implements, with), and a structural subtype is introduced by composite type expressions S and T where a certain setup of subtyping relationships exist for subterms of S and of T allow us to conclude S <: T.

So with class B extends A {} we have a nominal subtype relation B <: A and a structural subtype relation like B Function(A) <: A Function(B). But the latter would be a structural subtype relationship based on a nominal one, and so is List<B> <: List<A>, and List<B> <: Iterable<A> involves two steps where one is nominal and the other is structural, so we probably can't keep the two entirely separate.

I'm not 100% sure how I could map that to the choice of typing new D().f as void Function(A, B) vs. typing it as void Function(B, B).

One interpretation that does come to mind is that, with D d; I1 i1;, the static type of d.f is a subtype of the static type of i1.f with the former choice, which is not true with the latter choice. So the latter choice will make someReceiver.f a "contravariant expression": Even when the type of someReceiver gets more special there is no guarantee that the type of someReceiver.f will get more special.

However, that's exactly what developers must expect when they see void f(covariant A x, B y); in I1, and we have, of course, adjusted the reified type of tear-offs such that someReceiver.f does yield a value whose type is a subtype of the statically known type.

So I can see the temptation to ascribe the type void Function(A, B) to D.f because it preserves the monotonic relationship (typeof(y) <: typeof(x) => typeof(y.f) <: typeof(x.f)). But the presence of covariant in I1 serves exactly to inform developers that "This member does not have that property!".

On the contrary, I think we have good reasons for ascribing the type void Function(B, B) to D.f, that is, to take the signature from the actual forwardee.

The most prominent one is simply the semantics of the construct that we're discussing. Here we have it, explicitly:

class A {}
class B extends A {}
class C {
  void f(B x, B y) {}
}
abstract class I1 {
  void f(covariant A x, B y);
}
class D extends C implements I1 {
  void f(B x, B y) { super.f(x, y); }
}

This example basically shows the code that we're generating. The core conceptual reason why I think we should take this into account when we ascribe a type to D.f is that the type of an expression should strive to describe the run-time value as accurately as possible (basically, that's a combination of (1) intuitive soundness: don't lie, and (2) accuracy: don't gratuitously forget information).

Another reason is that the type of the forwardee may well yield the solution to a computational task that we are otherwise rejecting to perform in a compiler/analyzer:

class A {}
class B extends A {}
class C {
  void f(B x, [B y]) {}
}
abstract class I1 {
  void f(covariant A x);
}
abstract class I2 {
  void f(A x, B y);
}
class D extends C implements I1, I2 {}

In this example, we couldn't use the signature from I1.f as the type of D.f because it is not a correct override for every method signature for f in the superinterfaces of D (and I2.f wouldn't do, either). But we can use the one from C.f.

If we can obtain a well-formed class by inheriting some method (forget covariance for a minute) then the signature of that method must be a correct override for all signatures of f.

So, taking covariance into account, if we need to implicitly generate a forwarder for such an (otherwise) inherited method, it will work to adopt the method signature from the forwardee (amended with covariance as needed, simply because the forwarder is placed in D), and it may not work to adopt the signature that contributes covariance for some parameters. On top of that, we may of course have multiple signatures contributing covariance to different parameters, which makes it even less workable to say "take the method signature that contributes the covariance".

From a user intent perspective..

I actually think that the method signature from the actual forwardee fits quite nicely for "uses the implementation of C to implement I1", just like the following:

class C {
  void f(Object o) {}
}
abstract class I1 {
  void f(int i);
}
class D extends C implements I1 {}

main() => new D().f('Hello, int!'); // Fine!

In this situation we also adopt the signature from the inherited implementation, even though it isn't identical to the signature in the class that D implements. So there's no way we could expect implements I1 to imply that each method that I1 has (declared or obtained by subtyping) must have a method signature which is identical to the one that I1 has. In particular there is no guarantee that this signature is the most specific, and when there is some other method signature which is strictly more specific (as in this case) then it obviously won't work.

Maybe the general phrase should be "uses the implementation of C to implement an interface which conforms to the interface of I1". It is then implied that it also implements the interface of I1, but not that they are identical, and that's a property that we can actually rely on.

if C is abstract, then presumably we would choose the I1.f signature?

If C is abstract and C.f is abstract then (in a roughly similar example, anyway) D is also abstract, and in that case we would presumably not need an implicitly generated forwarder. There must still be a most specific method signature for each method, but that's still true here:

class A {}
class B extends A {}
abstract class C {
  void f(B x, B y);
}
abstract class I1 {
  void f(covariant A x, B y);
}
abstract class D extends C implements I1 {}

In the interface of D, f has the signature void Function(A, B). If some subclass E of D is concrete it must have an implementation of f, and we may then reapply the rules we're discussing here on E.

If C is abstract and C.f is implemented I believe we have exactly the same situation as with a concrete C: The implementation of f from C possibly cannot be inherited directly (because it may not include dynamic type checks on the actual arguments as needed), but it should be "inherited" in a more loose sense, so we must insert an implicitly generated forwarder into E.

the interface of a class should be done based on super-interfaces, not based on implementations

I don't think we can make that distinction: We may obtain implementations from direct or indirect superclasses (including the ones obtained via mixins), but the interface of the superclass is based on the interfaces of its superclasses in turn, and the interface of the superclass is just one more superinterface, which contributes to the interface of the class in focus. So whenever we have an implementation it will contribute to the interface of the class just like all the non-implementation method signatures that we get from the implements hierarchy.

I think this means I wasn't really convinced. ;-)

@lrhn
Copy link
Member

lrhn commented Jan 4, 2018 via email

@eernstg
Copy link
Member

eernstg commented Jan 4, 2018

Let the synthetic forwarder [..have..] the signature of the implementation it forwards to + covariant

I haven't read the whole thing yet, but I agree completely on this part.

@stereotype441
Copy link
Member Author

Since the type given to the forwarder isn't directly visible to the user, I think it might be beneficial to focus the discussion on the compile-time and runtime behaviors we want to see, and then once we agree on that decide the best way to implement the desired behaviors in kernel and front end. Considering a slightly expanded example:

class I0 {}
class A {}
class B extends A implements I0 {}
class B2 extends A {}
class C {
  void f(B x, B y) {}
}
abstract class C2 {
  void f(B x, B y);
}
abstract class I1 {
  void f(covariant A x, B y);
}
class D extends C implements I1 {}
abstract class D2 extends C2 implements I1 {}
void test1(D d, B2 b2) { d.f(b2, null); }
void test2(D2 d, B2 b2) { d2.f(b2, null); }
void test3(D d) { void Function(B2, B) f = d.f; }
void test4(D2 d) { void Function(B2, B) f = d2.f; }
class Test5 extends D {
  void f(covariant I0 x, B y) {}
}
class Test6 extends D {
  void f(covariant B2 x, B y) {}
}
void test7() { new D().f(new A(), null); }
void test8() { print(new D().f is void Function(Object, B)); }

I think these are the behavioral questions we need to answer (along with my proposed answers):

(1) At compile time, when type checking, how do we account for a forwarder when checking what the static type of the argument should be assignable to? In other words, in the example above, should test1 have a compile-time error? My proposal is: concreteness vs abstractness should not affect the answer, so we should give the same answer for test2 and test1. In test2 there is no forwarder, so the inherited interface for D2.f has type (A, B) -> void, therefore test2 should not have a compile-time error. Therefore test1 should not have a compile time error either.

(2) At compile time, when type checking, how do we account for a forwarder when determining the static type of a tear-off? In other words, in the example above, should test3 have a compile-time error? I would propose a similar resolution here: in test4 there is no forwarder, so the inherited interface for D2.f has type (A, B) -> void, which is assignable to (B2, B) -> void, therefore test4 should not have a compile time error. Therefore test3 should not have a compile time error either.

(3) At compile time, when checking whether an override is valid, how do we account for a forwarder in the class being overridden? In other words, should class Test5 have a compile time error? How about Test6? My proposal is: synthetic forwarders shouldn't participate in the test for what constitutes a valid override; instead we follow the usual rule that a parameter that has (or inherits) the "covariant" keyword needs to have a type that is assignable to the type of the corresponding overridden parmeter in every non-synthetic overridden method (including transitive overrides). The non-synthetic overridden methods are C.f and I1.f; these declare a first parameter type of B and A respectively; therefore both Test5 and Test6 are in error, because I0 is not assignable to A, and B2 is not assignable to B.

(4) What types should be checked at runtime when a forwarder is executed? In other words, in the example above, should test7 have a runtime error? I think that to ensure soundness, the types to be checked must eventually come from the method that the forwarder forwards to, which in this case is C.f, having type (B, B) -> void. Therefore test7 should have a runtime error.

(5) What runtime type should be given to a tear-off of a forwarder? In other words, what should be printed by test8? My proposal is that since the forwarder's x parameter is covariant, the tear-off should have runtime type (Object, B) -> void, so test8 should print true.

Are there other behavioral questions we need to answer?

I believe the current implementation has all of these proposed behaviors. But I now see that they were achieved in a counterintuitive way: I gave the forwarder the signature (covariant A, B) -> void in the kernel representation, which ensured (1), (2), (3), and (5) but not (4). I then "fixed" (4) by requiring the back ends to look up the method being forwarded to when determining what type checks to perform in the forwarder. An alternative implementation would have been to give the forwarder the signature (covariant B, B) -> void, which would have ensured (3), (4), and (5), and then to fix (1) and (2) by changing the front end's type checking code so that it ignores forwarders (this would be similar to Lasse's suggestion that we keep the forwarder "out of the interface"). I am certainly open to changing the implementation (though there are some technical difficulties due to limitations in the kernel ClassHierarchy API). But I want to make sure that we agree on the behaviors we want first.

@leafpetersen
Copy link
Member

I agree with Paul's summary.

I believe if I understand Lasse's response correctly, that he and I are in agreement. By transitivity... presumably Lasse agrees with Paul's summary. Lasse, if not, please speak up.

I think Erik is still not convinced. For me, the uniformity between the abstract and concrete case is conclusive: I believe we can, and should, synthesize the class interface based on super-interfaces independent of whether there is a concrete implementation backing the interface. The fact that there is a concrete implementation, and possibly a forwarder, is an implementation detail.

@eernstg
Copy link
Member

eernstg commented Jan 5, 2018

tl;dr Paul, I think the generated forwarder should be taken into account; otherwise we agree on everything rd;lt

Apparently, you all agree that the generated forwarder should be ignored when computing the class interface. I prefer to take it into account.

The reason why I want to take the generated forwarder into account is that it has observable consequences when it is added. I mentioned already long ago that the reified type of new C().f must be void Function(B, B) and the reified type of new D().f must be void Function(Object, B), so D cannot inherit f "as is". It would not be acceptable for an implementation to generate code such that the reified type of new C().f is void Function(Object, B), hence it's not an implementation detail.

You seem to think that it is "cleaner" or "more correct" to ignore the generated forwarder, possibly because that amounts to "better encapsulation" or "abstractness" or something like that. However, given that the generated forwarder matters for the meaning of the program, it's a completely standard causal chain: There is a declaration of D.f, and it is taken into account when computing the interface for D. This doesn't reveal implementation details any more than any other declaration as used during the computation of the interface of a class.

So here are the consequences for your examples, Paul:

class I0 {}
class A {}
class B extends A implements I0 {}
class B2 extends A {}
class C {
  void f(B x, B y) {}
}
abstract class C2 {
  void f(B x, B y);
}
abstract class I1 {
  void f(covariant A x, B y);
}
class D extends C implements I1 {
  void f(B x, B y) => super.f(x, y); // Generated, and taken into account.
}
abstract class D2 extends C2 implements I1 {
  // No generated forwarders, interface has `void Function(A, B)` for `f`.
}
void test1(D d, B2 b2) { d.f(b2, null); }
void test2(D2 d, B2 b2) { d2.f(b2, null); }
void test3(D d) { void Function(B2, B) f = d.f; }
void test4(D2 d) { void Function(B2, B) f = d2.f; }
class Test5 extends D {
  void f(covariant I0 x, B y) {}
}
class Test6 extends D {
  void f(covariant B2 x, B y) {}
}
void test7() { new D().f(new A(), null); }
void test8() { print(new D().f is void Function(Object, B)); }

test1 would have a compile-time error because B2 is not assignable to B. This matches the dynamic behavior: If we were to allow this then with a new D() we would have a dynamic error. Paul says no compile-time error: disagree.

For test2, we actually have evidence that supports a compile-time error, but this would be a new feature in the type system: For an invocation like d2.f(b2, null) in test2, we know that the type of d2 implements D2, so it must have an implementation of f whose 1st argument must have a type annotation which is a correct override of f for D2, i.e., it must have a 1st argument type which is a supertype or a subtype of B and a supertype or a subtype of A; it can be Object/dynamic/void, A, B, and subtypes of B, but, in particular, it cannot be B2 (but it can of course be something which is a subtype of both B and B2). However, I think it would be too complex to include this kind of reasoning into the type check, so we would presumably just accept everything which is assignable to A. With that, test2 is OK. Paul says no compile-time error: agree.

For test3, we reject the assignment as a compile-time error because d.f has static type void Function(B, B) which is unrelated to the required type. It would succeed at run time, if allowed, but that's because we rely on the reified Object argument type, and it's misleading that we have a variable of type void Function(B2, B) and every argument of type B2 (which isn't also a B) will be rejected. So it's not much of an advantage to allow it. Paul says no compile-time error: disagree.

test4 is OK, with the same reasoning as for test2. Paul says no compile-time error: agree.

Test5 is a compile-time error because I0 is not a subtype nor a supertype of A, which is found in I1, so it's a compile-time error both when the generated forwarder is taken into account and when it is ignored. Paul says compile-time error: agree.

Test6 is a compile-time error, because B2 is neither a subtype nor a supertype of B (which is found in C, and in D iff the generated forwarder is taken into account, but again that does not matter because it already fails due to the declaration in C). Paul says compile-time error: agree.

test7 is OK (if we do not take into account that new A() has the exact type A), because A is assignable to B. It would of course fail at run time because the actual requirement is B. Analysis that takes exactness into account would be able to detect this, and so would --no-implicit-casts. Paul says no compile-time error, but run-time error: agree.

test8 is OK, and would print true at run time. Paul says the same: agree.

So we agree everywhere except in test1 and test3 where, arguably, the static analysis is better when we take the generated forwarder into account when computing the class interface.

I think this reflects the situation quite well: When the generated forwarder is taken into account, the static analysis is improved --- because the static information is more honest about the situation. So, again, it all comes down to whether it is actually an implementation detail. ;-)

@lrhn
Copy link
Member

lrhn commented Jan 5, 2018 via email

@lrhn
Copy link
Member

lrhn commented Jan 5, 2018 via email

@jensjoha jensjoha added the P2 A bug or feature request we're likely to work on label Jan 9, 2018
@eernstg
Copy link
Member

eernstg commented Jan 10, 2018

I've written a rough draft of an approach to interfaces in Dart (which uses
Lasse's preferred model where we collapse the interface of a class such
that it contains all methods, i.e., methods declared locally plus inherited
methods), and given it a small twist such that it accumulates the
information relevant to covariant parameters:
https://gist.github.com/eernstg/872bf60e8a2d942ccb514e80142ede25

It's basically the same stuff as usual, but explicitly flattened, and then
with the extra twist for covariance.

I believe it shows that we could obtain the more precise typing of the
situations discussed in this issue without any reference to generated
forwarders, and without any information about whether a given method
declaration is an implementation or an abstract method.

As far as I can understand (from discussing the basic setup with Johnni)
it might actually be quite compatible with the approach that the common front
end is already using (so there is some implementation work to do, but
not necessarily a huge amount).

Finally, I expect any change in this area to break a very small amount of
code, if it is breaking at all, simply because the situations that we have
discussed are so marginal.

@eernstg
Copy link
Member

eernstg commented Jan 10, 2018

OK, I know you hate this, and we don't have the time.

So let's stick to the model that everybody except I preferred, and then we can keep these other things in mind for a later time. ;-)

@stereotype441
Copy link
Member Author

Ok, based on this I'm going to move ahead with https://dart-review.googlesource.com/c/sdk/+/31750 as is, however I will update the comments in it to reflect the discussion here. Then I'll follow up with another CL containing the test cases we've been discussing in this thread, and my understanding of what we've agreed upon; I'll send that CL to Lasse, Erik, and Leaf for review so that if I've misunderstood something you can correct me.

whesse pushed a commit that referenced this issue Jan 11, 2018
Change-Id: I34abe6993e0dc85d1234878c91ce735139b9cb47
Reviewed-on: https://dart-review.googlesource.com/31750
Reviewed-by: Leaf Petersen <leafp@google.com>
Commit-Queue: Paul Berry <paulberry@google.com>
whesse pushed a commit that referenced this issue Jan 20, 2018
Change-Id: Ib67f8ae239b89bf56efc059054b6430e27a0e66f
Reviewed-on: https://dart-review.googlesource.com/34922
Commit-Queue: Paul Berry <paulberry@google.com>
Reviewed-by: Erik Ernst <eernst@google.com>
@eernstg eernstg mentioned this issue Jun 1, 2018
@leafpetersen
Copy link
Member

What's the status of this?

@stereotype441
Copy link
Member Author

I'm not sure, and at this point it's been long enough since I worked on it that I would have to take a fresh look. Since I'm not on the front end project anymore, probably someone on the front end team should take a look.

@stereotype441 stereotype441 removed their assignment Sep 21, 2018
@kmillikin
Copy link

kmillikin commented Sep 27, 2018

We pass language_2/issue31596_test. Analyzer fails with

ERROR|COMPILE_TIME_ERROR|INVALID_OVERRIDE||'C.f' ('(B, B) → void') isn't a valid override of 'I.f' ('(A, B) → void').
ERROR|STATIC_WARNING|ARGUMENT_TYPE_NOT_ASSIGNABLE|The argument type 'B2' can't be assigned to the parameter type 'B'.

I presume that means the CFE issue is fixed, but I can't follow the discussion in this issue well enough to confirm that.

Can we verify fixed?

@eernstg
Copy link
Member

eernstg commented Sep 5, 2019

@leafpetersen wrote:

What's the status of this?

I just needed to look into this area. Cf. this comment, we decided that it is not correct to inherit an implementation whose parameters are not covariant into a class where the same declaration would only be correct when it is taken into account that the parameters would be covariant-by-declaration implicitly. So the following is an error as indicated in the comment:

class C {
  void f(int x) {}
}
abstract class I {
  void f(covariant num x);
}
class D extends C implements I {} // Error.

As of b31566b, the analyzer reports a compile-time error, but CFE doesn't.

However, it is allowed to inherit an implementation whose parameters are not covariant into a class where the same declaration would only be correct when it is taken into account that the parameters would be covariant-by-class.

class C {
  void f(int x) {}
}
abstract class I<T> {
  void f(T x);
}
class D extends C implements I<int> {} // OK.

With this example, same commit, the analyzer does not report any compile-time errors, and neither does CFE.

The dynamic type error is detected by dart as it should be in both cases.

So the status is that front-end-missing-error is still correct; but the analyzer does the right thing, so the fix should not be a breaking change in practice.

@fishythefish
Copy link
Member

The tests related to this issue are broken and I'm trying to clean them up.

The language_2/issue31596_super_test multitests are massively broken:

  • 01 and 03 fail on every configuration (88 total).
  • 05 passes on 3 fasta configurations and fails on 85 others.
  • 02 and 04 pass on 6 analyzer and DDC (but not DDK) configurations and fail on 77 others.
  • none is the opposite of 02 and 04: it fails when the frontend is analyzer and passes otherwise.

I'm happy to update the tests, but I need some help from the language lawyers on the current set of expectations:

class I0 {}
class A {}
class B extends A implements I0 {}
class B2 extends A {}

class C {
  void f(B x) {}
}

abstract class I {
  void f(covariant A x);
}

// analyzer rejects classes D and E on the grounds that `C.f` isn't a valid override of `I.f`, but all other configurations accept them.

class D extends C implements I {}

class E extends D {
  void test() {
    I0 i0 = null;
    B2 b2 = null;

    // What should the type of `super.f` be?
    // * The test expects `superF` to have type (B) -> void below.
    // * Printing `super.f.runtimeType` says that it's (Object) -> void.
    // * Parts of the test behave like it's (A) -> void (or (covariant A) -> void)).

    super.f(i0); // We expect this to succeed but it fails on every configuration because `I0` is not assignable to `A`.

    super.f(b2); // We expect a compile-time error because `B2` is not assignable to `B`, but this passes on CFE.

    var superF = super.f;
    // What is the inferred type of `superF`?
    // The test expects (B) -> void, but see the discussion above regarding `super.f`.
    // In particular, are the following `true` or `false`? (We expect them all to be true but these fail on all configurations except fasta.)
    // * superF is void Function(B)
    // * superF is! void Function(I0)
    // * superF is! void Function(A)
    // * superF is! void Function(Object)

    // These are analogous to the `super.f` invocations:
    superF(i0);
    superF(b2);
  }
}

The following tests also fail on the 6 analyzer/DDC configuration:

  • language_2/issue31596_test
  • language_2/issue31596_implement_covariant_test
  • language_2/issue31596_tearoff_test
  • language_2/issue31596_override_test/{none,01,02,03,04}

(The first 3 also happen to fail on dart2js-production-linux-d8.)

@eernstg
Copy link
Member

eernstg commented Sep 6, 2019

About issue31596_super_test.dart, looking at the code here (looks like that is the source of that test):

Class D has an error, because C.f cannot be inherited by D, because its parameter isn't covariant-by-declaration in C.

Class E might have an error just because it can't have a superclass with an error (we don't specify derived errors); otherwise it has an error because it would also only have C.f as the potential implementation of f, and it has the same problem as in D.

So the test as a whole isn't so useful as long as it's all wrong, and it doesn't look like it is intended to have that property. ;-)

But it could be changed to express the expectation that D has an error, and E could be deleted.

And then it could be useful to have a variant of the original test as well, testing that it is allowed to have a very similar situation, as long as it has covariance-by-class where the current one has covariance-by-declaration (I added comments in the code using brackets, after the part that I'm commenting on):

class I0 {}
class A {}
class B extends A implements I0 {}
class B2 extends A {}

class C {
  void f(B x) {}
}

abstract class I<X> {
  void f(X x);
}

class D extends C implements I<B> {}

class E extends D {
  void test() {
    I0 i0 = null;
    B2 b2 = null;

    // What should the type of `super.f` be?
    //     [`void f(B)`; the parameter is covariant-by-class, but that's not visible in the signature]
    // * The test expects `superF` to have type (B) -> void below.
    // * Printing `super.f.runtimeType` says that it's (Object) -> void.
    //      [That's correct, that reified parameter type is `Object`; but there's a generated check for `B`.]
    // * Parts of the test behave like it's (A) -> void (or (covariant A) -> void)).
    //     [It does have type `void Function(A)`, and `void Function(S)` for _any_ `S`,
    //     but the dynamic check will make an invocation with argument `A()` throw.]

    super.f(i0); // We expect this to succeed but it fails on every configuration because `I0` is not assignable to `A`.
    // [The static parameter type is `B`, so (until NNBD) `IO` is assignable. No compile-time error.]

    super.f(b2); // We expect a compile-time error because `B2` is not assignable to `B`, but this passes on CFE.
    // [Right, this should be a compile-time error now.]

    var superF = super.f;
    // What is the inferred type of `superF`?
    //     [The static type of the tear-off preserves the declared type: `void Function(B)`.]
    // The test expects (B) -> void, but see the discussion above regarding `super.f`.
    // In particular, are the following `true` or `false`? (We expect them all to be true but these fail on all configurations except fasta.)
    // * superF is void Function(B)
    // * superF is! void Function(I0)
    // * superF is! void Function(A)
    // * superF is! void Function(Object)
    //     [This is the dynamic type (`void Function(Object)`): true, false, false, false.]

    // These are analogous to the `super.f` invocations:
    superF(i0);
    // [OK.]
    superF(b2);
    // [Compile-time error.]
  }
}

dart-bot pushed a commit that referenced this issue Sep 9, 2019
Change-Id: I02d56402aa09c1c9563929ab3792743b8fe7e97d
Bug: #31596
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/116085
Commit-Queue: Mayank Patke <fishythefish@google.com>
Reviewed-by: Erik Ernst <eernst@google.com>
Auto-Submit: Mayank Patke <fishythefish@google.com>
@johnniwinther johnniwinther self-assigned this Jul 15, 2021
@johnniwinther
Copy link
Member

Related to #46389

@eernstg
Copy link
Member

eernstg commented Oct 18, 2021

Closing: The language team decided that we would generate the forwarding methods that are required for soundness reasons (or whatever implementation a tool may use, forwarding methods is just one possible implementation). It is currently being implemented (cf. #47072, with spec changes in dart-lang/language#1833).

With this change, the tests language{,_2}/regress/regress31596... have wrong expectations. That's handled in #47073 and https://dart-review.googlesource.com/c/sdk/+/217016.

@eernstg eernstg closed this as completed Oct 18, 2021
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
front-end-missing-error legacy-area-front-end Legacy: Use area-dart-model instead. P2 A bug or feature request we're likely to work on
Projects
None yet
Development

No branches or pull requests

8 participants