Closed tolotrasamuel closed 2 weeks ago
Summary: The user is encountering a runtime error where a function accepting an int
argument is not considered a subtype of a function accepting an Object
argument. This occurs despite the user's expectation that int
should be a subtype of Object
.
The error message is correct: void Function(int)
is indeed not a subtype of void Function(Object)
. Just think about substitutability: We can only say that T
is a subtype of S
when an entity of type T
can be used in a context where an S
is expected.
So assume that we expect to use a function f
of type void Function(Object)
, but we are actually working with a function of type void Function(int)
. Based on the expected type, we can call it as f(true)
because true
has a type which is assignable to Object
. In reality, we can't do this because the actual function insists that its actual argument must have type int
.
In general, this is what it means when it is stated that "function parameter types are contravariant": In order to obtain a subtype, we must replace the parameter type by a supertype (in particular, void Function(int)
is a subtype of void Function(Object)
, not the other way around).
The culprit here is that you have declared a "non-covariant member".
class DropdownField<V> extends ModelField {
final void Function(V value) setValue; // <-- Non-covariant, that is, dangerous!
DropdownField({required this.setValue});
}
This member is non-covariant in the sense that its type contains a type parameter declared by the enclosing class in a contravariant (that is, non-covariant) position, namely as the function type's parameter type.
We could emit a warning when this occurs (see https://github.com/dart-lang/sdk/issues/59050), but this hasn't yet been implemented. (You can vote for that issue in order to support that it gets implemented.)
One step you could take in order to reduce the danger is to make sure that the non-covariant member is never used from a client, it must always be used as a member of this
. You can't directly enforce this, but we can make it private and call it via a method:
class DropdownField<V> extends ModelField {
final void Function(V value) _setValue;
void setValue(V value) => this._setValue(value);
DropdownField({required void Function(V) setValue}): _setValue = setValue;
}
This is sufficient to make your example run without run-time failures:
class ModelField {}
class DropdownField<V> extends ModelField {
final void Function(V value) _setValue;
void setValue(V value) => this._setValue(value);
final List<V> options;
DropdownField({
required setValue,
required this.options,
}) : _setValue = setValue;
}
void _buildDropdownField<V>(DropdownField<V> field) {
final options = field.options;
final data = options.first;
field.setValue(data); // error Here
}
void _renderField(ModelField field) {
/// from server
// if field is StringField, else if field is DateField, ... else
if (field is DropdownField<Object>) {
// same thing occurs with DropdownField<dynamic>
_buildDropdownField(field);
}
}
void main() {
final field =
DropdownField<int>(setValue: (value) => print(value), options: [1, 2, 3]);
_renderField(field);
}
The reason why this helps is that the non-covariant member is never accessed under an unsafe type (like void Function(Object)
when it's actually a void Function(int)
), and hence there's no failing type check at run time. It is accessed using the type void Function(V)
at a location where V
is in scope, which is a precise and safe typing. The crucial point is that we're working with a function object, and this will cause a run-time failure if the given function object doesn't have the statically known type.
In contrast, clients will now call the method setValue
, which is done directly; that is, we will just call the method, we will not tear off the method in order to get a function object, and then call that function object.
It will be checked that the actual argument has the required type (int
, or whatever it is), but this is a requirement that the given code does satisfy.
I'll close this issue because it's all working as specified. (But I'd very much like to have the lint that warns developers against declaring non-covariant members in the first place ;-).
Thank you!
The Static check would be so useful if implemented.
Without your clear explanation, I would never guess that the solution is to make a private method wrapping the setValue
.
Sorry to bump into this again, but the prescribed solutions feels like a workaround.
Since in reality I have several method belonging to the dropdown such as selectedItemBuilder
and I find myself wrapping them all in private methods.
Is there an actual proper solution to this issue? Is there a best practice that I did not follow ? Is it that my code was badly written overall and needs deeper refactoring or this is an actual Dart limitation without any proper solution ? @eernstg
I found that simply adding the experimental flag inout
fixes the issue.
keyword inout
Thus: class DropdownField
What are the potential issue of this keyword ? Would I lose type safety in some edge cases ?
the prescribed solution feels like a workaround
It is! William Cook described the underlying issue about variance and type safety 35 years ago, and various approaches have been taken.
The simplest approach is to say that every type parameter is invariant. This is safe. It means that List<int>
and List<num>
are unrelated types, so you'll just get a compile-time error in cases like List<num> xs = <int>[1, 2, 3];
. Java introduced wildcarded types, which will allow you to have subtype relationships like "List<? extends Integer>
is a subtype of List<? extends Number>
". With wildcards, many methods cannot be invoked because they may fail at run time. For instance, you can't add an element to an object whose static type is List<? extends Integer>
. And there are several other approaches.
When Dart was created, a new approach was taken: Every class type parameter is considered to be covariant, and there will be a run-time type check in every case where the operation isn't guaranteed to succeed at run time. This means that we can have assignments like List<num> xs = <int>[1, 2, 3];
, and we can also do xs.add(4)
. However, we can even do xs.add(1.5)
, which will throw at run time, so there is a trade-off: Some operations are only checked at run time, but we get a substantial amount of extra flexibility in return. Note that this is exactly the same approach that several languages have used with arrays: The ArrayStoreException
in Java is the same error as the one we get at xs.add(1.5)
.
The situation where an instance variable has a type like void Function(V)
where V
is a type parameter of the enclosing class is a corner case where we're paying an extra high price for this flexibility. In particular, any evaluation of field.setValue
in a situation where field
has type DropdownField<V>
where V
has a different value than the actual type argument of that field
will throw. You don't even get to call the function, it throws just because you're "looking at it", that is, because you're evaluating the expression field.setValue
.
So we're transforming the instance variable to a method in order to avoid that we evaluate the variable in a situation where the statically known V
is a supertype of the actual V
, which is true for your example when it throws.
If you're using inout V
then the compiler will treat that type variable as invariant, which means that you will get a compile-time error whenever you try to assign a DropdownField<V1>
to a variable/parameter whose type is DropdownField<V2>
where V1
and V2
are different types.
So that's safe, but it takes away your flexibility. In particular, field is DropdownField<Object>
will evaluate to false when the run-time type of field
is DropdownField<int>
. You may need to make large-scale changes to your software in order to start using this strict approach. So that's the reason why I mentioned the other approach first: Wrapping the non-covariant member in a method such that it's never used directly by clients. With that approach, field is DropdownField<Object>
will evaluate to true, and your example will work.
If you wish to use the strict approach now (where we don't yet have dart-lang/language#524 -- yes, please vote for it!) then you can actually emulate invariance:
class ModelField {}
typedef Inv<X> = X Function(X);
typedef DropdownField<V> = _DropdownField<V, Inv<V>>;
class _DropdownField<V, Invariance extends Inv<V>> extends ModelField {
final void Function(V value) setValue;
final List<V> options;
_DropdownField({
required this.setValue,
required this.options,
});
}
void _buildDropdownField<V>(DropdownField<V> field) {
final options = field.options;
final data = options.first;
field.setValue(data); // error Here
}
void _renderField(ModelField field) {
/// from server
// if field is StringField, else if field is DateField, ... else
if (field is DropdownField<Object>) {
// same thing occurs with DropdownField<dynamic>
print("Choosing the `Object` path.");
_buildDropdownField(field);
} else if (field is DropdownField<int>) {
print("Choosing the `int` path.");
_buildDropdownField(field);
}
}
void main() {
final field =
DropdownField<int>(setValue: (value) => print(value), options: [1, 2, 3]);
_renderField(field);
}
This means that you actually emulate the declaration class DropdownField<inout V> extends ModelField {...}
, without relying on any new features in Dart.
However, as you can see, the fact that you do not have subtype relationships like "DropdownField<int>
is a subtype of DropdownField<Object>
any more may require changes in client code (like _renderField
), and this may be a very substantial change to your software. So your mileage may definitely vary. ;-)
So in summary, there two ways to simulate what inout
does.
First is the wrap the field with private
method. However, with this workaround, I find myself wrapping several callback fields in private methods and once there are several of them, it becomes so tedious and makes the code hard to maintain and so unreadable.
The second option is typedef Inv<X> = X Function(X);
does but it not simulate inout
behavior. Instead, it produces a different behavior (the one where we do not have subtype relationships) .
That is why I chose to go with inout
route, because it is the cleanest approach so far. However, it does not work on the Flutter Web with latest stable Dart 3.5.4
So in summary, there two ways to simulate what
inout
does.
First, I'm assuming that we're talking about the actual feature (not the incomplete implementation which is available using --enable-experiment=variance
).
The approach that uses a type alias of the form typedef TheClass<X> = _TheClass<X, Inv<X>>;
will faithfully emulate the static analysis and dynamic semantics of an invariant type parameter. That is, it emulates class TheClass<inout X> ...
.
If needed, this approach can be generalized to handle multiple type parameters, some of which are invariant.
In any case, we're protecting the original class (_TheClass
) from direct access by making it private, which means that you can violate the discipline in the same library. So you just need to entrust yourself not to do that. ;-)
The experiment variance
will give you the static analysis, but not the dynamic semantics (the dynamic semantics will just ignore inout
and continue to treat the type variable declared as inout X
as if it had been declared without inout
).
This emulation technique will work for class hierarchies that are declared in the same library, but you can't make the underlying class private if you must have subtypes/subclasses in different libraries (because they need to be subclasses of _TheClass<X, Invariance>
, it isn't possible to get the correct typing if you try to create a subclass of TheClass<X>
).
In any case, this will give you the real thing in a rather general way.
The other approach that I mentioned (making the non-covariant member private and wrapping it by a public method) does not emulate invariance. What it does is actually to use privacy to help enforcing that the non-covariant member is never accessed on any other receiver than this
. (Again, code in the same library can violate this constraint, but we can strive to enforce the constraint manually.)
Here's an example which demonstrates that the type parameter isn't invariant:
class A<X> {
final void Function(X) _fun;
A(this._fun);
void fun(X x) => _fun(x);
}
void main() {
A<num> a = A<int>((i) => print(i)); // OK.
}
If the type parameter X
had been invariant then A<num>
would not have been a supertype of A<int>
and then we would have had a compile-time error at the declaration of a
in main
. Also, if we had used A<int>((i) => print(i)) as dynamic
to avoid the compile-time error, we would still get a type failure at run time.
But we don't get that, because A<num>
is a supertype of A<int>
, because X
is covariant, not invariant.
This is actually useful because it means that the class A
is more "user-friendly" than a similar class whose type argument is invariant. So you might actually prefer to use this approach even though (or perhaps because) it doesn't change the variance of any type parameters. On the other hand, there's no help from the analyzer/compiler when it comes to maintaining the discipline: You can only access _fun
when the receiver is this
.
class A<X> {
final void Function(X) _fun;
A(this._fun);
void fun(X x) => _fun(x);
}
void main() {
A<num> a = A<int>((i) => print(i));
a._fun; // Throws!
}
I checked all my generics are all OK. Inheritance seems ok. Type inference seems ok. No compiler error. I don't have any hard type casting. Everything is seems strongly typed.
But when I run:
How can
int
not be a subtype ofObject
???I am so confused (or Dart is) I hope it's me. But this error occurs only at run time.