dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.25k stars 1.58k forks source link

Dart can't infer the type in the switch case. I #54095

Open tolotrasamuel opened 12 months ago

tolotrasamuel commented 12 months ago

in the switch case, it is clearly a cat or a dog, but Dart can't infer the type in the switch case. Is this a bug?

class Animal {
  const Animal();
}

class Dog extends Animal {
  const Dog();
  void woof() {}
}

class Cat extends Animal {
  const Cat();
  void meow() {}
}

void main() {
  final cat = Baz.cat;
  cat.value.meow(); // works
  final baz = Baz.values.byName("cat");
  switch (baz) {
    case Baz.dog:
      print(baz.value.woof()); // error
      break;
    case Baz.cat:
      baz.value.meow(); // error
      print('cat');
      baz.value;
      break;
  }
}

enum Baz<T extends Animal> {
  dog(Dog()),
  cat(Cat());

  const Baz(this.value);
  final T value;
}

Dart can't infer the type in the switch case. Is this a bug?

eernstg commented 12 months ago

You could use something that corresponds to this variant in order to proceed:

class Animal {
  const Animal();
}

class Dog extends Animal {
  const Dog();
  void woof() => print('Woof!');
}

class Cat extends Animal {
  const Cat();
  void meow() => print('Meow!');
}

void main() {
  final baz = Baz.values.byName("cat");
  switch (baz) {
    case Baz<Dog>():
      baz.value.woof();
    case Baz<Cat>():
      baz.value.meow();
  }
}

enum Baz<T extends Animal> {
  dog(Dog()),
  cat(Cat());

  const Baz(this.value);
  final T value;
}

In this particular case there is a 1-to-1 correspondence between the subtypes of Animal and the values of Baz, but in the case where this is not true you could perform a case analysis on the values when there is more than one value with the same actual type argument.

@johnniwinther, @stereotype441, would type inference be expected to handle the original example?

lrhn commented 11 months ago

We generally do not attempt to infer types based on identical comparisons, other than the null check implicitly in == null.

We could. Identical checks cannot be fooled, if the two values are identical, then they have the same runtime type, and we can promote either operand to the type of the other (if it's a subtype).

That is, if a check like identical(x1, x2) is true, and the static type of x1 is a subtype of the static type of x2, then we can promote the variable x2 to the type of x1, or symmetrically promote x1. If the types are unrelated, nothing happens.

The best argument against is that nobody asked for a type check. We don't do general assignment promotion, we only promote to a "type of interest". But that's an assignment, identical is a test, just not a type test, so it's more reasonable that it can promote. Also, if it had to be a type of interest, idential-promotion would likely never trigger, because you wouldn't do a type check before an identical check.

Then, to make this actually work, we'd have to special-case == checks on enums, maybe on other types which has "primitive equality", to be recognized as being equivalent to an identical check. I'm OK with enums. Less OK with "primitive equality" in general, because that makes having primitive equality even more part of the public contract of a class. Currently that only matters for constants, so if your class doesn't have a const constructor, you don't need to care about having primitive equality or not.

If having primitive equality is enough to let anyone do promotion based on == checks, then it becomes a much more prominent feature, and a breaking change to add a == operator, even if it behaves exactly as before. So, no to that.

Which means that switch (variable) { case Animal.cat: ... would promote only when variable holds an enum type, since case constant is a == check, not an identical check, and only if we choose to special-case enums (which are required to have primitive equality).

But being limited to enums also takes some of the usefulness away, because you can often just use the identical enum value instead of the variable:

witch (baz) {
    case Baz.dog:
      print(Baz.dog.value.woof()); // no error
      break;
    case Baz.cat:
      Baz.cat.value.meow(); // no error
      print('cat');
      Baz.cat.value;
      break;
  }

Where that wouldn't work would be having multiple cases with the same type:

enum E<T> {
  int<num>(1),
  double<num>(1.0),
  string<String>("");
  T get value;
}
...
  E<Object?> e = ...;
  switch (e) {
    case E.int: 
    case E.double:
      print(e.value + 1);
    case E.string:
      print(e.value.length);
  }

Here the promotion would make a difference.

Working only for enums is actually somewhat reasonable, since Null is like an enum, it has a fixed set of enumerated values (one, called null). So it should probably also work for the last enum-like platform type, bool.

So, if you do identical(v, e) manually, then you can get promotion of v to the static type of e. Which means someone will soon ask for an identical pattern, so they can get promotion in patterns too. (Let's call it === c :wink: )

If you do case c: and c has one of the types Null, bool or an enum type, then it means c == matchedValue and will promote the same way as identical(c, matchedValue) would. If you do case == c: and the matched value has a type that is one of Null, bool or an enum type, then you also get promotion like doing identical(matchedValue, c).

Could work.

tolotrasamuel commented 11 months ago

You could use something that corresponds to this variant in order to proceed:

class Animal {
  const Animal();
}

class Dog extends Animal {
  const Dog();
  void woof() => print('Woof!');
}

class Cat extends Animal {
  const Cat();
  void meow() => print('Meow!');
}

void main() {
  final baz = Baz.values.byName("cat");
  switch (baz) {
    case Baz<Dog>():
      baz.value.woof();
    case Baz<Cat>():
      baz.value.meow();
  }
}

enum Baz<T extends Animal> {
  dog(Dog()),
  cat(Cat());

  const Baz(this.value);
  final T value;
}

In this particular case there is a 1-to-1 correspondence between the subtypes of Animal and the values of Baz, but in the case where this is not true you could perform a case analysis on the values when there is more than one value with the same actual type argument.

@johnniwinther, @stereotype441, would type inference be expected to handle the original example?

This is nice, and it seems to works. The only issue is that I have to manually write the Generic type, and autofill does not do it by itself