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

'null' isn't promoted to 'Null'. #1505

Open
modulovalue opened this issue Mar 9, 2021 · 7 comments
Open

'null' isn't promoted to 'Null'. #1505

modulovalue opened this issue Mar 9, 2021 · 7 comments
Labels
flow-analysis Discussions about possible future improvements to flow analysis nnbd NNBD related issues question Further information is requested

Comments

@modulovalue
Copy link

void function(int? value) {
  Null nil;
  if (value == null) {
    nil = value; // A value of type 'int?' can't be assigned to a variable of type 'Null'.  Try changing the type of the variable, or casting the right-hand type to 'Null'.
    nil = null; // works
  }
}

I was surprised that this isn't allowed. Is this a bug or is this expected behavior?

@srawlins srawlins transferred this issue from dart-lang/sdk Mar 9, 2021
@lrhn lrhn added nnbd NNBD related issues question Further information is requested labels Mar 9, 2021
@leafpetersen
Copy link
Member

This was discussed here and the resolution was that we intended to promote. @stereotype441 looks like this is a bug, or did we change our mind on this? Not sure whether we can fix this now, though it's pretty unlikely to be de facto breaking.

@stereotype441
Copy link
Member

This was discussed here and the resolution was that we intended to promote. @stereotype441 looks like this is a bug, or did we change our mind on this? Not sure whether we can fix this now, though it's pretty unlikely to be de facto breaking.

Yeah, it looks like this was an oversight. I'll try to find some time this week to investigate whether it would cause any internal breakages to change this behavior; that should be a pretty good proxy for whether external users would be affected.

@stereotype441
Copy link
Member

@leafpetersen

This was discussed here and the resolution was that we intended to promote. @stereotype441 looks like this is a bug, or did we change our mind on this? Not sure whether we can fix this now, though it's pretty unlikely to be de facto breaking.

Yeah, it looks like this was an oversight. I'll try to find some time this week to investigate whether it would cause any internal breakages to change this behavior; that should be a pretty good proxy for whether external users would be affected.

Unfortunately, it seems to be quite breaking after all. So far I haven't even gotten the Dart SDK to build with this change. There's a lot of failures and I haven't checked them all out (you can look at https://dart-review.googlesource.com/c/sdk/+/192420 if you want to see details), but I've seen enough that I don't think we should consider changing this now, right after a stable release.

FWIW, here are the two coding patterns I've found so far that would be broken by this change:

Dead code

If a function contains dead code for handling non-null values, the promotion can carry into it, causing type errors, e.g.:

f(int? i) {
  if (i != null) {
    ...
    return;
  }
  ...
  if (i != null) {
    g(i); // Used to be a warning ("dead code"), now is an error ("Null can't be assigned to int")
  }
}
g(int i) { ... }

Maybe we could come up with a clever way to modify flow analysis to fix this. For example, maybe at the time we detect unreachability, we un-do the promotion to Null, so the type of i goes back to int? and then gets promoted to int.

Type inference when setting a null value to non-null

This type of construction appears to be pretty common:

f(List<int>? x) {
  if (x == null) {
    ...
    x = []; // Used to get type inferred as <List>[], now is an error ("List<dynamic> can't be assigned to List<int>")
  }
}

What's happening is that when analyzing an assignment to a variable, we use the promoted type of the LHS as the context for the RHS, so that we only demote when that context can't be satisfied. But since the promoted type of the LHS is now Null rather than List<int>?, we've lost the information about what type of list element we want.

Again, some cleverness could probably address this. We could, for instance, say that when analyzing an assignment, we use the promoted type of the LHS only if it's not Null; if it's Null, we pop one step up in the promotion chain and use the previous promoted type (or the declared type, if there was no previous promotion).

@stereotype441 stereotype441 added the flow-analysis Discussions about possible future improvements to flow analysis label Nov 6, 2021
@lrhn
Copy link
Member

lrhn commented Nov 6, 2021

In the

if (i != null) {
  return;
}
if (i != null) {
  // what is i?
}

example, is there any way we can promote i to Never in the second block?
We've promoted it to Null before (aka Never?), and now we check that it's also not Null, which could potentially promote it to Never proper.

Not sure how useful it is. The code is dead, so all we can say is that we never have a value for i reaching there.

@leafpetersen
Copy link
Member

example, is there any way we can promote i to Never in the second block?

This would be the natural outcome of using the factor algorithm as specified in the flow analysis design doc. I'm not sure we do that though.

@stereotype441
Copy link
Member

I prototyped a change to the type system that allows promotion to Null, and I found that there are some unfortunate consequences.

The biggest effect is on code like this (adapted from package:dart_style/src/rule/rule.dart):

  Set<Rule> get allConstrainedRules {
    Set<Rule> rules = _allConstrainedRules;
    if (rules != null) return rules;

    rules = {}; // (1)
    _traverseConstraints(rules, this);
    _allConstrainedRules = rules;
    return rules;
  }

