Skip to content

More complicated context type for ?? #2259

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

Open
lrhn opened this issue May 25, 2022 · 2 comments
Open

More complicated context type for ?? #2259

lrhn opened this issue May 25, 2022 · 2 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@lrhn
Copy link
Member

lrhn commented May 25, 2022

Background

The Dart ?? operator has the following type inference behavior:

  • Let e be e1 ?? e2 with context type P.

  • If P is ?, the context type of e1 is ?, otherwise the context type of e1 is P?.

  • Let T1 be the static type of e1. Let T'1 be NONNULL(T1).

  • If P is ?, the context type of e2 is T'1, otherwise the context type of e2 is P.

  • Let T2 be the static type of e2.

  • The static type of e is UP(T'1, T2).

The use of the first operand's (non-null) type as the context type of the second operand, when there is no context type from the outside, is a trick to enable an existing code pattern to keep working:

List<int>? maybeIntList = ...;
var intList = maybeIntList ?? [];

This is not a new thing, it has been like this since Dart 2.0 when the new stronger type system was introduced, and with it the concept of "context type". Without that special rule, the [] would infer a type of List<dynamic> and the type of intList would be List<dynamic>, which nobody wants.

There are situations where the trick does not apply, and where you can still get List<dynamic>, namely when there is a context type, but it doesn't constrain the element type of the [] expression. Examples include:

  • Object intList = maybeIntList ?? [];
  • T? firstOrNull<T>(Iterable<T> values) => values.isEmpty ? null : values.first;
    var firstInt = firstOrNull(maybeIntList ?? []);
  • for (var i in maybeIntList ?? []) { print(i.toRadixxString(16)); }

In each case, there is a real context type (it's not ?). In the first example the context type is Object, and in the last two, the context type is Iterable<?>. Because of that, the [] is inferred as <dynamic>[], completely independently of the type of maybeIntList, even though the context type is no more useful than ? would be. This is particularly dangerous because the variable i in the for/in gets type dynamic which can hide other errors (like the typo above). Using const [] instead makes no difference, like it would for a context type of Iterable<TypeVariable>.

In some cases, this behavior is perhaps inevitable, in others, it seems there is available information that can actually be used to improve the result.

Proposed change

Where the context type is Iterable<?> and the static type of the first operand is List<int>, it seems like we are still in a situation where the context type is not sufficient to find the desired type of the [] list literal, it's no better than a context type of ?, and it's visible in the type that the user has not supplied sufficient information, because there is a ? in the Iterable<?> context type.

So, we could try to see if the static type of the first operand provides a useful hint for that ?. We do that by changing the type inference as follows (bearing in mind that I am not an expert on Dart type inference, so I may very well be using the wrong words):

  • Let e be e1 ?? e2 with context type P.

  • If P is ?, the context type of e1 is ?, otherwise the context type of e1 is P?.

  • Let T1 be the static type of e1. Let T'1 be NONNULL(T1).

  • Let S be the result of "solving P wrt ? using T'1".

  • The context type of e2 is S.

  • Let T2 be the static type of e2. It is not a compile-time error if T2 is not assignable to S. It is a compile-time error if T2 is not assignable to the greatest closure of P wrt. ?.

  • The static type of e is UP(T'1, T2).

The hand-wavy "solve P wrt. ? using T" is intended to fill in the ?s in P with corresponding types from T where possible, but keep the ? where not. It should work roughly like when we have a generic function type with a parameter, void Function<T>(Iterable<T>) and an actual argument of type List<int> and we try to solve for T (and hopefully getting int), only we solve for each individual ? in the type schema, and if we can't find a value, we keep the ?, so the result is again a type schema.

The goal is that for maybeIntList ?? []:

  • We get the current result if the context type is ?, because the solving that against the non-null static type of maybeIntList, List<int>, just gives List<int> directly.
  • For a context type of Iterable<?>, we would synthesize a context type for [] of Iterable<int>, because the type we solve Iterable<?> against, List<int> , has Iterable<int> as superintercface, so [] is inferred as <int>[]. In that case for (var i in maybeIntList ?? []) would give i declared type int, instead of dynamic like now.

It won't change the result for the Object context type. That is probably OK, there is a context type, so it's not lack of specified information that leads us to <dynamic>[], because that's a perfectly good solution to the fully-specified context type.

Example

This code shows the cases where type inference uses the less-precise context type instead of the more precise first-operand type.

void main() {
  List<int>? maybeIntList = null as dynamic;
  void f<T>(Iterable<T> iterable) {
    iterable.log("f.iterable");
  }
  
  var l = (maybeIntList ?? []..log("var []"))..log("var ??");
  l.log("l");
  
  Object o = (maybeIntList ?? []..log("Object []"))..log("Object ??");
  o.log("o");
  
  f((maybeIntList ?? []..log("func []"))..log("func ??"));
  
  for (var o in (maybeIntList ?? []..log("for-in []"))..log("for-in ??")) {
    o.arglebargle; // is dynamic 
  }
}

extension <T> on T {
  T log(String name) {
    // Name = value: runtime type @ static type (type variables instantiated)
    print("$name = $this: $runtimeType @ $T");
    return this;
  }
}

Why we should not change anything

It's worked so far, an very few people have hit the problem. We think. We can't know for sure, because it infers dynamic which may hide the problem.
In code bases which enables the "no implicit dynamic" warning, we can be fairly sure it doesn't happen. We do have quite a corpus where that is the case. Everybody can enable that warning and avoid the problem.

The workaround is trivial. If you want a List<int>, write <int>[]. The usability issue is that it sometimes works, so it's surprising when it doesn't, and people are not expecting to need a workaround.

Taking the type of the first operand as a context type for the second operand is arbitrary. We might as well take the type of the second operand as context type for the first. The ?? operator can be used in different modes, like

var result = rarelyUsedOverride ?? defaultComputation;

or

var result = defaultComputation ?? rarelyUsedFallback;

If we assume that the default computation signifies the desired type, and the other operand is a special case, possibly with a more precise type, then the current algorithm only really matches the latter use, not the former. That makes it arbitrary, and it shows that it was introduced to handle one particular case, ?? []. We should perhaps instead try to get out of that special-casing, instead of doubling down on it.

It won't work for anything but ?? [] anyway, so why pile more special cases on top for that one case. Just educate users about this one pattern.

@lrhn lrhn added the feature Proposed language feature that solves one or more problems label May 25, 2022
@leafpetersen
Copy link
Member

The hand-wavy "solve P wrt. ? using T" is intended to fill in the ?s in P with corresponding types from T where possible, but keep the ? where not.

I think I would suggest the following approach:

  • Replace every occurrence of ? in P with a fresh type variable Xi to get a new type P'
  • Compute T <# P' with respect to the Xi, yielding constraint set C
  • If Xi is constrained by C, choose a solution Si for it as usual (that is, use the same heuristics we use in inference)
  • If Xi is not constrained by C, choose ? as the solution
  • Replace every Xi in P with the solution chosen above to generate the context for e2

@lrhn
Copy link
Member Author

lrhn commented May 31, 2022

That does look like it would do what I want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

2 participants