-
Notifications
You must be signed in to change notification settings - Fork 205
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
Comments
I'd actually expect the very last occurrence of |
Fixed, thanks. |
Here's a reason for wanting the type argument passed to // 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)
} |
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. |
It would be worth looking into whether this comes up in C#, and if so how they deal with it. |
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 This is not new for For 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. |
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 But we already have a similar situation with a type alias: The type parameter This means that the parameter type 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 |
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 Alternatively, this can be seen as an argument for using the straightforward static interpretation. |
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). |
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 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<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 |
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. |
For what it's worth, here is my current take on this:
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. |
Can you give a straw man for what that might look like and how it solves the |
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";
}
}
} |
@munificent wrote:
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 Alternatively, still considering what we could do when type patterns are matched statically, we could allow invocations on receivers of type 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
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 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). |
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 Alternatively, if we match the type argument dynamically then the receiver type is 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. |
@tatumizer wrote:
It's all about the treatment of the invocation 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 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 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 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 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 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 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 This means that the statically known return type of the invocation would be 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 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. |
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:
The object However, it is incorrect to assume that But we do not have We could then assign a sound type to the expression However, we do not do that currently, we actually assign the type I'm arguing that we should use a sound typing in the first place. This makes it harder to call |
@tatumizer wrote:
The original example (with
Right, because it's a caller-side check on the value of I hope we can get rid of caller-side checks entirely, we should just use a sound typing of
My proposal is to give With a sound typing, the evaluation of
Right, everything concerned with
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 |
@tatumizer wrote:
This is a standard property of function type subtyping: You can't tolerate a function accepting a |
@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. |
LGTM |
This was decided in favor of the static type. |
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:
And suppose I defined an extension as follows:
Now consider the following code:
If extension methods capture the static type of the receiver only, then the runtime (reified) type of
r2
andr3
isRef<num>
, and:If extension methods capture the dynamic type of the received, then the runtime (reified) type of
r2
andr3
isRef<int>
, and: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:
If we treat extension methods as capturing the static type, then the following de-sugaring of the test code is valid:
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.
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.
The text was updated successfully, but these errors were encountered: