-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
dart vm can't upcasting generic type of function getter #55427
Comments
Check out this issue about "contravariant members". The core issue here is that This means that Dart uses dynamic type checks to enforce soundness of the heap (that is, roughly: no variable has a value that contradicts its declared type), and these "contravariant members" are particularly prone to cause failures in such a run-time type check. (You can vote for dart-lang/linter#4111 if you wish to help getting support for detecting when a member is contravariant in this sense, such that you can make sure you don't have any of them.) Here is a minimal example showing such a run-time failure: class A<X> {
void Function(X) fun;
A(this.fun);
}
void main() {
A<num> a = A<int>((int i) { print(i.isEven); });
a.fun; // Throws. We don't even have to call it!
} However, if you insist that you want to have a contravariant member then you can still make it statically type safe. This means that we get a compile-time error at the location where the situation is created that causes the covariance problem to arise. This approach relies on a feature which is still experimental, so you need to provide an option when running tools: // Use option `--enable-experiment=variance`.
void main() {
A<Object?> a = B<Data>(); // Compile-time error!
a.done(Data());
}
typedef Fn<T> = void Function(T a);
abstract class A<inout T> {
Fn<T> get done;
}
class B<inout T> extends A<T> {
B();
@override
Fn<T> get done => (T a) => print(a);
}
class Data {} The compile-time error in You can change the declaration to However, as you can see, you also have to give up on the ability to forget that the actual type argument is You mention that the following variant is more forgiving: void main() {
A<Object?> a = B<Data>();
a.done(Data()); // No compile-time error, succeeds at run time.
a.done(false); // No compile-time error, throws at run time.
}
typedef Fn<T> = void Function(T a);
abstract class A<T> {
void done(T a);
}
class B<T> extends A<T> {
B();
@override
void done(T a) => print(a);
}
class Data {} The reason why this variant will run successfully (until we reach In particular, we can check dynamically that the argument passed to At In contrast, the expression So you never take the next step and try to call that function object. So it doesn't help that you might pass an argument like Note that you can combine the two approaches if it is important for you to use separate function objects rather than instance methods: void main() {
A<Object?> a = B<Data>();
a.done(Data());
}
typedef Fn<T> = void Function(T a);
abstract class A<T> {
Fn<T> get _done;
void done(T a) => _done(a);
}
class B<T> extends A<T> {
B();
@override
Fn<T> get _done => (T a) => print(a);
}
class Data {} The invocation of I'll close this issue because it is all working as specified. |
The examples in this issue will all give rise to type checks at run time. Even the examples that do not give rise to a run-time error will perform a check, and it is very easy to come up with a modified version of the example where there are no compile-time errors, but we still get a type error at run time: void main() {
A<Object?> a = B<Data>();
a.done(Data2()); // Throws.
}
typedef Fn<T> = void Function(T a);
abstract class A<T> {
Fn<T> get _done;
void done(T a) => _done(a);
}
class B<T> extends A<T> {
B();
@override
Fn<T> get _done => (T a) => print(a);
}
class Data {}
class Data2 {} It is actually possible to use a mechanism known as an "existential open" operation to establish all the needed information at compile time (which means that there will not be any type errors at run time, even if you try out one of the cases like the one that fails because it uses a There will be some type casts (because we don't have an actual language mechanism to support the existential open operation during type checking), but they will never fail because they rely on relationships that are guaranteed to hold for the existential open operation. It's more complex, so you may wish to do it because you really want to play safe, or you may wish to continue using code that has some dynamic type checks because it is simpler (and, perhaps, you don't see any run-time type errors in practice). Here's the version that uses an existential open operation (which basically means that we're able to discover which type argument import 'package:typer/typer.dart';
void main() {
A<Object?> a = B<Data>();
// Every time we use `done` on a receiver of type `A` (or any
// subtype of `A`) we must "open" it first.
// This object knows the type argument of `a`, which enables an
// approach that never gives rise to any dynamic type checks which
// are able to fail.
var typer = a.typerOfT;
// The `callWith` invocation provides access to the type argument
// of the given instance of `A<...>` under the name `T`, no matter
// which value it has.
typer.callWith(<T>() {
// At this point it is guaranteed that `a` is an `A<T>`. Note
// that it is not an `A<S>` where `S` is some unknown subtype of
// `T`, it is actually an "A<exactly T>".
var openedA = a as A<T>; // Tell the type system that it is so.
if (Typer<Data>() <= typer) {
// At this point is is guaranteed that `Data` is a subtype
// of `T`. Tell the type system that `Data() is T`.
var data = Data() as T;
// This is now completely statically type checked: We are
// passing a `T` to the `done` of an `A<T>`.
openedA.done(data);
}
});
}
typedef Fn<T> = void Function(T a);
abstract class A<T> {
Fn<T> get done;
Typer<T> get typerOfT => Typer(); // Enable "existential open" on this type.
}
class B<T> extends A<T> {
B();
@override
Fn<T> get done => (T a) => print(a);
}
class Data {} If we create a |
I can explain my use case, and maybe there is a better way we can do this without fooling the compiler. This is for Flutter's https://github.com/flutter/flutter/blob/125543505d2608afccbbd1486bd3380d7c388893/packages/flutter/lib/src/widgets/routes.dart#L1119 The class is quite complex so i will simplify to this I have a typedef Fn<T> = void Function(T a);
class ModalRoute<T> {
List<PopEntry<T>> popEntries = <PopEntry<T>>[];
void add(PopEntry<T> entry) => popEntries.add(entry);
void pop<T>(T result) {
for(entry in popEntries) {
entry?.onPopInvoked(result);
}
}
}
class PopEntry<T> {
FN<T> onPopInvoked;
} This is all good if the type involve are all the same. e.g. ModalRoute<int> route = ModalRoute<int>();
route.add(PopEntry<int>());
route.pop(1); but what I want to support is to loosen the type on PopEntry ModalRoute<int> route = ModalRoute<int>();
route.add(PopEntry<Object?>()); // This will throw compile error
route.pop(1); The only way to get pass this is to hold the list as Object? class ModalRoute<T> {
List<PopEntry<Object?>> popEntries = <PopEntry<Object?>>[];
void add(PopEntry<Object?> entry) => popEntries.add(entry);
} but then it will crashes the route.pop(1); in runtime. Thus, I use the workaround to change PopEntry
|
That's an interesting case, @chuntai! This is a long response, but the situation is rather complex, even with that small excerpt of the code that you have shown. On the other hand, I think it's worth diving into the details because they bring out relationships that are useful to think about and deal with in a careful manner. First, note that the very core of the example introduces potential run-time type errors with a program that has no compile-time errors or warnings: typedef Fn<T> = void Function(T a);
class PopEntry<T> {
Fn<T>? onPopInvoked;
}
void main() {
PopEntry<num> entry = PopEntry<int>()..onPopInvoked = (_) {};
entry.onPopInvoked; // Throws.
} The point is that there is a "contravariant member" in the class If we want to make contravariant typing relationships safe then we need to take several steps. First, (We must use invariance rather than contravariance because So here is an invariant version of typedef Fn<T> = void Function(T a);
class PopEntry<inout T> {
Fn<T>? onPopInvoked;
PopEntry([this.onPopInvoked]);
} In order to avoid relying on the experiment, we can emulate invariance as follows: typedef Inv<X> = X Function(X);
typedef PopEntry<T> = _PopEntry<T, Inv<T>>;
class _PopEntry<T, Invariance extends Inv<T>> {
Fn<T>? onPopInvoked;
_PopEntry([this.onPopInvoked]);
} We need to have a supertype, sealed class PopEntryBase {
Typer<Object?> get typerOfT;
}
typedef Inv<X> = X Function(X);
typedef PopEntry<T> = _PopEntry<T, Inv<T>>;
class _PopEntry<T, Invariance extends Inv<T>> implements PopEntryBase {
Fn<T>? onPopInvoked;
_PopEntry([this.onPopInvoked]);
@override
Typer<T> get typerOfT => Typer();
} We will then handle the pop entries as a Next, We cannot express this constraint in the signature of the function (we could do it using an upper bound on a type parameter, but Dart doesn't have such lower bounds, at least not yet ;-). So we will perform this check at run time. Finally, there is an extension method which will perform a special case of the same check at compile time, The resulting program is here: import 'package:typer/typer.dart';
typedef Fn<T> = void Function(T a);
class ModalRoute<T> {
final List<PopEntryBase> _popEntries = [];
/// Add an [entry].
///
/// This method accepts an [entry] which can be a [PopEntry<S>]
/// for any `S`. It is then checked at run time that it is
/// for an `S` which is a supertype of [T], which means that
/// it is guaranteed that an actual argument of type [T] can be
/// passed to the function of that [entry].
///
/// We cannot express this constraint in the parameter type without
/// lower bounds (`void add<S super T>(PopEntry<S> entry)`), and
/// Dart does not have those (yet).
void add(PopEntryBase entry) {
// Ensure that `entry` is a `PopEntry<S>` such that
// `T <: S`. This subtype relation has "the wrong direction",
// that is, it can not be tested using any expression of the
// form `e is U`.
var typer = entry.typerOfT; // `typer` reifies said type `S`.
if (Typer<T>() <= typer) {
typer.callWith(<S>() {
// This typing is guaranteed, tell the type system about it.
entry as PopEntry<S>;
_popEntries.add(entry);
});
} else {
// Error handling: Can not add `entry` to `_popEntries`,
// we wouldn't be able to call its function with an
// argument of type `T`, as done in `pop` below.
}
}
void pop(T result) {
for (var entry in _popEntries) {
var typer = entry.typerOfT;
typer.callWith(<ActualT>() {
// These typings are guaranteed, based on `typer`
// and based on the checks in `add`.
entry as PopEntry<ActualT>;
result as ActualT;
// This invocation is statically type safe.
entry.onPopInvoked?.call(result);
});
}
}
}
extension ModalRouteExtension<T> on ModalRoute<T> {
/// Add an [entry] in a statically checked manner.
///
/// This method accepts an [entry] which is a [PopEntry<T>].
/// Note that [PopEntry] is invariant in [T], and this implies
/// that an actual argument of type [T] can safely be passed
/// to the function of that [entry].
///
/// Note that `this` can have the run-time type [ModalRoute<S>]
/// for any `S` which is a subtype of [T], but [entry] will have
/// run time type [PopEntry<T>] (not just [PopEntry<S>] where `S`
/// is some unknown subtype of [T]).
void typedAdd(PopEntry<T> entry) => add(entry);
}
sealed class PopEntryBase {
Typer<Object?> get typerOfT;
}
typedef Inv<X> = X Function(X);
typedef PopEntry<T> = _PopEntry<T, Inv<T>>;
class _PopEntry<T, Invariance extends Inv<T>> implements PopEntryBase {
Fn<T>? onPopInvoked;
_PopEntry([this.onPopInvoked]);
@override
Typer<T> get typerOfT => Typer();
}
void main() {
var route = ModalRoute<int>();
route.add(PopEntry<Object?>((o) { print('`Object?` entry: $o'); }));
route.typedAdd(PopEntry<int>((i) { print('`int` entry: $i'); }));
route.pop(1);
route.pop(2);
} Of course, it is still possible to encounter a run-time type error simply because void main() {
...
(route as ModalRoute<Object>).pop('Hello!'); // Throws.
} This would fail at run time (just like |
@eernstg wow thanks for the detailed answer, really appreciate. There is a lot of new things I learned about dart for your answer. I have some question on the terminology. What are contravariant and invariant in the context of dart? These two words are new to me. I assume covariant means transform method input type to subclass type from the uses of key word |
Thanks for the kind words, @chuntai! Covariant is just like 'increasing' for functions ("if a ≤ b then f(a) ≤ f(b)"), with respect to types (that is, "if Contravariant is just like 'decreasing' for functions ("if a ≤ b then f(a) ≥ f(b)"), with respect to types (that is, "if Finally, invariant means that different inputs yield incomparable outputs. This can't immediately be modeled by a simple mathematical function (assuming that it's mapping real numbers to real numbers, or something like that). But we can state what it is all about as with the other variances: "Even if we know that The keyword class A {
void foo(covariant Object o) {}
}
class B extends A {
void foo(String s) {}
} So this parameter type is void main() {
A a = B();
a.foo(true); // OK, according to the declaration of `foo` in `A`.
// But it actually throws, because it's a `B`, and it wants a `String`.
} Because of this danger, the types can't be specified as shown if we do not have the modifier In any case, the work |
I am not sure if this is expected or not.
The following code will crash the dart vm
If you run this code, it crashes with
If I convert the function getter to a instance method, it will work.
The text was updated successfully, but these errors were encountered: