sdk icon indicating copy to clipboard operation
sdk copied to clipboard

LUB missing case

Open FMorschel opened this issue 6 months ago • 8 comments

Repro:

// Items
sealed class Foo {
  const Foo();
}

final class Bar extends Foo {
  const Bar();
}

final class Baz extends Foo {
  const Baz();
}

// Holders
sealed class Alpha<T extends Foo> {
  const Alpha({required this.first});

  final T first;
}

sealed class Beta<T extends Foo, U extends Foo> extends Alpha<T> {
  Beta({required this.second, required super.first});

  final U second;
}

abstract interface class AlphaBar extends Alpha<Bar> {
  AlphaBar({required super.first});
}

abstract interface class AlphaBaz extends Alpha<Baz> {
  AlphaBaz({required super.first});
}

// Concrete holders for items
final class BetaBarFoo<T extends Foo> extends Beta<Bar, T>
    implements AlphaBar {
  BetaBarFoo({required super.second, required super.first});
}

final class BetaBazFoo<T extends Foo> extends Beta<Baz, T>
    implements AlphaBaz {
  BetaBazFoo({required super.second, required super.first});
}

void foo(BetaBazFoo q, BetaBarFoo p) {
  final list = [q, p];
}

I was expecting list to be at least List<Alpha<Foo>>, but I get List<Object>. It does work fine if I add Alpha/Beta as the literal generic.

I'm not sure if my expectations are correct.

FMorschel avatar Jun 06 '25 14:06 FMorschel

@dart-lang/language-team

bwilkerson avatar Jun 06 '25 14:06 bwilkerson

I was testing this on stable. Just copied this on master, and it seems to be working. Not sure if this was intentionally fixed, maybe add some tests?

FMorschel avatar Jun 06 '25 14:06 FMorschel

That looks like it's working as intended. Not optimally, but what is actually specified. The two types in the list, and their superinterfaces, are

  • BetaBazFoo<Foo> <: Beta<Baz, Foo> <: Alpha<Baz> <: Object
  • BetaBarFoo<Foo> <: Beta<Bar, Foo> <: Alpha<Bar> <: Object

The only common supertype is Object.

There are cases where the Up algorithm tries to unify inside type arguments, but that's only when the outer type is the same, so List<int> and List<double> can be unified to List<num>, but List<int> and Iterable<num> will only try to compare actual superinterfaces, and the only common superinterface of those two is Object.

lrhn avatar Jun 06 '25 15:06 lrhn

I see, but both Alpha<Bar> and Alpha<Baz> are Alpha<Foo> and then Object, and it seems to be working on master, as I mentioned in my last comment.

FMorschel avatar Jun 06 '25 15:06 FMorschel

I'd be ecstatic if we've made a better UP, but I don't remember any recent changes that should make this work. (Doesn't mean there wasn't one, just that I don't remember. @stereotype441, I don't think inference-using-bounds should affect this, and not sound-type-inference either. The only other experiment I can see is it type-inference-4, which doesn't look like it's enabled on the main branch.)

lrhn avatar Jun 06 '25 15:06 lrhn

I opened the example again (maybe slow analyzer? I'm on Windows and it would not be the first time), it seems I was mistaken, it doesn't know to use Alpha<Foo> (I could swear I saw it before, but I'm not sure now).

Anyway, if that is not working, I'll leave this as a request for enhancement.

I feel like if it knows that List<int> and List<double> can be unified to List<num>, it should probably use the same reasoning to unify Alpha<Bar> and Alpha<Baz> to Alpha<Foo>.

FMorschel avatar Jun 06 '25 15:06 FMorschel

This is a more concise example:

sealed class Foo {
  const Foo();
}

final class Bar extends Foo {
  const Bar();
}

final class Baz extends Foo {
  const Baz();
}

sealed class Alpha<T extends Foo> {
  const Alpha({required this.first});

  final T first;
}

abstract interface class AlphaBar extends Alpha<Bar> {
  AlphaBar({required super.first});
}

abstract interface class AlphaBaz extends Alpha<Baz> {
  AlphaBaz({required super.first});
}

void bar(AlphaBar q, AlphaBaz p) {
  final v = 1 == 1 ? q : p;
  final list = [q, p];
}

void baz(Alpha<Bar> q, Alpha<Baz> p) {
  final v = 1 == 1 ? q : p;
  final list = [q, p];
}

While bar makes v and list use Object, you can see that baz makes the right decision and uses Alpha<Foo>.

FMorschel avatar Jun 06 '25 15:06 FMorschel

Here is an even more concise example:

void foo(bool b, Iterable<int> iter, List<num> list) {
  var x = b ? iter : list;
  x.expectStaticType<Exactly<Object>>; // This implies that `x` has type `Object`.
}

typedef Exactly<X> = X Function(X);

extension<X> on X {
  X expectStaticType<Y extends Exactly<X>>() => this;
}

Here's a proposal which would handle many of these cases: https://github.com/dart-lang/language/issues/3282. See also the overall topic https://github.com/dart-lang/language/issues?q=state%3Aopen%20label%3A%22least-upper-bound%22.

eernstg avatar Jun 07 '25 12:06 eernstg

Another case from the same project. I have a specific set of enums that all add the same mixin:

abstract mixin class Base {
  // Some members here
}

enum E1 with Base { v }
enum E2 with Base { v }
enum E3 with Base { v }

void f() {
  var myList = [E1.v, E2.v, E3.v];
}

For the above, myList is neither Enum or Base, but Object yet again. For this specific case I could see a point of having them not show any base type because the user may want either, but it still feels weird. Changing between with and implements makes no difference here too.

I'd say:

  • When a list of Enums that have no other shared super type in common, chose Enum (this would allow access to index and name.
  • If all enums do share another common subtype, defer to it instead.

I will note that if you make Base a mixin-only and add on Enum (or implement Enum for the mixin class) it will calculate the correct type of Base for the list.


Edit: I found a problem with this setup, see: https://github.com/dart-lang/sdk/issues/60965.

FMorschel avatar Jun 20 '25 02:06 FMorschel