At the line marked (1), under the current rules, where we don't promote to Null, the literal {} is type inferred with the context type Set<Rule>, so it's interpreted as an empty set literal (rather than an empty map literal). But if we allow promotion to Null, then the literal {} is type inferred with the context type Null, so there is no contextual information to tell whether it should be a map or a set literal. We default to assuming it's a map literal, and that results in a compile-time error.

The problem doesn't just affect the set/map distinction; it can happen to lists too. Consider this example (from package:sass/src/extend/extension_store.dart):

  List<ComplexSelector>? _extendCompound(...) {
    List<List<Extender>>? options;
    for (...) {
      ...
      if (options == null) {
        options = []; // (1)
        ...
      }
    }
  }

In this case, at the line marked (1), there's question that [] represents a list, but the type promotion to Null takes away the necessary context to see what kind of a list it should be, so type inference infers it as a List<dynamic>, which is not assignable to List<List<Extender>>?.

I found several dozen examples of these two patterns.

Another pattern, which is less common, but still problematic, occurs when promotion to Null causes dead code to fail to type check. This example is from package:typed_data/src/typed_buffer.dart:

abstract class TypedDataBuffer<E> extends ListBase<E> {
  void insertAll(int index, Iterable<E> values, [int start = 0, int? end]) {
    ...
    if (end != null) { // (1)
      _insertKnownLength(index, values, start, end);
      return;
    }
    ...
    if (end != null && writeIndex < end) { // (2)
      throw RangeError.range(end, start, writeIndex, 'end');
    }
    ...
  }
  ...
}

I've elided a lot of code here, so I should mention that there are no assignments to end anywhere in this method. Therefore, the if condition at (2) will always be false (because the case of end == null was handled at (1)), and in point of fact, the && writeIndex < end part of the condition is redundant. Ideally, the analyzer's dead code hint should warn about the fact that it's redundant, but it doesn't, because we don't currently allow promotion to Null.

If we do start allowing promotion to Null, here's what happens: at (2), the type of end is Null, so the expression writeIndex < end now has a type error, and the code fails to compile.

It's my personal belief that these problems outweigh the benefit of allowing promotion to Null, so we should leave the language as it is and not allow == null checks to promote to Null.

@lrhn
Copy link
Member

lrhn commented Nov 23, 2021

I'm assuming the rules declaration should be nullable

Set<Rule>? rules = _allConstrainedRules;

The problem seems to be that we assume that the context type of the assignment to a promoted variable can always be represented by the most promoted type, because if any type in the promotion chain has an instantiation of an interface type, all of them must agree. That's not true for Null ... or Never! It's already an issue if you do explicit promotion to Null.

void main() {
  List<int>? o = null as dynamic;
  if (o is Null) {
    o = []; 
    // A value of type 'List<dynamic>' can't be assigned to a variable of type 'List<int>?'. 
  }
}

And, since we allow demoting assignments, the currently promoted type is not the only relevant type for assignment.

I can see that we don't want to do type inference on the sub-expression more than once.

What if a context type could an entire chain of promoted types. Most of the time, it's just one type, but for assignment to promoted variables (and propagated down through that expression), every one of the types is seen as a constraint. They'll mostly agree, or be specializations, but in a few cases, where you promote a union type to one of its branches, there might be a constraint further up the chain. That goes for FutureOr too:

import "dart:async";
void main() {
  FutureOr<int> foo = 42 as dynamic;
  if (foo is int) {
    foo = Future.error("Not a future!");
    // A value of type 'Future<dynamic>' can't be assigned to a variable of type 'FutureOr<int>'. 
  }
}

I don't know how to actually use a chain of types as context type. I guess if you need a type argument to a generic type, you check whether any of the types in the chain implements that generic type, and then you still have to choose one of those type arguments, likely the most specific. (Union types as context types are just not very useful - another reason I don't want union types in the language.) For every other use of context types, you'd probably just use the most specific promoted type.
The:

    if (end != null && writeIndex < end) { // (2)  

again looks like something where the second end should have been promoted to Never since doing end != null on something typed Null, aka Never?, should promote it to Never.
Then the analyzer should warn that the code is dead (because it can contain any number of incorrect uses of end without any further errors.)

copybara-service bot pushed a commit to dart-lang/sdk that referenced this issue Mar 17, 2023
Although the true branch of `x != null` and the false branch of `x ==
null` promote `x` to non-nullable, the opposing branches do not promote
`x` to `Null` in the current flow analysis spec, which applies to the
CFE.

When switching from the dart2js static type computation to the CFE
static type computation, attempting to use `x` in these branches may
generate an unintended type check, which can overflow the stack if this
occurs in code responsible for performing type checks. The fix is fairly
straightforward - we can simply use an unchecked cast (via `JS`) instead.

See dart-lang/language#1505 for discussion on
why the expected promotion does not (yet) occur.

Change-Id: Ia9cca4e1aa8e9c67b42c60189f0d3811afb61360
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/289061
Reviewed-by: Stephen Adams <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
flow-analysis Discussions about possible future improvements to flow analysis nnbd NNBD related issues question Further information is requested
Projects
None yet
Development

No branches or pull requests

4 participants