dart-lang / language

Design of the Dart language
Other
2.65k stars 202 forks source link

[Static extensions] Can static extensions add members to (or via) a typedef? #4052

Open leafpetersen opened 2 weeks ago

leafpetersen commented 2 weeks ago

Extensions can be defined with typedef names as the on type:

class C {
}

typedef D = C;

extension E on D {
  int get foo => 3;
}

void main() {
  print(C().foo);
}

Should static extensions work when the on type is a typedef? If so, are they only accessible via the typedef name, or only via the name of the underlying class, or both?

class C {
}

typedef D = C;

// Error?  Or allowed?
extension E on D {
  static int bar() => 3;
}

void main() {
  print(D.bar()); // Error or not?
  print(C.bar()); // Error or not?
}
leafpetersen commented 2 weeks ago

I guess the question of whether the declaration itself is an error is only relevant for the variant in which we use a separate declaration form for static extensions, i.e.:

// Error?  Or allowed?
static extension E on D {
  static int bar() => 3;
}

since the variant without the leading static is already valid Dart code.

lrhn commented 2 weeks ago

Should static extensions work when the on type is a typedef?

Yes. It's just another way to write a type.

We have rules for when you can access static members through a type alias. We should use the exact same rules for when you can add static members through an extension.

That is, if we have a class-like type declaration ... Foo<...> {... members...} and a type alias typedef Bar<...> = Foo<...>; where the static namespace of Foo it's the static namespace of Bar (as I like phrasing it), then an extension on Bar<...> adds static members to the static namespace of Bar, and e the static namespace of Foo.

If so, are they only accessible via the typedef name, or only via the name of the underlying class, or both?

Both, because a normal static member on the target type would be accessible through both. And there are no new static namespaces, so "only the type alias name" is not an option.

lrhn commented 2 weeks ago

And more reasoning why we should allow it.

A available extension declaration of

static extension MyList<T> on List<T> {
  List.singleton(T value) : this.filled(1, value);
  static List<Object?> parse(String list) => jsonDecode(list) as List;
}

should allow users to use List.singleton and List.parse exactly as if they were declared on List directly. That includes accessing them through type aliases for List.

The complications do come with generics, and how we choose to apply those.

For static members (not constructors), which have no access to type parameters of the class, we should just let the static members be extension members of the static namespace of the on type.

Here that means parse can be found when doing a lookup on the static namespace of the List class definition, and failing to find a member in that namespace. Then we check static extensions on that namespace for alternatives.

For a slightly less trivial example:

static extension NumList<T extends num> on List<T> {
  List.singleton(T value) : this.filled(1, value);
  static List<num> parse(String source) => (jsonDecode(source) as List).cast<num>().toList();
}
typedef IntList = List<int>;
typedef StringList = List<String>;

Here you can access static members of List through IntList, it ignores the type argument to List for that, but if you access a constructor, it's a constructor for List<int>, and if you tear off IntList.filled, it's tearing off List<int>.filled. So generics are passed through to the constructor.

The simplest model to keep here is that the static namespace has no type arguments, static extensions adds extension members to the static namespace, and constructors for generic classes take extra type arguments to instantiate the class, placed before the name instead of after. It just doesn't work for constructors, because extension constructors refer to the type parameters of the extension, which may be different in number and bounds, than the type being constructed. And an IntList.singleton should denote the non-generic function NumList<int>.singleton, whereas List.singleton should denote the generic function NumList.singleton. Instantiation of the receiver type matters.

So we need to do something to go from IntList/List<int>/List to a binding, lack of binding, or even partial binding, of the type parameters of the extension.

The big question is whether we do that before or after deciding applicability. If after, then we have no specializing constructors by type. (But I'd rather do that directly in the constructor declaration as List<T extends num>.name(T value) ....) A failure to find a valid binding is then a compile-time error. If before, then we can make a failure to find a valid binding mean the extension is not applicable.

We probably do need to do something to ensure a consistency between the written receiver type and the extension on type.

static extension Bad on List<int> {
  List.bad() : this.empty();
}
var list = List<num>.bad(); // Creates a `List<int>`, not `List<num>`. Type-sound, but ...
var list2 = List<String>.bad(); // Should ensure that the result is assignable to `List<String>`.

Here list2 could technically ignore the <String> and say that List.bad returns a List<int> and infer that as the type of list2. We don't want that. The static type of List<String>.bad() should be List<String>. If that's not what it creates, either the constructor should not apply, or it's a compile-time type error.

Then there is:

class BadClass<String> extends List<String> {
  BadClass() : super.bad(); // Is really super bad!
}

The super.bad denotes a generative constructor of "List", but it's one that can only create a List<int>.

So we may want to say that an extension generative redirecting constructor is treated as a factory constructor when it comes to super-class constructor invocations. Aka: Don't!

(I keep going in circles when trying to figure this out. It may just be complicated, but it feels like there should be some simpler rule to unify it all.)