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

Do extension methods capture static type parameters or runtime type parameters (or both) #180

Closed
leafpetersen opened this issue Jan 15, 2019 · 24 comments

Comments

@leafpetersen
Copy link
Member

leafpetersen commented Jan 15, 2019

The static extension method feature proposed in dart-lang/sdk#57189 likely allows extensions to reference the generic type parameter of the class (it's almost certainly not useful otherwise). For Dart, which has reified generics, a key question is whether the type parameter which is captured is the static type parameter, or the runtime type parameter. In discussion, it has been pointed out that the latter might be very useful. This issue is to discuss the implications and mechanisms involved.

Concretely, suppose I have the following:

class Ref<T> P
  T _value;
  Ref(this._value);
  T get value() => _value;
  set value (T x) => _value = x;
}

And suppose I defined an extension as follows:

extension CloneRef<T> on Ref<T> {
  Ref<T> clone() => Ref(this.value);
  Ref<T> cloneWith(T x) => Ref(x);
}

Now consider the following code:

  void test() {
    Ref<num> r1 = Ref<int>(3);
    Ref<num> r2 = r1.clone();
    r1 = r2 as Ref<int>; // (1)
    r2.value = 3.0; // (2)
    Ref<num> r3 = r1.cloneWith(3.0); // (3)
  }

If extension methods capture the static type of the receiver only, then the runtime (reified) type of r2 and r3 is Ref<num>, and:

  • line (1) will be a failed runtime cast
  • lines (2) and (3) will succeed.

If extension methods capture the dynamic type of the received, then the runtime (reified) type of r2 and r3 is Ref<int>, and:

  • line (1) will succeed
  • lines (2) and (3) must be arranged to fail for soundness.

The simple operational model of extension methods is to treat them as syntactic sugar for application of a static function. For this example, we would have something like the following de sugaring:

class CloneRef {
  static Ref<T> clone<T>(Ref<T> this) => Ref(this.value);
  static Ref <T> cloneWith<T>(T x) => Ref(x);
}

If we treat extension methods as capturing the static type, then the following de-sugaring of the test code is valid:

  void test() {
    Ref<num> r1 = Ref<int>(3);
    Ref<num> r2 = CloneRef.clone<num>(r1);
    r1 = r2 as Ref<int>; // (1)
    r2.value = 3.0; // (2)
    Ref<num> r3 = CloneRef.cloneWith<num>(r1, 3.0); // (3)
  }

If we wish to treat extension methods as capturing the runtime type, then we need some additional mechanism, akin to the runtime checks that are required for checked covariant generics. It's plausible (but I don't think entirely clear to me yet) that this can be done entirely via caller side checking, using something like an "open type" mechanism which captures the runtime generic type parameter. In that case, we can use the same de-sugaring of the extensions themselves, but the code which uses them must be de-sugared into code which opens the object and potentially performs runtime checks.

  void test() {
    Ref<num> r1 = Ref<int>(3);
    Ref<num> r2 = 
     open r1 as Ref<T> rt in CloneRef.clone<T>(rt);
    r1 = r2 as Ref<int>; // (1)
    r2.value = 3.0; // (2)
    Ref<num> r3 = 
      open r1 as Ref<T> rt in CloneRef.cloneWith<T>(rt, 3.0 as T); // (3)
  }

Notice the additional runtime cast required on the argument to cloneWith. In some cases, a similar runtime cast might be required on returned values as well.

It's plausible that these checks could be done on the callee side as well, I'll have to think through what the encoding would be.

@leafpetersen
Copy link
Member Author

cc @lrhn @munificent @eernstg

@eernstg
Copy link
Member

eernstg commented Jan 15, 2019

I'd actually expect the very last occurrence of num need to be T (CloneRef.cloneWith<T>(rt, 3.0 as T)), because, in the original expression r1.cloneWith(3.0), cloneWith is not a generic method, it gets the value of T from the dynamic type of the receiver. Would that be compatible with your thinking?

@leafpetersen
Copy link
Member Author

Would that be compatible with your thinking?

Fixed, thanks.

@eernstg
Copy link
Member

eernstg commented Jan 15, 2019

Here's a reason for wanting the type argument passed to cloneWith to be T rather than num (even though the usual preference given to the context type might dictate the latter), namely the perspective where the extension methods are handled by a companion object:

