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

casting with as doesn't work on Lists #49661

Closed
DetachHead opened this issue Aug 15, 2022 · 7 comments
Closed

casting with as doesn't work on Lists #49661

DetachHead opened this issue Aug 15, 2022 · 7 comments
Labels
closed-as-intended Closed as the reported issue is expected behavior type-question A question about expected behavior or functionality

Comments

@DetachHead
Copy link

void main() {
  foo([1]);
}

void foo(List<Object> value) {
  value as List<int>;
}
TypeError: Instance of 'JSArray<Object>': type 'JSArray<Object>' is not a subtype of type 'List<int>'Error: TypeError: Instance of 'JSArray<Object>': type 'JSArray<Object>' is not a subtype of type 'List<int>'

at first i thought this was because List is invariant, but it turns out it's actually covariant (dart-lang/language#213), so i have no idea why this doesn't work

@mraleph
Copy link
Member

mraleph commented Aug 15, 2022

That's because [1] is an instance of List<Object> (just as error message tells you). And List<Object> is not a subtype of List<int> (meaning that you can't put an instance of List<Object> into a place where List<int> is expected). Covariance works the other way: List<int> is a subtype of List<Object> (meaning that you can put an instance of List<int> into a place where List<Object> is expected).

Now the question is: why is [1] inferred as <Object>[1]? That's because context provides the type argument.

Try:

final x = [1];
foo(x);

or

foo(<int>[1]);

instead. Here you will get an instance of List<int> and you cast will work.

@mraleph mraleph closed this as completed Aug 15, 2022
@mraleph mraleph added type-question A question about expected behavior or functionality closed-as-intended Closed as the reported issue is expected behavior labels Aug 15, 2022
@DetachHead
Copy link
Author

@mraleph why do other types behave differently then?

void main() {
  Object a = 1;
  print(a.runtimeType); // int
  a as int; // int
  List<Object> b = [1,2];
  print(b.runtimeType); // JSArray<Object>
  b as List<int>; // error
}

why does a become an instance of int but b doesn't become a JSArray<int>? this behavior seems extremely inconsistent

@KotlinIsland
Copy link

If Lists were immutable then it would be safe to make List<Object> a = [1, 2, 3] a List<int> at runtime, but:

  • They are mutable, so that would be a bad idea.
  • That is inconsistent with every other language and could result in confusion.

@DetachHead
Copy link
Author

They are mutable, so that would be a bad idea.

that's unrelated imo. that's List's fault for being covariant and also mutable. here's an example of the same issue that doesn't use List:

class Foo<T> {
  Foo(this._value);
  final T _value;
  T getValue() => _value;
}

void main() {
  Object a = 1;
  print(a.runtimeType); // int
  a as int; // int
  Foo<Object> b = Foo(1);
  print(b.runtimeType); // Foo<Object>
  b as Foo<int>; // error
}

That is inconsistent with every other language and could result in confusion.

what other languages do this? imo the current behavior is very confusing. i saw lots of stackoverflow threads where nobody understood the problem and were providing convoluted workarounds without explaining the issue.

if 1 is an int at runtime, unaffected by the Object type annotation on the left, then Foo(1) should be a Foo<int> at runtime, unaffected by the Foo<int> type annotation on the left.

@mraleph
Copy link
Member

mraleph commented Aug 16, 2022

@mraleph why do other types behave differently then?

They don't behave differently. There are two different things: there is type of the object itself (e.g. what class an object is an instance of) and then there is type arguments supplied to that type.

Imagine you have a generic class:

class Foo<T> {
  // ...
}

When you write code which uses this class you can choose to omit T, e.g. you can write Foo() which is equivalent to asking compiler to fill it for you somehow (via type inference). One thing that this algorithm does is to look at the context of the expression (how it is used), so for the case Foo<Object> c = Foo(1) it would infer that you meant to write Foo<Object> c = Foo<Object>(1);. The context takes precedence over types of the parameters, so final c = Foo(1); gets inferred as Foo<int>(1), but providing context would change the inference. This choice was made during Dart 1 -> Dart 2 migration (which moved the language from optional type system to static type system), I am not entirely sure why but my guess is that it was done to minimise the breakage and ease the migration (/cc @eernstg @leafpetersen who would have more context).

Type inference fills omitted type arguments, but it does not change the type of the object being constructed (e.g. if you write Bar bar = Foo() it's not going to change Foo() to Bar().

Now this explains why Foo(1) ended up meaning Foo<Object>(1) in your example. As for why Foo<Object> is not a Foo<int> the explanation is pretty simple, if it was the type system would be unsound without additional runtime checking:

final Foo<Object> foo1 = Foo(1);
final Foo<int> foo2 = foo1 as Foo<int>;
final int v = foo2.value;
foo2.value = "wat?";
final int u = foo2.value; // huh? static type says it is okay, but runtime types don't match

i saw lots of stackoverflow threads where nobody understood the problem and were providing convoluted workarounds without explaining the issue.

I don't think the linked thread has anything to do with this particular behaviour. It's a slightly different issue with a lack of horizontal type inference. Back before 2.12 the code below:

// @dart=2.9

class StreamBuilder<T> {
  final Stream<T> stream;
  final void Function(T) callback;
  
  StreamBuilder({this.stream, this.callback}) {
    print(callback.runtimeType);
  }
}

void main() {
  final foo = Stream<String>.fromIterable(["A", "B", "C"]);
  StreamBuilder(stream: foo, callback: (val) {
    print(val.length);
  });
}

The expectation is that callback is void Function(String), but it would actually (surprisingly) print

(dynamic) => Null

Meaning that val in callback was inferred to have type dynamic. This happens because Dart's type inference did not attempt to flow type information horizontally between arguments. Developer expected that val is inferred to String because the type of stream is given, but this does not happen. This is now fixed by @stereotype441 (dart-lang/language#731) and in 2.18 and newer the code will infer val to be String.

Applying this to the code in question would mean that snapshot has type dynamic and snapshot.data.documents.map is all dynamic calls producing dynamic values. Consequently map produces List<dynamic> because compiler does not even know that map has a type argument which could be inferred from its argument.

(The same code would fail to compile between 2.12 and 2.18 because the inference was changed to infer val as Object? instead so you get compile time error on val.length).

In general when trying to answer a question "why some type check fails" people should systematically inspect results of type inference (e.g. in IDE) and then the answer will become clear.

@DetachHead
Copy link
Author

thanks for writing this up. though i still think this behavior is strange when it comes to generics as it seems to limit the casting functionality by needlessly widening the type at runtime

@eernstg
Copy link
Member

eernstg commented Aug 30, 2022

Ah, I forgot the cc to me on this issue. I do have a couple of comments on the topic, so here we go.

The context takes precedence over types of the parameters, so final c = Foo(1);
gets inferred as Foo<int>(1), but providing context would change the inference.

History is certainly one reason: It's a breaking change to do something other than what we've done so far; changes to type inference are rather subtle to detect; and they may cause run-time errors a long time after the situation where the behavior of the program changed slightly. So we need to tread carefully when we change anything in this area.

We have some proposals about sound variance (such as dart-lang/language#524 and dart-lang/language#753), but they haven't (yet) been accepted into the language.

The underlying soundness issue has been known at least since the late 1970'ies (of course, it's not that hard to discover), and they were very well-known when Dart was designed to have dynamically checked covariance back in 2010 or so (several years before I joined Google).

So the choice to use dynamic checks rather than using some rules that are statically safe was deliberate. It's a trade-off between (1) simple and flexible rules, and (2) type safety. We get more of (1) by postponing the enforcement of (2) to run time.

I've been profoundly surprised that we haven't had more pressure in the direction of switching to a ruleset which is fully statically checked, but it's really not a topic that comes up very often in practice.

Note that dynamically checked covariance is well-known from mainstream languages: Both Java and C# use dynamically checked covariance for arrays.

Anyway, this means that we can have the following situation:

void main() {
  List<num> xs = [1];
  xs.add(1.5);
}

In this situation, it would be perfectly OK, locally, to have a variable like xs whose static type is List<num> and whose dynamic type is List<int>: List has a type parameter which is covariant (until we get dart-lang/language#524 etc. that's the only kind we have), so List<int> is a subtype of List<num>, and hence there is no problem in assigning a value of type List<int> to a variable of type List<num>.

So we could infer the type argument of the newly created list using a bottom-up computation (yielding List<int>, so the list literal is inferred as <int>[1]).

However, what we really want is invariance, because newly created lists tend to be modified, and this means that we will encounter run-time errors if we use the permission to let the variable of type List<num> refer to an object with type List<int>. We actually want a variable of type List<num> to refer to an instance of List<num> (not List<int> or any other proper subtype), because this means that there will not be any run-time type errors when we use members whose signature has a contravariant occurrence of the type argument (such as the method add).

In particular, if the variable actually refers to an object of type List<num> then there is no problem with .add(1.5), because 1.5 has type num—but we will get the run-time type error if the list is a List<int>.

So we're combining two things:

  • We are using dynamically checked covariance in general (where a List<T> is safe if we just read it and don't modify it, even if it's accessed via a variable of type List<S> where S is a supertype of T).
  • But the context type is used during type inference, in order to maintain invariance-in-practice for a lot of newly created objects.

Those two things conspire amazingly to ensure that almost everything "just works" in practice.

That said, I would still prefer to have at least the option to express variance in a statically checked manner, like dart-lang/language#524 and dart-lang/language#753.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
closed-as-intended Closed as the reported issue is expected behavior type-question A question about expected behavior or functionality
Projects
None yet
Development

No branches or pull requests

4 participants