// Desugared extension.
class CloneRef<T> {
  Ref<T> clone(Ref<T> this) => Ref(this.value);
  Ref<T> cloneWith(T x) => Ref(x);
  static CloneRef<T> extensionObject(Ref<T> target) {...}
}

 void test() {
    Ref<num> r1 = Ref<int>(3);
    CloneRef<num> cr1 = CloneRef.extensionObject(r1); // Compiler-generated, yields a `CloneRef<int>`.
    Ref<num> r2 = cr1.clone(r1);
    r1 = r2 as Ref<int>; // (1)
    r2.value = 3.0; // (2)
    // `r1` not modified, can reuse `cr1`.
    Ref<num> r3 = cr1.cloneWith(r1, 3.0); // (3)
  }

@eernstg
Copy link
Member

eernstg commented Jan 17, 2019

Note that there is a proposal for how to deal with type patterns (#170), and in particular that it ensures that if a pattern P matches a type T and S <: T, then P matches S, and it will bind type variables introduced by P to values which have the expected relationship to the bindings resulting from a match with T, and which also has the soundness property that the matched type (here: T or S) is a subtype of the matching type (that is, the type [U1/X1 .. Us/Xs]P, where Xj, 1 <= j <= s are the type variables introduced by P and Uj the corresponding binding produced by the match). I believe that these properties are highly relevant to the discussion about the soundness of the resulting language mechanism.

@leafpetersen
Copy link
Member Author

It would be worth looking into whether this comes up in C#, and if so how they deal with it.

@lrhn
Copy link
Member

lrhn commented Feb 4, 2019

If we match partial function types, then it's possible that the actual type is a supertype of the static type.

extension Unary<R, T> on R Function(T) {
  void before(T function(T) intercept) => (T value) => super(intercept(value));
  void after(R function(R) intercept) => (T value) => intercept(super(value));
}

So:

int foo(Object o) => o.hashCode;
num Function(num) f = foo;
f.before((x) => x); /// static inference would type x as num Function(num)
f.after((x) => x); /// static inference would type x as num Function(num)

Here we pass a num Function(num) to something expecting Object Function(Object) or an int Function(int). That will blow up in either case. So, we must recognize that calling the before and after functions are not safe.

This is not new for after, it's a classical covariant type argument, and we get the same effect for classes.

For before, our type variable is contravariant (and still used invariantly). I don't know if we have anything like it already.

So, we can definitely do unsafe things. The big question is whether we can predict where the unsafeness occurs and insert checks statically, or whether we'll have to check everywhere.

@eernstg
Copy link
Member

eernstg commented Feb 4, 2019

extension Unary<R, T> on R Function(T) ...

This has a structure which is similar to the one that we have outlawed (where a superinterface in a class declaration uses a type argument in a contravariant or invariant position, #39), so we should certainly not aim at enforcing the usual discipline on class types where a static typing situation involving Unary<R0, T0> is assumed to be associated with a dynamic environment where the corresponding type variable values R and T are subtypes (R <: R0 and T <: T0).

But we already have a similar situation with a type alias: The type parameter T "is contravariant", and such a type parameter could also "be invariant", and at run time we would have R <: R0 and T :> T0.

This means that the parameter type T of intercept in before is an example of using a contravariant type parameter in a covariant position (which is unsafe), and similarly for the return type R of intercept in after.

So we shouldn't have any particular problems detecting the unsafe cases, and doing whatever we want about them.

The situation where inference comes up with a result that will cause a dynamic error if there is any non-trivial subsumption is also something we have looked at earlier (dart-lang/sdk#57775). I think we ought to be able to flag such situations (maybe always, maybe just as a lint), and offer some sort of invariance support such that developers can take away the variance that causes the problem.

(We haven't discussed having the ability to declare that a function has a type like num Function(exactly num), but we could allow that, if we insist that these unsafe constructs must be allowed, and it should be possible to make them statically safe).

@leafpetersen
Copy link
Member Author

Just to be clear, I don't see anything problematic with the example as written, I think you mean to set it up to use the results in a bad way? e.g.

extension Unary<R, T> on R Function(T) {
  Object before(T function(T) intercept) => (T value) => super(intercept(value));
  Object after(R function(R) intercept) => (T value) => intercept(super(value));
}
int foo(Object o) => o.hashCode;
num Function(num) f = foo;
Object g0 = f.before((x) => x.toInt()); /// static inference would type of the lambda as num Function(num)
Object g1 = f.after((x) => 3.0); /// static inference would type the lambda as num Function(num)
// Runtime type of g0 is int Function(Object) so cast succeeds
(g0 as int Function(Object))("hello");  // Calls the lambda on "hello", which blows up

// Runtime type of g1 is int Function(Object)
(g1 as int Function(Object))("hello").isEven // lambda returns 3.0, which has no isEven

Is that what you're getting at, or something else?

This seems like a good example of places where the static and runtime checking needs to be worked out if we want to take this approach, as we discussed a few weeks ago. It's not immediately clear to me whether this example should be statically rejected (because of variance), or whether there are implied casts on the arguments to before and after that will fail. Working the semantics out in terms of an underlying model using some kind of "open type" construct might make this clear.

Alternatively, this can be seen as an argument for using the straightforward static interpretation.

@lrhn
Copy link
Member

lrhn commented Feb 5, 2019

Yes, the point of the examples were to show that a naïve approach would introduce type-unsoundness that had to be detected and guarded against.

I think Erik's approach of disallowing contravariant cheating like this is likely simpler.

Another option is to fail early if the captured type parameter is actually used invariantly, and the run-time type doesn't match the static type. That would mean failing immediately when calling the extension method that requires a run-time type that is both a supertype and a subtype of the static type.

Just using the static type is an option, but not a particularly good one (and we still need to introduce open-type functionality anyway).

@natebosch
Copy link
Member

Some of this discussion is a bit over my head. I have a concrete example that may be impacted by this decision. Handling types with StreamTransformer has traditionally given headaches since one of it's generics is for an argument type, and the other a return type.

Here is an extension I'd like to be able to write. Will the decision made in this issue have an impact on my ability to write it?

extension WhereType<T> on Stream<T> {
  Stream<R> whereType<R>() => super.transform(
          StreamTransformer<T, R>.fromHandlers(handleData: (e, sink) {
        if (e is R) sink.add(e);
      }));
}

When I attempt to write this today as a method returning a StreamTransformer I'm pushed into a worse API:

StreamTransformer<T, R> whereType<T, R>() =>
    fromHandlers(handleData: (v, sink) {
      if (v is R) sink.add(v);
    });

// usage
numbers.transform(whereType<num, int>());

What I would like is:

numbers.transform(whereType<int>());

However I can't get that. If I return a StreamTransformer<dynamic, int> I get a static error because StreamTransformer<dynamic, int> is not a StreamTransformer<num, int>, and if I return a StreamTransformer<Null, int> I get a runtime error because a Stream<num> is not a Stream<Null> in the call to bind().

@lrhn
Copy link
Member

lrhn commented Feb 11, 2019

This is exactly one of the cases where a method on the object can do things that an external method cannot, because the instance method has access to the concrete type argument. It can create something that has the "exact type" needed for invariant constraints. Any external function cannot. If we want extension methods to be able to do everything instance methods can, then they need access to the actual type parameters too.

@munificent
Copy link
Member

munificent commented Apr 1, 2019

For what it's worth, here is my current take on this:

  1. I do think it is important to have functionality to crack open an object and extract its runtime type argument. During the 2.0 migration, we had to add a hacky dart_internal library to expose this for Iterable<T> and Map<K, V> precisely because we had very compelling use cases. Obviously, to get rid of that hack, we need a real language (or maybe library) mechanism to do this.

  2. I think you should be able to access the above mechanism within the body of a method. In other words, you shouldn't have to define a special method whose parameter signature is where the magic happens.

  3. My limited understanding is that exposing type patterns in the signature of an extension method doesn't add any expressiveness compared to using the type pattern inside the body of the extension method.

    That's probably not clear. By analogy, let's talk about union types. If you want a function that accepts either a number or a string, you can today do:

    function(Object numberOrString) {
      if (numberOrString is num) {
        print("number");
      } else if (numberOrString is String) {
        print("string");
      } else {
        throw ArgumentError();
      }
    }

    This has the desired runtime behavior. But the static experience isn't
    great:

    function(true); // Not a number or a string.

    There's no static error here. So even though we can write code inside the body of the method to do what we want, the fact that it's not visible in the signature compromises the experience.

    As far as I know, disallowing type patterns in extension methods does not have this problem. Because it's all about grabbing the runtime type argument, the static shape is the same either way.

  4. I expect not supporting extracting the runtime type in extension methods to lead to a strictly simpler and more shippable feature.

1 and 2 imply that even if extension methods can grab runtime type parameters, we'll need another feature to let us do the same inside the body of a method. 3 implies that using that latter feature inside extension methods is just as good as direct support for it in extension methods. 4 implies that doing both has a cost.

All of those imply to me then that supporting grabbing the runtime type argument in extension methods isn't worth the cost. Instead, I think it makes more sense to keep it a relatively simple purely static feature, and rely on some sort of type pattern system at the statement level for the rare cases where you need it. Then those two features can be naturally composed in the cases where you want an extension method that accesses its receiver's runtime type arguments.

@natebosch
Copy link
Member

rely on some sort of type pattern system at the statement level for the rare cases where you need it.

Can you give a straw man for what that might look like and how it solves the whereType use case?

@munificent
Copy link
Member

This syntax is totally made up, but something along the lines of:

extension WhereType<T> on Stream<T> {
  Stream<R> whereType<R>() {
    match (this) {
      case Stream<var S>:
        return transform(
            StreamTransformer<S, R>.fromHandlers(handleData: (e, sink) {
          if (e is R) sink.add(e);
        }));
      default:
        throw "Unreachable";
    }
  }
}

@eernstg
Copy link
Member

eernstg commented Apr 2, 2019

@munificent wrote:

type patterns in the signature of an extension method doesn't
add any expressiveness compared to using the type pattern
inside the body of the extension method

That is not quite true. When the actual type arguments are in scope for the extension as a whole (rather than in a scope nested inside the body of each extension method), it is possible to use those type arguments in the signature of the extension method. I'll use type patterns as specified in dart-lang/sdk#57266 to illustrate this.

An example where that makes a difference is when we have an exact type, and we are constructing an entity (here: a function) whose type has non-trivial variances (the argument type is contravariant):

class Pair<X, Y> {
  final X x;
  final Y y;
  Pair(this.x, this.y);
}

extension Pairing on var X {
  Pair<X, X> Function(X) pairWithMe() => (X other) => Pair(other, this);
}

main() {
  Pair<String, String> Function(String) f = "Hello".pairWithMe();
}

In this example, we can safely assign the returned function to a variable whose signature is exactly the statically known signature from the extension method. This gives us a "tight" typing, and we know that both static and dynamic checks will help us using that function only with string arguments.

Currently this only arises with exact types, but we might very well get support for some kind of invariance (for instance, dart-lang/linter#229), and this allows us to achieve similar benefits for type arguments:

extension Adding on List<var X> {
  void Function(X) adder() => add;
}

void Function(X) foo<X>(List<invariant X> xs) => xs.adder();

main() {
  // Statically safe code.
  void Function(num) f = foo([25.3]);
  f(3.25);
  f(325);

  // With covariance we will get the usual dynamic checks, ..
  List<num> xs = <int>[];
  void Function(num) f = xs.adder(); // Dynamic check, succeeds due to tear-off erasure.
  f(3.25); // Throws at runtime.

  // .. or we can use type patterns based promotion to achieve a safe scope.
  if (xs is List<var Y>) {
    void Function(Y) f2 = xs.adder(); // Statically safe.
    num n = 3.25;
    if (n is Y) f2(n); // `f2` is not called: Dynamically safe.
  }
}

In this code, assuming dynamic matching of type patterns, there are no dynamic type checks except the ones in the part about using covariance and getting "the usual dynamic checks".

In contrast, if we only match statically in Adding then the attempt to return the tear-off of add would fail whenever adder is added and the dynamic type argument of the list is actually a subtype of the statically known one (so that would mean that we can only call adder when we have obtained an invariant type on the list, which could be marked explicitly with invariant, or it could just be sheer luck that the actual type argument is the same as the statically known one).

Alternatively, still considering what we could do when type patterns are matched statically, we could allow invocations on receivers of type List<T> for some type T (rather than List<invariant T>) by giving adder the return type void Function(Null). But that function type is not handled well in Dart (an over-eager static check is applied, so we basically only get to call it if we cast the function to dynamic and then perform a dynamic invocation). There is no type S different from Null (with non-null types that would be Never—it just needs to be the bottom type) such that we could safely use the return type void Function(S).

In other words, the distinction between being able or unable to refer to the actual type arguments in the signature of extension methods makes a difference as soon as we consider

  • code where a receiver uses covariance and hence some dynamic checks are inserted, along with
  • code where invariance or similar devices are used to achieve full static safety,

in particular in those situations where we use signatures that we have called "contravariant" (e.g., when an instance variable type or a method return type is contravariant or invariant in a type variable of the enclosing class). Those "contravariant" return types is an area where improvements in static type safety would be greatly appreciated (by me and others ;-).

I think that's a relevant consideration for Dart, certainly when features like invariance are new (and lots of code hasn't yet been adapted to use it), but also in the long run, because some objects may be used in a mostly read-only fashion, in which case it may be a perfectly meaningful trade-off to use covariance and accept a dynamic check in a couple of situations where it's necessary to write to it (and those situations could of course also be made statically safe with dynamic checks like if (x is List<var X extends Whatever>) ...).

For a more detailed presentation of a possible design of static scoped extension methods using type patterns, take a look at PR dart-lang/sdk#57361 (dynamic matching) and PR dart-lang/sdk#57367 (static matching).

@eernstg
Copy link
Member

eernstg commented Apr 2, 2019

Here's a more compact example with direct emphasis on a contravariantly typed member (such that we don't need to consider things like tear-offs and parameters that are covariant-by-class):

class C<X> {
  void Function(X) f;
  C(this.f);
}

extension Foo on C<var X> {
  void Function(X) foo() => f;
}

If we match the type pattern statically then an invocation c.foo() where the statically known receiver type is C<num> and the dynamic receiver type is C<int> will have static type void Function(num), and the dynamic type will be void Function(int), so we have a soundness hole or we add a caller side check, which will throw in case there is any difference between the statically known type argument of C and the dynamic one. This is based on a static choice of the value of X, and there is really no way to make that safe because X is detached from the list.

Alternatively, if we match the type argument dynamically then the receiver type is C<num> which actually means exists X <: num. C<invariant X> in Dart, and this means that the type of the invocation c.foo() will be exists X <: num. void Function(X). This type can be approximated soundly by void Function(Null), and that would allow us to, say, assign the returned value to a variable of type Function (no checks needed), or assign it to a variable of type void Function(int) (with a dynamic check, which would succeed in this case).

So the static matching essentially forces us to forget the existential properties of the type, and this means that we cannot soundly select an upper bound of the type of any expression involving a "member with a contravariant type". With the dynamic matching we preserve the static knowledge that the receiver and the extension method signature are connected, and that allows us to preserve more precise typings.

@eernstg
Copy link
Member

eernstg commented Apr 3, 2019

@tatumizer wrote:

please illustrate the last C<X> example with the code

It's all about the treatment of the invocation c.foo(), so there isn't much additional code:

class C<X> {
  void Function(X) f;
  C(this.f);
}

C<num> c = C<int>((int i) {});

extension Foo on C<var X> {
  void Function(X) foo() => f;
}

var x = c.foo();

However, the point is that we may have different choices for specifying what that invocation of foo means.

If we use static type pattern matching then the extension amounts to a thin layer of syntactic sugar. In this case the meaning of an extension is a set of top-level functions, and call sites will just move the receiver from being in front (like e.f(...)) to the first parameter position (like f(e, ...)). Type parameters of the extension will be type parameters of each of the top-level functions, and they will be chosen by type pattern matching on the static type of the receiver. So we get the following desugared code (skipping class C and variable c, which are the same each time):

void Function(X) Foo_foo<X>(C<X> c) => c.f;
var cf = Foo_foo<num>(c);

With this design, the type returned by the extension method is void Function(num). This means that the body of the method will throw, because it tries to return a value of type void Function(int), which is not a subtype of the return type.

What I'm pointing out here is that the semantics of the program is not correctly represented by the static analysis, because it got lost along the way that the receiver has type exists T <: num. C<invariant T>, but it was taken to have the type C<invariant num> (we need to use invariance because the whole point is that this T is precisely the type argument of the dynamic type of c).

It is not a soundness problem that the program throws, but it indicates that we get a lower quality of service. ;-)

When I mentioned the soundness problem I was referring to the hypothetical solution where we allow that Foo_foo invocation to return a void Function(int). That's simply unsound, so given that we want to preserve soundness, we cannot do that.

But we do not have to tolerate this quality-of-service problem. It's a little bit like saying to a contractor: "Let's sign a contract. I promise that X <= 100, and then you build a house for me". The contractor signs the contract. Then at run-time we have X == 99, and then the contractor turns on us and says "Oh, but I assumed X == 100, so now I won't build the house". That's unfair.

With dynamic type pattern matching we will have the following semantics. We can still desugar each extension method to a top-level function, and the type variables introduced in the type pattern of the extension are type parameters of that function (followed by the user-declared ones, in case the extension method is generic). But the dynamic matching of the type pattern must be expressed explicitly, and that's not a feature that Dart has currently.

void Function(X) Foo_foo<X>() => f;
var cf = let v = c in Foo_foo<v@C.X>(v);

You might think of v@C.X as a path dependent type. Of course, Dart does not have path dependent types (introduced here, formalized here and in the development of Scala), but we can just think of type variables as final members of objects and then looking up the actual value of the type argument X of an instance of C could be written as v@C.X. We have to say which class we want to use because we could have things like class B<X> extends A<List<X>> {}, and then we could have a b with type B, and b@B.X could be num whereas b@A.X could be List<num>.

This means that the statically known return type of the invocation would be void Function(v@C.X). But given that we don't want to allow deep excursions into dependent types we will make a sound approximation to that type and never allow the dependent types to exist beyond each single step of the type analysis. The static knowledge we have is that v@C.X <: num, but this means that the best sound approximation (supertype) is void Function(Null). So that's the inferred type of cf.

With that, we get a successful execution of the extension method, and we preserve soundness. The resulting function could then be invoked:

void bar(void Function(int) g) => g(42);

main() {
  bar(cf); // Downcast `void Function(Null)` to `void Function(int)`: succeeds.
}

So my point is that we can preserve a more precise static analysis by using dynamic type pattern matching and running a static analysis which takes this fact into account, and the alternative (static type pattern matching) leaves the programmer with something that basically corresponds to broken promises.

We're currently treating function types where one or more of the formal parameter types is Null a bit harshly (everything other than null will cause a dynamic error), but we could relax that such that we only enforce the type which is needed for soundness (that is, the one which is declared in the parameter list of the function which is actually called). This means that it may currently be necessary to explicitly cast the function to some other type (here, void Function(int)), but in the future we could allow invocations using types like void Function(Null). We would then have lints, enabling developers to force themselves to cast the function type if they do not wish to accept the dynamic check. Cf. dart-lang/sdk#57370.

But the point is in any case that we don't over-eagerly stop the program with a dynamic error, and that's something that we just can't avoid when type patterns are matched statically.

@eernstg
Copy link
Member

eernstg commented Apr 4, 2019

As a follow-up on this comment, I've written a rough first proposal for how to directly compute the resulting Dart type in a situation where we would otherwise take a detour via path dependent types: dart-lang/sdk#57371. The output from that algorithm is the kind of type we are using here.

@tatumizer wrote:

explain .. a much simpler example:

The object c_of_duck variable refers to an instance of C<Duck> typed as such, and c_of_bird refers to the same instance, but typed as C<Bird>. This is not a violation of the heap invariant, because C<Duck> <: C<Bird>.

However, it is incorrect to assume that c_of_bird.f has type Bird Function(Bird). What we actually know is that there is some type T such that T <: Bird, and c_of_bird has a type S such that C<invariant T> is a superinterface of S (directly or indirectly). Based on that, we can conclude that c_of_bird.f has type T Function(T).

But we do not have T Function(T) <: Bird Function(Bird) for all T <: Bird, and that's the reason why the assumption above is incorrect.

We could then assign a sound type to the expression c_of_bird.f (the best one would be Bird Function(Null)), and check c_of_bird.f(new Duck()) accordingly. This would succeed when implicit downcasts are supported, and code generation will then have to proceed in such a way that it is statically known that there will be a check (btw, that check will succeed at run-time in this case).

However, we do not do that currently, we actually assign the type Bird Function(Bird) to the expression c_of_bird.f, and then we generate a dynamic check to insist that this is what we get (a caller-side check). Of course, given that c_of_bird can be a C<Duck> there is no reason to expect this to hold, but it is true that we will enforce that the program incurs a dynamic error except in some situations where it is indeed safe to proceed. Indeed, it would work in some situations with exactly these static types, namely the situations where we are effectively using an invariant type; it's just not possible (yet) to specify that the receiver has type C<invariant Bird> (and, of course, that dynamic check would fail in the current situation, but we can adjust the program to make it succeed).

I'm arguing that we should use a sound typing in the first place. This makes it harder to call c_of_bird.f, but it will be based on information which is actually known to hold, and we will succeed in actually obtaining that function object (rather than throwing at a caller-side check before we even get started invoking the function).

@eernstg
Copy link
Member

eernstg commented Apr 10, 2019

@tatumizer wrote:

c_of_bird.f(new Bird()) fails ..

The original example (with c_of_bird.f(new Duck())) fails because of the caller-side check at the evaluation of the getter f, so we never proceed far enough to even try to call that function. So changing the actual argument to something else should not make a difference. I still get the same error message, and I don't understand how you'd get an error referring to (dynamic) => dynamic.

So it seems that no matter what we pass .. the program fails

Right, because it's a caller-side check on the value of f that fails. It would also fail on dynamic d = c_of_bird.f;.

I hope we can get rid of caller-side checks entirely, we should just use a sound typing of f.

Logically, there are 3 possibilities for the compiler:

My proposal is to give c_of_bird.f a sound type (cf. dart-lang/sdk#57371 for details), such that there is no need to perform a caller-side check. The best sound type is Bird Function(Null) (or Bird Function(Never) when we get that).

With a sound typing, the evaluation of c_of_bird.f would never fail, but further usage of its value might now involve a downcast (the sound type is a proper supertype of the type that we currently give to c_of_bird.f).

"var X" idea .. provides the way to avoid an error in runtime

Right, everything concerned with var X is part of an effort to push for features that would allow developers to obtain statically safe typing of code using existential types. All these issues with "contravariant fields" are just examples of that kind of situation.

we succeed in obtaining the function, but still fail while trying to call it

That's exactly one of the possible scenarios:

class Bird {}
class Duck extends Bird {}

class C<X> {
  X Function(X) f = (X x)=>x;
}

main() {
  var c_of_duck = C<Duck>();
  C<Bird> c_of_bird = c_of_duck;

  // Assuming the typing of dart-lang/sdk#57371 (and hence no need for caller-side checks)
  // the following does not include a downcast, and there is no error at run time.
  Bird Function(Null) f = c_of_bird.f;

  // Assuming dart-lang/sdk#57370 (checking the actual argument against the actual
  // requirement for functions with types like that of `f`), the following
  // invocation does include a dynamic check, which succeeds at run time.
  print(c_of_bird.f(new Duck()));
  // This one is similar, but fails at the check on the actual argument.
  print(c_of_bird.f(new Bird()));

  // With an 'existential open' operation we can check and make it safe.
  // By the way, it's statically known that we will enter the body of this `if`,
  // because `f is Bird Function(var X)` is guaranteed to evaluate to true
  // because of the previously known type of `f``; but we will bind `X`
  // to a specific type, and that's needed for the `if (b is X) ...` test below.
  if (f is Bird Function(var X)) {
    Bird b = new Duck();
    Bird b2 = b is X ? f(b) : new Bird(); // Statically safe.
  }
}

The last few lines show that we can indeed use var X to reconstruct static knowledge about the types involved in this situation, and then we can also write statically safe code for dealing with it.

@eernstg
Copy link
Member

eernstg commented Apr 12, 2019

@tatumizer wrote:

why Duck=>Duck is not a subtype of Bird=>Bird

This is a standard property of function type subtyping: You can't tolerate a function accepting a Duck if you statically expect a function accepting a Bird (because your perfectly nice Ostrich will be rejected when it meets the test is Duck), so ... Function(Duck) is not a subtype of ... Function(Bird).

@eernstg
Copy link
Member

eernstg commented Apr 12, 2019

@munificent, @leafpetersen, @lrhn, please confirm that we have decided to use the static type of the receiver when we bind the values of type arguments in an static extension methods declaration.

Although this approach does not have the same amount of expressive power as the dynamic binding, we get performance characteristics that are easier to understand, and we can use a very sophisticated, well-tested, and well-understood device to compute the value of such type arguments statically, namely type inference.

@leafpetersen
Copy link
Member Author

@munificent, @leafpetersen, @lrhn, please confirm that we have decided to use the static type of the receiver when we bind the values of type arguments in an static extension methods declaration.

LGTM

@leafpetersen
Copy link
Member Author

This was decided in favor of the static type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants