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

More capable Type objects #4200

Open
eernstg opened this issue Dec 9, 2024 · 108 comments
Open

More capable Type objects #4200

eernstg opened this issue Dec 9, 2024 · 108 comments
Labels
feature Proposed language feature that solves one or more problems meta-classes

Comments

@eernstg
Copy link
Member

eernstg commented Dec 9, 2024

This issue is a response to #356 and other issues requesting virtual static methods or the ability to create a new instance based on a type variable, and similar features.

Static substitutability is hard

The main difficulty with existing proposals in this area is that the set of static members and constructors declared by any given class/mixin/enum/extension type declaration has no interface and no subtype relationships:

class A {
  A();
  A.named(): this();
  static int foo => 1;
  static int bar => 2;
}

class B extends A {
  B();
  static int foo => -1;
  static int baz => -3;
}

As a fundamental OO fact, B is an enhanced version of A when it comes to instance members (even in this case where we don't enhance anything), but it is simply completely unrelated when it comes to constructors and static members.

In particular, the relationship between the constructors A(); and B(); is very different from an override relationship. A has a constructor named A.named but B doesn't have a constructor named B.named. The static member B.foo does not override A.foo. B does not inherit A.bar. In general, none of the mechanisms and constraints that allow subtype substitutability when it comes to instance members are available when it comes to "class members" (that is, static members and constructors).

Consequently, it would be a massively breaking change to introduce a rule that requires subtype substitutability with respect to class members (apart from the fact that we would need to come up with a precise definition of what that means). This means that it is a highly non-trivial effort to introduce the concept which has been known as a 'static interface' in #356.

This comment mentions the approach which has been taken in C#. This issue suggests going in a different direction that seems like a better match for Dart. The main difference is that the C# approach introduces an actual static interface (static members in an interface that must be implemented by the class that claims to be an implementation of that interface). The approach proposed here transforms the static members into instance members, which means that we immediately have the entire language and all the normal subsumption mechanisms, we don't have to build an entirely new machine for static members.

What's the benefit?

It has been proposed many times, going back to 2013 at least, that an instance of Type that reifies a class C should be able to do a number of things that C can do. E.g., if we can do C() in order to obtain a new instance of the class C then we should also be able to do MyType() to obtain such an instance when we have var MyType = C;. Similarly for T() when T is a type variable whose value is C.

Another set of requests in this topic area is that static members should be virtual. This is trivially true with this proposal because we're using instance members of the reified Type objects to manage the access to the static members.

There are several different use cases. A major one is serialization/deserialization where we may frequently need to create instances of a class which is not statically known, and we may wish to call a "virtual static method".

Proposal

We introduce a new kind of type declaration header clause, static implements, which is used to indicate that the given declaration must satisfy some subtype-like constraints on the set of static members and constructors.

The operand(s) of this clause are regular class/mixin/mixin-class declarations, and the subtype constraints are based on the instance members of these operands. In other words, they are supertypes (of "something"!) in a completely standard way (and the novelty arises because of that "something").

The core idea is that this "something" is a computed set of instance members, amounting to a correct override of each of the instance members of the combined interface of the static implements types.

abstract class A<X> {
  int get foo;
  void bar();
  X call(int _);
  X named(int _, int _);
}

class B static implements A<B> {
  final int i;

  B(this.i);
  B.named(int i, int j): this(i + j);
  
  static int get foo => 1;
  static void bar() {}
}

These declarations have no compile-time errors. The static analysis notes the static implements clause, computes the corresponding meta-member for each static member and for each constructor, and checks that the resulting set of meta-members amount to a correct and complete set of instance members for a class that implements A<B>. Here is the set of meta-members (note that they are implicitly created by the tools, not written by a person):

mixin MetaMembers_Of_B on Type implements A<B> {
  B call(int i) => B(i);
  B named(int i, int j) => B.named(i, j);
  int get foo => B.foo;
  void bar() => B.bar();
}

The constructor named B becomes an instance method named call that takes the same arguments and returns a B. Similarly, the constructor named B.named becomes an instance method named named. Static members become instance members, with the same name and the same signature.

The point is that we can now change the result of type Type which is returned by evaluating B such that it includes this mixin.

This implies that for each constructor and static member of B, we can call a corresponding instance member of its Type:

void main() {
  dynamic t = B; // `t` is the `Type` that reifies `B`.
  t(10); // Similar to `B(10)`, yields a fresh `B`.
  t.named(20, 30); // Ditto, for `B.named(20, 30)`.
  t.foo; // Similar to `B.foo`.
  t.bar(); // Similar to `B.bar()`.
}

This shows that the given Type object has the required instance members, and we can use them to get the same effect as that of calling constructors and static members of B.

We used the type dynamic above because those methods are not members of the interface of Type. However, we could change the typing of type literal expressions such that are not just Type. They could be Type & M in every situation where it is known that the reified type has a given mixin M. We would then be able to use the following typed approach:

class C static implements A<C> {
  final int i, j;

  C(int i): this(i, i);
  C.named(this.i, this.j): assert(i < j);
  
  static int get foo => 1000;
  static void bar() {}
}

void main() {
  var t = B; // `T` has type `Type & MetaMembers_Of_B`.

  // With that in place, all of these are now statically checked.
  t(10); t.named(20, 30); t.foo; t.bar();

  // We can also use the type `A` in order to abstract the concrete class away.
  X f<X>(A<X> a) {
    a.bar();
    return switch (a.foo) {
      1 => a(),
      _ => a.named(),
    };
  }

  B b = f(B);
  C c = f(C);
}

Next, we could treat members invoked on type variables specially, such that T.baz() means (T).baz(). This turns T into an instance of Type, which means that we have access to all the meta members of the type. This is a plausible treatment because type variables don't have static members (not even if and when we get static extensions), so T.baz() is definitely an error today.

We would need to consider exactly how to characterize a type variable as having a reified representation that has a certain interface. Let us use the following, based on the syntax of regular type parameter bounds:

X f<X static extends A<X>>() { // New relationship that `B` and `C` satisfy.
  X.bar();
  return switch (X.foo) {
    1 => X(),
    _ => X.named(),
  };
}

void main() {
  B b = f(); // Inferred as `f<B>();`.
  C c = f(); // Inferred as `f<C>();`.
}

Even if it turns out to be hard to handle type variables so smoothly, we could of course test it at run time:

X g<X>() { // No specialized bound.
  var Xreified = X;
  if (Xreified is! A<X>) throw "Ouch!";
  Xreified.bar();
  return switch (Xreified.foo) {
    1 => Xreified(),
    _ => Xreified.named(),
  };
}

void main() {
  B b = f(); // Inferred as `f<B>();`.
  C c = f(); // Inferred as `f<C>();`.
}

Customized behavior

The behavior of the reified type objects can be customized, that is, they can do other things than just forwarding a call to a static member or a constructor.

One possible approach could be to have static extends C with M1 .. Mk in addition to static implements T1 .. Tn on type introducing declarations (like classes and mixins), and then generate the code for the reified type object such that it becomes a subclass that extends C with M1 .. Mk and also implements T1 .. Tn. However, we could also apply those mixins outside the static extends clause, so we only consider a simpler mechanism:

A type introducing declaration can include a static extends C clause. This implies that the reified type object will be generated such that the given C is the superclass. Compile-time errors occur according to this treatment. E.g., if C is a sealed class from a different library then static extends C is an error, based on the fact that it would give rise to a subclass relation to that class, which is an error.

This mechanism allows the reified type object to have arbitrary behaviors which can be written as code in the class which is being used as the static extends operand.

Use case: An existential open mechanism

One particular kind of feature which could be very useful is a flexible mechanism that delivers the behaviors otherwise obtained by an existential open mechanism. In other words, a mechanism that allows the actual type arguments of a given class to be accessed as types.

This would not involve changes to the type system (so it's a much smaller feature than a real existential open would be). It is less strictly checked at compile time, but it will do the job—and it will presumably be used sparingly, in just that crucial bit of code that allows a given API to be more convenient to use, and the API itself would be statically typed just like any other part of the system.

For example, the reified type object could implement this interface:

abstract class CallWithTypeParameters {
  int get numberOfTypeParameters;
  R callWithTypeParameter<R>(int index, R Function<T>() callback);
}

A class C with a single type parameter X could use static extends _CallWithOneTypeParameter<X>, which would make it implement CallWithTypeParameters:

abstract class _CallWithOneTypeParameter<E> implements CallWithTypeParameters {
  int get numberOfTypeParameters => 1;
  R callWithTypeParameter<R>(int index, R Function<Y>() callback) {
    if (index != 1) {
      throw ArgumentError("Index 1 expected, got $index");
    }
    return callback<E>();
  }
}

For example, assume that the standard collection classes will use this (note that this is a non-breaking change):

abstract mixin class Iterable<E> static extends _CallWithOneTypeParameter<E> {
  ... 
}

abstract interface class List<E> static extends _CallWithOneTypeParameter<E>
    implements Iterable<E>, ... {
  ...
}
...

The 'existential open' feature is so general that it would make sense to expect system provided classes to support it. It also makes sense for this feature to be associated with a very general interface like CallWithTypeParameters, such that all the locations in code where this kind of feature is needed can rely on a well-known and widely available interface.

If we have this feature then we can deconstruct a type of the form List<T>, Set<T>, ..., and use the actual type argument:

X build<X, Y>(Y y) {
  if (<X>[] is List<List>) {
    // `X` is `List<Z>` for some `Z`.
    final reifiedX = X as CallWithTypeParameters;
    return reifiedX.callWithTypeParameter(1, <Z>() {
      return <Z>[build<Z, Y>(y)];
    }) as X;
  } else if (<X>[] is List<Set>) {
    // `X` is `Set<Z>` for some `Z`.
    final reifiedX = X as CallWithTypeParameters;
    return reifiedX.callWithTypeParameter(1, <Z>() {
      return <Z>{build<Z, Y>(y)};
    }) as X;    
  } else if (<Y>[] is List<X>) {
    // `Y <: X`, so we can return `y`.
    return y as X;
  } else {
    throw ArgumentError("Inconsistent call `build<$X, $Y>(_)");
  }
}

void main() {
  String v1 = build('Hello!'); // Passthrough, 'Hello!'.
  List<num> v2 = build(1); // `<num>[1]`.
  Set<List<int>> v3 = build(2); // `<List<int>>{<int>[2]}`.
  print('v1: ${v1.runtimeType} = "$v1"');
  print('v2: ${v2.runtimeType} = $v2');
  print('v3: ${v3.runtimeType} = $v3');
}

Obviously, this involves some delicate low-level coding, and it needs to be done carefully. However, the resulting API may be considerably more convenient than the alternatives.

In particular, an API could use regular objects that "represent" the composite types and their type arguments. Those type representations would then be handled by an interpreter inside build. For example, with this kind of approach it is probably not possible to obtain a helpful return type, and it is certainly not possible to use type inference to obtain an object that "represents" a type like Set<List<int>>.

Running code, emulating the example above.
abstract class CallWithTypeParameters {
  int get numberOfTypeParameters;
  R callWithTypeParameter<R>(int index, R Function<T>() callback);
}

abstract class _CallWithOneTypeParameter<E> implements CallWithTypeParameters {
  int get numberOfTypeParameters => 1;
  R callWithTypeParameter<R>(int index, R Function<Y>() callback) {
    if (index != 1) {
      throw ArgumentError("Index 1 expected, got $index");
    }
    return callback<E>();
  }
}

// Assume `static extends _CallWithOneTypeParameter<E>` in collection types.

class ReifiedTypeForList<E> extends _CallWithOneTypeParameter<E> {}
class ReifiedTypeForSet<E> extends _CallWithOneTypeParameter<E> {}

// Workaround: Allow the following types to be used as an expression.
typedef _ListNum = List<num>;
typedef _ListInt = List<int>;
typedef _SetListInt = Set<List<int>>;

CallWithTypeParameters? emulateFeature<X>() {
  return switch (X) {
    const (_ListNum) => ReifiedTypeForList<num>(),
    const (_ListInt) => ReifiedTypeForList<int>(),
    const (_SetListInt) => ReifiedTypeForSet<List<int>>(),
    _ => null,
  };
}

X build<X, Y>(Y y) {
  if (<X>[] is List<List>) {
    // `X` is `List<Z>` for some `Z`.
    final reifiedX = emulateFeature<X>() as CallWithTypeParameters;
    return reifiedX.callWithTypeParameter(1, <Z>() {
      return <Z>[build<Z, Y>(y)];
    }) as X;
  } else if (<X>[] is List<Set>) {
    // `X` is `Set<Z>` for some `Z`.
    final reifiedX = emulateFeature<X>() as CallWithTypeParameters;
    return reifiedX.callWithTypeParameter(1, <Z>() {
      return <Z>{build<Z, Y>(y)};
    }) as X;    
  } else if (<Y>[] is List<X>) {
    // `Y <: X`, so we can return `y`.
    return y as X;
  } else {
    throw ArgumentError("Inconsistent call `build<$X, $Y>(_)");
  }
}

void main() {
  String v1 = build('Hello!'); // Passthrough, 'Hello!'.
  List<num> v2 = build(1); // `<num>[1]`.
  Set<List<int>> v3 = build(2); // `<List<int>>{<int>[2]}`.
  print('v1: ${v1.runtimeType} = "$v1"');
  print('v2: ${v2.runtimeType} = $v2');
  print('v3: ${v3.runtimeType} = $v3');
}

Type parameter management

With this mechanism, a number of classes will be implicitly induced (that is, the compiler will generate them), and they will be used to create the reified type object which is obtained by evaluating the corresponding type as an expression.

The generated class will always have exactly the same type parameter declarations as the target class: If class C has 3 type parameters with specific bounds then the generated class will have the same type parameter declarations with the same bounds. This implies that T in static implements T or static extends T is well defined.

abstract class A<Y> {
  void foo();
  List<Y> get aList => <Y>[];
}

class C<X1 extends B1, X2 extends B2> static extends A<X2> {
  static void foo() => print('C.foo running!');
}

// Implicitly generated class.
class ReifiedTypeForC<X1 extends B1, X2 extends B2> extends A<X2> {
  void foo() => C.foo();
  // Inherited: `List<X2> get aList => <X2>[];`
}

// Example, where `String` and `int` are assumed to satisfy the bounds.
void main() {
  void f<X static extends A>() {
    X.foo(); // Prints 'C.foo running!'.
    print(X.aList.runtimeType); // 'List<int>'.
  }
  
  f<C<String, int>>();
}

More capable type objects as an extension

We may well wish to equip an existing type that we aren't able to edit (say, String) with reified type object support for a particular class (say, Serializable).

We can do this by adding a "magic" static member on the class Type as follows:

class Type {
  ...
  static DesiredType? reify<TypeToReify, DesiredType>() {...}
}

This is, at first, just a less convenient way to reify a type (passed as the type argument TypeToReify) into a reified type object. With an actual type argument of Type (or any supertype thereof), the returned object is simply going to be the reified type object that you would also get by simply evaluating the given TypeToReify as an expression.

However, if DesiredType is not a supertype of Type then the reified type object may or may not satisfy the type constraint (that is, it may or may not be an instance of a subtype of DesiredType). If it is not an instance of the specified DesiredType then reify will attempt to use an extension of the reified type of TypeToReify.

Such extensions can be declared in an extension declaration. For example:

extension E<X> on List<X> {
  static extends _CallWithOneTypeParameter<X>;
}

At the location where Type.reify<List<int>, CallWithTypeParameters>() is invoked, we gather all extensions in scope (declared in the same library, or imported from some other library), whose on-type can be instantiated to be the given TypeToReify. In an example where the given TypeToReify is List<int>, we're matching it with List<X>.

For this matching process, the first step is to search the superinterface graph of the TypeToReify to find the class which is the on-type of the extension. In the example there is no need to go to a superinterface, the TypeToReify and the on-type of the extension are already both of the form List<_>.

Next, the value of the actual type arguments in the chosen superinterface of the TypeToReify is bound to the corresponding type parameter of the extension. With List<int> matched to List<X>, X is bound to int.

Next, it is checked whether there is a static implements T or static extends T clause in the extension such that the result of substituting the actual type arguments for the type parameters in T is a subtype of DesiredType.

In the example where TypeToReify is List<int> and DesiredType is CallWithTypeParameters, we find the substituted static implements type to be _CallWithOneTypeParameter<int>, which is indeed a subtype of CallWithTypeParameters.

If more than one extension provides a candidate for the result, a compile-time error occurs.

Otherwise, the given reified type object is returned. In the example, this will be an instance of the implicitly generated class for this static implements clause:

class ExtensionE_ReifiedTypeForList<X> extends _CallWithOneTypeParameter<X>
    implements Type {}

The result is that we can write static implements and static extends clauses in extensions, and as long as we're using the long-form reification Type.reify<MyTypeVariable, CallWithTypeParameters>(), we can obtain an "alternative reified object" which is specifically tailored to handle the task that CallWithTypeParameters was designed for.

If we're asking for some other type then we might get it from the reified type object of the class itself, or perhaps from some other extension.

Finally, if the reified type object of the class/mixin/etc. itself doesn't have the DesiredType, and no extensions will provide one, then Type.reify returns null.

It is going to be slightly less convenient to use Type.reify than it is to simply evaluate the type literal as an expression, but the added expressive power will probably imply that Type.reify will be used for all the more complex cases.

Revisions

  • Feb 21, 2025: Added a section about extension-like reified type objects. Added a section about how to manage type parameters in the implicitly induced class that defines the reified type object.
  • Feb 20, 2025: Further developed the ideas about customized behavior.
  • Feb 14, 2025: Add the section about customized behavior.
  • Dec 10, 2024: Adjust the mixin to be on Type.
  • Dec 9, 2024: First version.
@eernstg eernstg added feature Proposed language feature that solves one or more problems meta-classes labels Dec 9, 2024
@lrhn
Copy link
Member

lrhn commented Dec 9, 2024

The approach proposed here transforms the static members into instance members,

That sounds like a Kotlin companion object.
I don't claim to understand Kotlin, but my understanding is that Kotlin doesn't have static members as such, only instance members on companion objects, which can be called just like static members otherwise would. The difference is that you can add interfaces to the companion object (otherwise each companion object is a singleton class instance with no relations to other classes) and that you can access the companion object as an object, and pass it around.

static implements

Can we have static extends to inherit static members? static with to add mixins?
(Or should we go the companion object way and require you to use an embedded declaration if you want more control,
like:

class C {
  // Before any static member:
  static class extends A.class with HelperMixin1 implements HelperType2;
  // Following static members are membes of this static class.
}     

and you can access the type as C.class, or C.static. Or something.

You can only extend or implement a Name.class type in another static companion class, to ensure that they all extend the real Type.

The constructor named B becomes an instance method named call

We probably still want to ensure that tear-offs from a static-member type is a canonicalized constant. Somehow. (That's one of the reasons I don't want normal classes to be able to implement the special singleton classes.)

To make this equivalent to the current behavior, I assume constructors have access to the type parameters of the
type object, so var l = List<num>; var vs = l.filled(24, 42); will be the same as var vs = List<num>.filled(24, 42);.
The other static members have no access to type variables.
And we want List<int>.copyRange and List<String>.copyRange (let's assume there is a static helper function with that name on List) to be identical, even if they are torn off from different instances. So some care is needed for that.

(Also, it's currently possible to have class C { C(); static void call() {} }, so just converting every static member to an instance member, and unnamed constructor to a call method, can be a conflict. One of them have to surrende.
Alternatively we can allow new as a member name that can only be declared indirectly using a constructor, but that still means you can't write (C)() and invoke the constructor. Or the call method. The ambiguity is still there.)

Type & M

Rather than needing this intersection, just let the generated mixin be on Type and have it actually extend the real Type type.
All such types implement Type, and their own companion interface MyName.class.

X static extends A

Probably want both a non-static and static type bound, say X extends Widget static extends Jsonable.
Having to throw away one of the types to use the other is going to be a problem.


This will be yet another reason to allow static and instance members with the same name.
I'd like to declare the toString of my static member objects, or have it be comparable, while the class itself is also comparable.
It'll happen. If not with this, then with static extensions, or any other feature that makes statics more important.

How will this interact with runtimeType?
Will A().runtimeType return the static Type object. (Yes, what else?)
Will the return type of runtimeType default to A.class when it's not overridden? (No, it's a virtual getter, so subclasses must override correctly, and there is no default type relation between the static member object's types.)

So, runtimeType stays useless, but if anyone does:

dynamic x;
// ...
 ...  (Object? o) { 
    ...
    x = o.runtimeType;   
    ...
...
x.foo();

we may have to retain a lot of static methods that could be tree-shaken today, because we can't tell statically whether they're invoked or not.
That's one advantage with static declarations today: they're either visibly used, or they're dead code.
When we turn static declarations into instance members, and allow casting the instance to Object and then dynamic, they're no longer static methods, and resolution is only going to be an approximation.

@tatumizer
Copy link

tatumizer commented Dec 9, 2024

print((int).runtimeType); // _Type
print((int).runtimeType.runtimeType); // _Type
print((String).runtimeType); // _Type

But... if int and String, as type objects, have the same type (_Type), they should have the same methods. But, according to my reading of the proposal, (int).parse("0") will be valid, but (String).parse("0") won't. 😕

(I think, the explanation is that (String).runtimeType is _Type, but String.runtimeType is not defined - but this difference is just an artifact of syntax. Will "Hello".runtimeType.runtimeType be different from ("Hello".runtimeType).runtimeType? Probably not. Not sure what to make of this 😄)

Maybe a function like static(String) can solve the problem? It will return something like _Static$String, which will be a regular object that can be a part of expression like static(MyClass) is MyInterface, static(MyClass) as MyInterface etc.

@lrhn
Copy link
Member

lrhn commented Dec 10, 2024

I would say that int.runtimType returns the same value as the expression int, but with static type Type.
The runtime type would be the type denoted by, fx, int.class, which is a subtype of Type.

Then int.runtimeType.runtimeType has the runtime type int.class.class.
We may want to limit the recursion here. Maybe say that the second-level runtime type is always the same type, plain Type, since the second level runtime types have no members at all. Any static member object with no members is a Type with no extra mixin, and since you can't declare a static static member, only the first level of .class can be proper subtypes of Type.

Or maybe that's a bad idea, because of what it does to tree shaking.
Maybe it's better to let runtimeType return a plain Type object representing the type, without the extra members from the static declarations.
It must still be equal to the object with the static members, but may not have the same runtimeType.

That is:

  • (strawman syntax) if A denotes a type declaration, then A.class denotes the static and runtime type of the expression A or A<T1,…,T2>.
  • If A declares no static members, and either has no constructors or the declaration is abstract, then A.class is the type Type
  • If A does declare a static member, or a constructor and is not abstract, then A.class denotes an implicit, unnamed subclass of Type which has the static members as instance members. That class has no static members or constructors. It may or may not be allowed to declare static operators, maybe including operator==
  • The object returned by Object.runtimeType is a plain Type object, with no extra instance members. If A.class does not override Type.==, then A().runtimeType == A, even of the latter has more members.

A generic type's class objects have instances for each instantiation, and the constructor members can access those.
The object for List<int>.class differs from List<String>.class as of they were different instantiations of a generic class (same generic mixin applied to Type, but different instantiation, so different runtime types, and constructor methods have different return types.)

The getters and setters of static variables are not represented by instance variables, they all access the same global variable's state.

@eernstg
Copy link
Member Author

eernstg commented Dec 10, 2024

Great comments and questions, @lrhn!

That sounds like a Kotlin companion object.

It's similar to Kotlin (and Scala) companion objects, but also different in other ways:

I'm not suggesting that Dart classes should stop having the static members and constructors that we know today, I'm just proposing that we should allow developers to request access to some of the static members and constructors (determined by the interface of the T in static implements T) using the reified types that we already have.

This differs from the companion objects in that there is no support for obtaining a companion object from a given actual type argument (because the type argument was erased). In contrast, a Dart type argument will always be able to deliver the corresponding Type object (just evaluate the corresponding type parameter as an expression). We may then invoke the (forwarding methods to the) static members and constructors of the underlying type, if this access has been provided.

For example:

abstract class A<X> { X call(); }

class B1 static implements A<B1> {}
class B2 {}

void main() {
  var type = someExpression ? B1 : B2;
  if (type is A) {
    var newObject = type();
  }
}

This means that the Kotlin/Scala developer must predict the need to invoke static members or constructors up front (when the concrete type is known) and must pass a reference to the required companion object(s) along with the base data. In contrast, Dart programs can pass type arguments along in the same way they do today, and then it will be possible to get access to the static members and constructors arbitrarily late, as long as the given type is available as the value of a type parameter.

In the case where a class does not have a static implements clause, the reified Type will be exactly the same as today, it's only the classes that are explicitly requesting this feature which will have a somewhat more expensive reified type.

Can we have static extends to inherit static members? static with to add mixins?

This seems to imply that we would implicitly generate a static member or a constructor from the instance method implementations. This may or may not be doable, but I'm not quite sure what the desired semantics should be.

When it comes to code reuse I would prefer to have good support for forwarding, possibly including some abstraction mechanisms (such that we could introduce sets of forwarding methods rather than specifying them one by one). This could then be used to populate the actual class with actual static members as needed, such that it is able to have the desired static implements clause.

We probably still want to ensure that tear-offs from a static-member type is a canonicalized constant

I think the current approach to constants based on constructors and static members is working quite well. The ability to access static members and constructors indirectly via a reified type is an inherently dynamic feature, and I don't think it's useful to try to shoehorn it into a shape where it can yield constant expressions. There's no point in abstracting over something that you already know at compile time anyway.

Rather than needing this intersection, just let the generated mixin be on Type and have it actually extend the real Type type.

Good point! Done.

Will A().runtimeType return the static Type object. (Yes, what else?)

I'm not quite sure what A means here. In my examples A is an abstract class which is used as the operand of static implements (that is, it's the "static interface" that the class B and C declare the required static members to "statically implement"), but it might just as well have been a concrete class (B and C don't care).

In that case, A().runtimeType would evaluate to the reified instance of Type (or a subtype) that represents the class A. It may or may not have some instance forwarders to static/constructor members of A, just like any other reified type. The whole thing is "getting rather meta" really quickly if A is used in static implements clauses of other classes, and also has its own static implements clause, but I don't see why it wouldn't work.

That's one advantage with static declarations today: they're either visibly used, or they're dead code.

A class that doesn't have a static implements clause doesn't have any new ways to invoke its static members or constructors, so they can be tree-shaken just as well as today.

A class that does have a static implements clause makes some of its static members and constructors callable from the implicitly generated forwarding instance members, but this is no worse than the following:

class A {
  static int get foo => 1;
}

class B {
  void foo() {
    print(A.foo);
  }
}

We may still be able to detect that B.foo is never invoked, and no other call sites for B.foo exist, so B.foo can be tree-shaken out of the program.

With "more capable Type objects" we need to track one more thing: Does it ever happen that a reified type object for a given class/mixin/etc. is created? If this may happen then we may obtain an object which is capable of calling a static method (via a forwarding instance member of that reified type object).

It may be harder, but it does sound like a task whose complexity is similar to that of tracking invocations of instance members. That is, if we're able to determine that B.foo above is definitely not called, couldn't we also determine that it is never going to be the case that a reified type object for a given class is created?

@eernstg
Copy link
Member Author

eernstg commented Dec 10, 2024

@tatumizer wrote:

But... if int and String, as type objects, have the same type (_Type), they should have the same methods. But, according to my reading of the proposal, (int).parse("0") will be valid, but (String).parse("0") won't. 😕

If you evaluate a type literal (such as int or String, but let's just use an example where we can decide whether or not there is a static implements clause on the class), the result will have static type Type. The run-time type is not specified currently, but the actual implementations may use a specific private _Type, or whatever they want. Nobody has a lower bound on this run-time type, just like it is with almost any other run-time type.

In particular, it is certainly possible for an implementation to evaluate B and obtain a reified type whose run-time type is of the form MetaMembers_Of_B (which is a subtype of Type and a subtype of A<B>, in the example).

Those reified objects may then have different interfaces, that is, they support invocations of different sets of members, so certainly it's possible for (int).parse("0") to be (1) statically type correct, and (2) supported by the expected implementation (which is the static method int.parse) at run time.

On the other hand, the reified String type does not have a parse instance method, because there is no parse static method in String to forward to (and hence it would be a compile-time error for String to have a static implements Something where Something has a String parse(); member). So (String).parse("0") is a compile-time error, and (String as dynamic).parse("0") throws at run time.

There's nothing special about this (and that's basically the point: I want this mechanism to use all the well-known OO mechanisms to provide flexible/abstract access to the static members and constructors which are otherwise oddly inflexible, from an OO perspective).

Maybe a function like static(String) can solve the problem? It will return something like _Static$String, which will be a regular object that can be a part of expression like static(MyClass) is MyInterface, static(MyClass) as MyInterface etc.

If we have B and C with a static implements A<...> clause then B evaluated as an expression is a regular object. It is also a reified type, but that doesn't prevent that it can be a perfectly normal object with normal semantics and applicability.

So we can certainly do B is MyInterface in order to detect whether the class B has MyInterface as a static interface. We would presumably evaluate the type literal and get a Type object and store it in a local variable in order to be able to promote the reified type such that we can use this fact:

abstract interface class MyInterface {
  int get foo;
}

class D static implements MyInterface {
  static int get foo => 1;
}

void f<X extends D>() {
  var reifiedX = X;
  if (reifiedX is MyInterface) {
    print(reifiedX.foo);
  }
}

@Wdestroier
Copy link

Wdestroier commented Dec 10, 2024

Could the following code similar to C#'s syntax:

abstract class Json {
  static fromJson(Map<String, dynamic> json);
  Map<String, dynamic> toJson();
}

class User implements Json { ... }

be syntatic sugar for this proposal's syntax?

abstract class Json$1 {
  Map<String, dynamic> toJson();
}

abstract class Json$2 {
  fromJson(Map<String, dynamic> json);
}

class User implements Json$1 static implements Json$2 { ... }

To keep static implements as an internal Dart abstraction / implementation detail, but I may be missing edge cases.

EDIT:
I chatted with mateusfccp and he commented "This would be the same as just making static part of the interface, which would basically break the entire universe". To avoid this problem, the base class must have the static method without a body (or marked as abstract). Another point was "you wouldn't be able to provide a default implementation, or else it would become a regular static method". If a static method with an implementation has to be abstract (probably rare), then it could have an abstract modifier imo.

I can't imagine any piece of code implementing the same interface for instance methods and static methods. However, I can imagine most use cases implementing an interface with instance methods and static methods. That's why I would prefer if instance and static interfaces were merged.

@tatumizer
Copy link

tatumizer commented Dec 10, 2024

@eernstg wrote:

So we can certainly do B is MyInterface in order to detect whether the class B has MyInterface as a static interface

This can't be! Today String is Object is tautologically true, but under the proposed reforms, the predicate will acquire a new meaning: "String implements static interface of Object", which is not true: static interface of Object includes the method hash and a couple of others, but these methods are not inherited by String. No, we need a different hierarchy and a different syntax for the "companions". Maybe String.class will do, not sure.
The way it's defined above is difficult to wrap my head around. 😄

(An argument against String.class syntax is that we will now have the notions of type and class, which might be very confusiing. We need a syntax for "get static interface of type B", so I think static(B) or B.static would be more appropriate)

@lrhn
Copy link
Member

lrhn commented Dec 10, 2024

String is Object is tautologically true because the expression String evaluates to a Type object, and Type is a subtype of Object.

The Type object that Foo of class Foo static implements Bar evaluates to is an object that implements Type and Bar. It will certainly have a toString and hashCode implementation because it's an object (and an Object).

That doesn't mean that Foo has a static toString, but it does absolutely mean that (Foo).toString() will be allowed, the "static interface implementation object" (or whatever we'll call it) that the expression Foo evaluates to does implement Object, and Type, and in this case also Bar.

(I chose String.class as strawman syntax because it's for accessing "class members", but also mainly because static is not a reserved word, so Foo.static might mean something. It better not, but it could.
You can do some weird stuff with classes and extensions if you really want to.
Like the expression static(C).static(C.static.static.C).static(C).static.)

Spoiler:

void main() {
  static(C).static(C.static.static.C).static(C).static;
}

class C {
  static C get static => C();
  C call(Object? value) => this;
}

C static(Type value) => C();

typedef _C = C;
extension on _C {
  _C get static => this;
  _C get C => this;
}

@tatumizer
Copy link

tatumizer commented Dec 10, 2024

Just to clarify: speaking of Object methods, I didn't mean hashCode or toString. I meant static methods of Object, which are:
hash, hashAll and hashAllUnordered. But I see your point: class String.static certainly implements Object, but it doesn't implement Object.static. To implement Object.static, class A has to say class A static implements Object, right?

static is not a reserved word, so Foo.static might mean something

That's not the reason to disqualify the word - in practice, it won't hurt anyone. Most people believe static is a keyword anyway. And if used in the form static(A), you can be quite certain no one has a global method called static. (It might be even possible to reserve the keyword static retroactively).

Still, it's not clear what static(A).runtimeType or static(static(A)) will evaluate to. Some synthetic types like _Static$A and _Static respectively?

@lrhn
Copy link
Member

lrhn commented Dec 10, 2024

Class A It would have to say class A static implements Object.static, which would give it nothing (as I now understand @eernstg's proposal) because Object won't have any static implements clause, so Object.static is just Type.

@tatumizer
Copy link

tatumizer commented Dec 10, 2024

If A is a regular class, we can always say class B implements A, and implement all methods of A in B.
Similarly, I guess the class can say class B static implements A.static and implement static methods of A in B (or else the compiler will complain about unimplemented methods).
So, if we declare class B static implements Object.static we will have to implement no-arg constructor, hash, hashAll and hashAllUnordered.
or else the compiler will complain. (Just a guess)

Q: Can class say class B /* no "static"! */ implements A.static? And what will it even mean? (Probably not, b/c "this" type mistmatch).

@tatumizer
Copy link

tatumizer commented Dec 11, 2024

Kotlin's companion object model is worth looking into, it won't take long: https://kotlinlang.org/docs/object-declarations.html#companion-objects

Main difference is that the companion object has a name and a type, and - most importantly - Kotlin has a word for it, which is (predictably) "companion object". It would be very difficult to even talk about this concept without the word, so I will use it below.

The idea is that you can extract a companion object from the object, e.g., (using dart's syntax), FooCompanion companion = foo.Companion (the name is by default capitalized, but you can assign any name). This immediately brings the companion object to a familiar territory, thus radically simplifying understanding.

I don't know if there's a simple way to express the same concept in dart without introducing the term "companion object". Does anyone have issues with this wording?

Interestingly, you very rarely need to extract the companion object explicitly, but the very possibility of doing so explains a lot: it's a real object; consequently, it has a type; this type can extend or implement other types - the whole mental model revolves around the term "companion object".

@eernstg
Copy link
Member Author

eernstg commented Dec 11, 2024

@Wdestroier wrote:

Could the following ... be syntactic sugar for this proposal's syntax:

abstract class Json {
  static fromJson(Map<String, dynamic> json);
  Map<String, dynamic> toJson();
}

class User implements Json { ... }

Desugared:

abstract class Json$1 {
  Map<String, dynamic> toJson();
}

abstract class Json$2 {
  fromJson(Map<String, dynamic> json);
}

class User implements Json$1 static implements Json$2 { ... }

I agree with @mateusfccp that it is going to break the world if a clause like implements Json implies not just the instance member constraints that we have today, but also a similar set of constraints on the static members (and, we should remember, constructors!).

In other words, it's crucial for this proposal that static implements has no effect on the subtypes of the declaration that has this clause, each class starts from scratch with respect to the static interface.

abstract class StaticInterface1 { int get foo; }
abstract class StaticInterface2 { void bar(); }

class B static implements StaticInterface1 {
  final int i;
  B(this.i);
  static int get foo => 10;
}

class C extends B static implements StaticInterface2 {
  C(super.i);
  static void bar() {}
}

This also implies that there is no reason to assume that a type variable X has a specific static interface just because we know that X is a subtype of Y, and Y has that static interface.

void f<X extends Y, Y static extends StaticInterface1>() {
  Y.foo; // OK.
  X.foo; // Compile-time error, no such member.
}

@mateusfccp
Copy link
Contributor

I must say I am in love with this proposal.

It solves many "problems" at once (although it may introduce more? let's see how the discussion goes), and it's a very interesting concept.

I agree that it's not as straightforward to understand it, but once you understand, it makes a lot of sense.

I also understand the appeal in having the dynamic and static interfaces bundled together, as @Wdestroier suggests, so if we could come with a non-breaking and viable syntax for doing it (while still providing the base mechanism), I think it would be valuable.

@eernstg
Copy link
Member Author

eernstg commented Dec 11, 2024

@tatumizer wrote:

This can't be! Today String is Object is tautologically true, but under the proposed reforms, the predicate will acquire a new meaning: "String implements static interface of Object", which is not true: static interface of Object includes the method hash and a couple of others, but these methods are not inherited by String.

Today String is Object evaluates to true because String evaluates to an instance of type Type when it is used as an expression, and Type is a subtype of Object. There is nothing in this proposal that changes this behavior.

If you want to test whether the reified type object for String implements a given type you can test this using the same unchanged features: String is SomeStaticInterface. This wouldn't be very useful, because you can just use the current syntax to call String.aStaticMethod() if String has that static method, but it would be possible:

void main() {
  var ReifiedString = String; // Obtain the reified type object for the type `String`.
  if (ReifiedString is Object) { // Same operation as `String is Object`.
    // Here we know that `ReifiedString` is an `Object`, but
    // we knew that already, so we can't do anything extra.
    ReifiedString.toString(); // Can do, not exciting. ;-)
  }
}

Testing that ReifiedString is Object doesn't imply that the type String that ReifiedString reifies has any particular static members or constructors, including hash, hashAll etc.

It's the instance members of the tested type A which must be available as instance members of the reified type object for B when we declare that class B ... static implements A {...}

This means that if A has an int get foo member then the reified type object for B also has an int get foo, and this is guaranteed to be implemented as a forwarder to a static member B.foo.

The reason why I'm emphasizing that it's all about instance members is that we already have the entire machinery for instance members: Late binding, correct overriding, the works.

The static members and the constructors are just used to derive a corresponding instance member that forwards to them, and all the rest occurs in normal object land, with normal OO semantics.

@tatumizer
Copy link

@eernstg : I understand each of these points, but they don't self-assemble in my head to result in a sense of understanding of the whole. The problem is terminological in nature. See my previous post about Kotlin.
(The very fact that you have to explain it reinforces the impression that the wording is not perfect. My guess is that we are missing an intermediate concept of "companion object" or similar).

@eernstg
Copy link
Member Author

eernstg commented Dec 11, 2024

About Kotlin's companion object, I already mentioned it here. The main point is that you cannot obtain the companion object based on a type argument, you have to denote the concrete class.

This reduces the expressive power considerably, because you can pass the type along as a type parameter and you can pass the companion object on as a regular value object, and then you can use the type as a type and the companion object as a way to access static members and constructors of that type. This is tedious because you have to pass the type and the object along your entire call chain whenever you want to use both.

You could also pass the type alone, but then you can't get hold of the companion object.

Or you could pass the companion object alone, but then you can't use the type (which is highly relevant, e.g., if you're calling constructors).

I spelled out how it is possible to write a companion object manually in Dart in this comment.

With this proposal, we can obtain the "companion object" (that is, the reified type object) for any given type (type variable or otherwise) by evaluating that type as an expression at any time we want, and we can test whether it satisfies a given interface such that we can call the static members / constructors of that type safely.

@eernstg
Copy link
Member Author

eernstg commented Dec 11, 2024

@tatumizer wrote:

My guess is that we are missing an intermediate concept of "companion object" or similar

The role of the companion object in Kotlin and Scala is played by the reified type object in this proposal. We could of course also introduce an indirection and just add a SomeInterface get companion getter to the reified type object's interface, but I don't think that's going to help in any way, it's just an extra step for no reason. Does that make more sense?

@tatumizer
Copy link

tatumizer commented Dec 11, 2024

@eernstg : that's where I have to disagree. It's an extra step for cognitive reason, which is a hell of a reason. :-)
But it's not only that! If you start reformulating the proposal in terms of companion object, many problems will resolve themselves automatically. Maybe a companion object is just a mental crutch, but I suspect it's more than that - or else we can get bogged down in hair-splitting about the meanings of words and their superpositions. Please give it a thought.

(Main property of a companion object, apart of its very existence, is that it has a clear type, which is visibly distinct from the type of the object itself, and it's a part of an (explicitly) different hierarchy. The difference is in explicitness).

@eernstg
Copy link
Member Author

eernstg commented Dec 11, 2024

If we just use the phrase 'companion object' rather than 'reified type object', would that suffice? I don't think there's any real difference between the members of a companion object in Scala and Kotlin, and the forwarding instance members in this proposal, so the reified type object is the companion object.

There is a difference in that this proposal only populates the companion object with forwarding instance members when the target class has a static implements A clause, and then only with forwarders that implement A.

We could also choose to populate every reified type object with all static members and all constructors. However, I suspect that it's a better trade-off to avoid generating so many forwarding methods because (1) they will consume resources (time and space) at least during compilation, and perhaps the unused ones won't be fully tree-shaken, and (2) they aren't recognized by the static type system if the reified type object doesn't have any useful denotable supertypes (so all invocations would then have to be performed dynamically).

The difference is in explicitness

Good point!

It is indeed an element of implicitness that this proposal works in terms of a 1:1 connection between each targeted static member / constructor and a forwarding instance member of the reified type object. We don't have that kind of relationship anywhere else in the language. The connection is created by specifying exactly how we will take a static member declaration and derive the corresponding instance member signature, and similarly for each constructor.

I don't know how this could be made more explicit without asking developers to write a lot of code that would be computed deterministically anyway (which is basically the definition of redundancy).

For the type, the static implements A clause does specify explicitly which type (in addition to Type) the reified type object will have.

@tatumizer
Copy link

If we just use the phrase 'companion object' rather than 'reified type object', would that suffice?

I don't know if that will suffice, but it would certainly be a step in the right direction. When I see the expression "reified type object", my head refuses to operate further - though I can guess what this means, I'm not sure the guess is correct, and the word "reified" especially scares me off (like, WTF: how this reified type object differs from just type object?).

I'll respond to the rest of your comments here later, need time to process :-).

@Wdestroier
Copy link

In other words, it's crucial for this proposal that static implements has no effect on the subtypes of the declaration that has this clause

True, it's important to be an opt-in feature.
Example: the abstract keyword means the person opted in.

abstract class MyClass {
  // No effect on subtypes.
  static String name() => 'MyClass';

  // Has effect on subtypes.
  abstract MyClass();

  // Has effect on subtypes.
  abstract static MyClass fromMap(Map<String, dynamic> json) =>
    MyConcreteClass(property: json['property']);
}

@tatumizer
Copy link

tatumizer commented Dec 11, 2024

@eernstg:

Here's an arrangement I could understand:

  1. every Type object receives an additional getter. let's call it "companionObject". That is, we can say String.companionObject and get a real Object. By naming convention, the type of this object is (strawman) StringCompanion.
print(String.companionObject is StringCompanion); // true
print(String.companionObject.runtimeType == StringCompanion); // true

This is achieved by adding one method to the Type class:

class Type {
  Object companionObject; // just an Object
  // etc...
}
  1. If we want to invoke the method of parametrized type, we have to declare the type like this
class Foo<T static implements SomeKnownInterface> {
   bar() {
     T.companionObject.methodFromKnownInterface(...);
   }
}
  1. In the class declaration, we have to add "static implements" (no "extends" or "with")
class Foo static implements FromJson<Foo> {
   // no changes to the existing syntax
  static Foo fromJson(String str) { ... }
}

The companion object will only include the methods from the static implements interface(s)

  1. If the class doesn't declare static implements, then the associated companionObject will remain empty.

The difference of this design and the original one is that, given a type parameter T, you can write if (T.companionObject is SomeKnownInterface) , but you cannot write if (T is SomeKnownInterface), because the latter doesn't make sense - it's always false, as it is today. Other differences follow from here.
(I apologize in advance for any possible misunderstanding of the current proposal)

WDYT?

(Possible alternative for "companionObject": "staticInterfaceObject" or something that contains the word "static")
(Another alternative: "staticProxyObject", or just staticProxy, and the name of its type is like StringStaticProxy)

@eernstg
Copy link
Member Author

eernstg commented Dec 13, 2024

Here's an arrangement ...

Very good! I think I can add a couple of comments here and there to explain why this is just "the same thing with one extra step" compared to my proposal. You may prefer to have that extra step for whatever reason, but I don't think it contributes any extra affordances.

Also, I'll try to demystify the notion of a 'reified type object'.

every Type object receives an additional getter. let's call it "companionObject".

Right, that's exactly what I meant by 'We could of course also introduce an indirection' here.

print(String.companionObject is StringCompanion); // true
print(String.companionObject.runtimeType == StringCompanion); // true

You'd have to use (String).companionObject because companionObject would be an instance getter on the result of evaluating String as an expression, which is what you get by using (String) as the receiver. In contrast, String.companionObject is an error unless companionObject is a static member of the class String or String.companionObject is a constructor (and that wouldn't be useful, because the whole point here is that we want to abstract away from the concrete type such that we can, for example, call constructors or static members of different classes/mixins/etc from the same call site). So companionObject must be an instance member of the value of evaluating String as an expression. So we'd have this:

print((String).companionObject is StringCompanion); // true
print((String).companionObject.runtimeType == StringCompanion); // true

But the value of evaluating a type literal like String as an expression is exactly what I call 'the reified type object' for the type String. It's a perfectly normal object (currently it only has the five Object members, and it overrides operator ==, so it's quite boring. However, we can use it for comparisons like obj.runtimeType == String or MyTypeVariable == String).

The core idea in this proposal (meaning "the proposal in the first post of this issue") is that this object should (1) have instance members forwarding to the static members and constructors of the type which is being reified, and it should (2) have a type that allows us to call those static members and constructors (indirectly via forwarders) in a type safe manner, without knowing statically that it is exactly the static members and constructors of String that we're calling. (If we know that we're operating on the companion object or reified type object of exactly String then we could just as well have used the normal static member invocation mechanism that we have today: String.staticMethod()).

Returning to StringCompanion, this is a type whose interface has instance members corresponding to the static members (and perhaps constructors?) of String. So we can use (String).companionObject.staticMethod() to call a static method staticMethod (let's just say that String.staticMethod exists and is a static method). It will do the same thing as String.staticMethod().

To compare, this proposal will do exactly the same thing in the following way (assuming that String has the clause static implements Interface where Interface is the interface that corresponds to the set of static members and constructors that String wants to support via its reified type object):

print(String is Interface); // true
print((String).runtimeType == Interface); // false

The second query is false because the run-time type is not exactly Interface, it is a subtype of Interface, and it is a subtype of Type. This is again not a problem (in other words, you don't need this to be true) because you can just call the static member or constructor using the syntax we have today if you know exactly what the run-time type of the companion object / reified type object is: You can simply do String.staticMethod() if you know it's String.

If we want to invoke the method of parametrized type, we have to declare the type like this

Here's how to do it in this proposal:

class Foo<T static extends SomeKnownInterface> {
   bar() {
     (T).methodFromKnownInterface(...);
   }
}

The proposal has an extra shortcut: If we encounter T.methodFromKnownInterface(...) where T is a type variable (such that it is definitely a compile-time error today) then this simply means (T).methodFromKnownInterface(...). So you can omit the parentheses around type variables, which makes this kind of invocation syntactically similar to the current syntax C.methodFromKnownInterface(...) where C denotes a class/mixin/etc declaration that actually declares a static member named methodFromKnownInterface.

In the class declaration, we have to add "static implements" (no "extends" or "with")

class Foo static implements FromJson<Foo> {
   // no changes to the existing syntax
  static Foo fromJson(String str) { ... }
}

The companion object will only include the methods from the static implements interface(s)

These statements are true for this proposal as well.

If the class doesn't declare static implements, then the associated companionObject will remain empty.

The corresponding statement for this proposal is that if the class doesn't declare static implements then the reified type object does not have any instance members that are forwarders to the static members or constructors of the corresponding class declaration. (The reified type object in this proposal will still have toString etc., like any other object, but that might well be true for your companion objects as wall. So they are very similar.)

The difference of this design and the original one is that, given a type parameter T, you can write if (T.companionObject is SomeKnownInterface), but you cannot write if (T is SomeKnownInterface), because the latter doesn't make sense - it's always false, as it is today. Other differences follow from here.

In your proposal you can write (T).companionObject is SomeKnownInterface, and in this proposal the exact same thing is written as T is SomeKnownInterface, which does make sense and will evaluate to true if and only if the class/mixin/etc. declaration that corresponds to the given value of T does have the clause static implements SomeKnownInterface (or, silly corner case: if Type <: SomeKnownInterface, e.g., if SomeKnownInterface is dynamic or Object).

I hope this illustrates that the two approaches correspond to each other very precisely, and the only difference is that the companionObject getter is invoked in your proposal in a number of situations, and you simply skip that step in my proposal.

@tatumizer
Copy link

tatumizer commented Dec 13, 2024

@eernstg:
my bad, I don't know why I wrote String.method whenever I meant (String).method - I perfectly understand the differences.

I think I can pinpoint the single place where our views diverge, and it's this:
To explain why "reified type object" (RTO) for one class has different methods than that of another, the types of these "reified type objects" have to be different. E.g. RTO for String may have type _StringTypeObject - it's specific to String. (currently, the runtime type of RTO is _Type; after the change, _StringTypeObject becomes a subclass of _Type).
But I'm not sure such reinterpretation is possible (for the reasons of backward compatibility). Or maybe it is?
(You need to be able to explain to the user why RTO for Foo contains not all static methods of Foo, but only those that are part of declared static interfaces. So if the class name explicitly says FooStaticInterfaceProxy, that would help).
Otherwise, I agree that it's the same idea.

@eernstg
Copy link
Member Author

eernstg commented Dec 13, 2024

Great, I think we're converging!

To explain why "reified type object" (RTO) for one class has different methods than that of another, the types of these "reified type objects" have to be different.

That's generally not a problem.

In my proposal, the RTO for a given class has a type which is a subtype of Type (such that current code doesn't break) and also a subtype of the specified static superinterfaces (introduced by static implements).

Currently, we already have a situation where the result returned from the built-in runtimeType getter has type _Type rather than Type, and the same is true for evaluation of a type literal as an expression (hence String is Type). This is just standard OO subsumption, and there's nothing special about the fact that we don't (officially) know the precise type of String used as an expression.

This implies that it isn't a breaking change to make those evaluations yield a result whose type isn't _Type, but types of the form _Type & MetaMembers_Of_String, or something along those lines. As long as the given object has type Type we're happy.

Next step, the static type of an expression that evaluates a type literal that denotes a class/mixin/etc. declaration can include the meta-member mixin. In other words, the static type of String as an expression can be _Type & MetaMembers_Of_String.

This implies that we can safely assign this RTO to a variable with the static implements type:

abstract class StaticFooable { void foo(); }

class A static implements StaticFooable {
  static void foo() => print('A.foo');
}

void main() {
  StaticFooable companion = A;
  companion.foo(); // Prints 'A.foo'.
}

However, we can not use an overriding declaration of runtimeType to improve on the type: It is true that the runtimeType of an instance of A will return an RTO whose run-time type is a subtype of StaticFooable, but the actual receiver type may be some subclass of A which might not static implement StaticFooable.

This means that we don't know anything more specific than Type when we obtain a RTO from a type variable, which is the reason why I'm using a dynamic test (var reifiedX = X; if (reifiedX is StaticFooable) reifiedX.foo();).

It might seem nice and natural if we could make the static interface co-vary with the interface of the base object itself (such that we could use SomeMoreSpecificType get runtimeType; to override runtimeType in every class that static implements SomeMoreSpecificType), but I do not think it's possible: There is no reason to assume that a subclass would have a static interface which is a subtype of the static interface of its superinterfaces. I think the instance member interface and the static interface are simply independent of each other.

However, note that it would certainly be possible for a type argument to use subsumption in static interface types:

abstract class StaticFooable { void foo(); }
abstract class StaticFooBarable implements StaticFooable { void bar(); }

class B extends A static implements StaticFooBarable {
  static void foo() => print('B.foo');
  static void bar() => print('B.bar');
}

void baz<X static extends StaticFooable>() {
  X.foo(); // OK
  X.bar(); // Compile-time error, no such member.
  (X as dynamic). bar(); // Succeeds when `X` is `B`.
}

void main() {
  baz<B>(); // OK, `StaticFooBarable <: StaticFooable` implies that `B is StaticFooable`.
}

@tatumizer
Copy link

tatumizer commented Dec 13, 2024

It might seem nice and natural if we could make the static interface co-vary with the interface of the base object itself (such that we could use SomeMoreSpecificType get runtimeType; to override runtimeType in every class that static implements SomeMoreSpecificType), but I do not think it's possible

Why do you need this SomeMoreSpecificType get runtimeType ?
It would be perfectly fine to leave it as Type get runtimeType. Probably, I'm missing something here.
When we invoke (Foo).runtimeType, we can get ANY type that extends or implements Type. It can be, say FooInterfaceObject, which is declared internally as class FooInterfaceObject extends Type. At least, for the user it should look like this. Under the hood, it can be implemented differently - no one cares how exactly.

Compare with this: we can declare some method as returning num, but the runtime type of the returned value can print double.
Are Type and num fundamentally different in this respect? Why?

To be sure if we are on the same page, please answer this question.
Suppose we have 2 objects obj1 and obj2.
We invoke obj1.runtimeType and get "Foo".
We invoke obj2.runtimeType and get "Foo".
Can we conclude that objects obj1 and obj2 implement exactly the same set of methods?

The static type of expression Foo can remain Type - same as the static type of String, or int. Maybe I said something contrary to that earlier - if so, it was a mistake.

(After re-reading your response, I am not even sure we disagree on anything important, And I do acknowledge the benefits of your proposal, assuming we agree that Foo.runtimeType is not just _Type, but a more specific FooInterfaceObject (or something) that extends Type and has Foo in its name. Then the idea will be very easy to understand, and the description will fit in a single page - in fact, it will be even easier to explain than the "companion" object in Kotlin)

@tatumizer
Copy link

tatumizer commented Dec 14, 2024

After considering it more, the idea of including in RTO only the references to methods from the declared static interfaces might be unnecessary. Whenever the tree-shaker decides to preserve class C, it will most likely have to preserve its noname constructor, too (otherwise, no one can create an instance). Apart from constructors, the classes rarely have static methods, and those that are potentially used could be identified by name (e.g. if someone says (T as dynamic).foo(), the tree-shaker can preserve all methods called foo in all classes potentially passed as T into the class in question.
The names of static methods in most cases are unique. So the restriction won't buy much in terms of size but may damage the logical integrity of a concept. It would be much better to maintain the illusion that all static methods are included in RTO.


Another point: I think static interfaces don't make much sense. I can't imagine the situation where a class expects type parameter to implement some static methods without also expecting the instances to implement some specific regular methods.
E.g., some class can compute polynomials over a ring (represented by an interface R), where R must provide static methods for zero and unity, and the instances must define operations of addition and multiplication.
To make this possible, we have to add support for the static abstract methods, e.g.

abstract class Ring<T> {
  abstract T operator+(T other);
  abstract T operator*(T other);
  // ...etc
  abstract static T zero; // SIC!
  abstract static T unity;  
}
class Integer implements Ring<Integer> {
  final int _n;
  Integer(this._n); 
  // ...implementation of operators, 
  static const zero = Integer(0);
  static const unity = Integer(1); 
}

@tatumizer
Copy link

tatumizer commented Feb 15, 2025

@eernstg:
Upon more thinking, I understand the motivation for your design. It can be explained in a couple of paragraphs.

Consider the predicate myObject is Foo. It returns true if the runtime type of myObject matches Foo (e.g. the type extends Foo, or implements Foo, or it's a Foo itself). If so, the object gets promoted to Foo, and we can invoke any method of Foo e.g. myObject.foo()

What we want is this: if myObject happens to be a type, then myObject is Foo, by our design, must mean that this type statically implements Foo - and then, as before, it gets promoted to Foo, enabling us to call the methods of Foo. In other words, Foo is treated as a regular interface here. But if it behaves like a regular interface, let it be a regular interface!

Then, how to make a type implement a regular interface? We have to use static implements Foo for this.
Everything looks consistent. No matter if myObject is a Type or a regular object, the same mechanism works.

(It's not always possible to statically implement any given interface - e.g. if the interface contains operators, there's no syntax to statically implement them, but it's OK).


If the class says it static implements anything, this "anything" extends Object and inherits all its methods. Today, there's syntax quirk in dart: String is Object, but String.runtimeType is an error (you should write (String).runtimeType). For backwards compatibility, this should be preserved. But when the class implements an interface X, which extends Object, the methods like runtimeType should be available: if (T is X) print(T.runtimeType); WDYT?

@eernstg
Copy link
Member Author

eernstg commented Feb 18, 2025

@tatumizer wrote:

Let's wait for a more authoritative opinion. :-)

I think we as a community will (and should!) assign authoritativity to each other in a subtle and continuously evolving manner, so let's avoid making that a concrete thing. ;-)

Anyway, I recently commented on Dart and recursive type aliases here.

@eernstg
Copy link
Member Author

eernstg commented Feb 18, 2025

@tatumizer wrote:

Upon more thinking, I understand the motivation for your design.
...
What we want is this: if myObject happens to be a type, then myObject is Foo, by our design, must mean that this type statically implements Foo - and then, as before, it gets promoted to Foo, enabling us to call the methods of Foo. In other words, Foo is treated as a regular interface here. But if it behaves like a regular interface, let it be a regular interface!

Agreed, this is a description of some of the properties of the proposal in this issue.

Let's use C rather than myObject, just to remind ourselves that it denotes a type.

In particular, if C is a type literal (denoting a class declaration, mixin declaration, etc., or a type variable) then the evaluation of C as an expression will yield a reified type object. Let's call it o.

We may then do object things with o, just like any other object. For instance, if C denotes a class declaration then the static type of the reified type object will be a subtype of any interfaces that C static-implements. For instance, with class C static implements Foo, we will be able to call o.foo(), assuming that the interface Foo has such a method.

Note that o is a regular object with a regular interface (that implements Foo), and instance member invocations on o are completely regular instance member invocations.

The direct syntax for o.foo() would be (C).foo(). A tiny bit of syntactic sugar on top of this will allow us to write C.foo() instead. I haven't used that for a while, because I felt the need to explicitly make the point that we're evaluating the type literal as an expression to obtain the reified Type object for that type.

If we consider a type variable X then the static type of X will just be Type (assuming that we haven't added a feature that specifies covariance of the static interface). In that case we will need to promote the reified type object before we can call, say, Foo members:

abstract class Foo { void foo(); }
class C static implements Foo { static void foo() {}}

void main() {
  (C).foo(); // OK.
  C.foo(); // OK, using said syntactic sugar, same as previous line.
  
  void f<X>() {
    final reifiedX = X;
    if (reifiedX is Foo) reifiedX.foo();
  }

  f<C>();
}

We could also do (X as dynamic).foo() and drop reifiedX entirely, but the recommended approach would certainly be to store a reference to the reified type object in a local variable, and use promotion to enable a given interface.

No matter if myObject is a Type or a regular object, the same mechanism works.

Right, there's nothing new here at all when it comes to is expressions and member invocations. Today we can do this:

void main() {
  final reifiedType = int;
  print(reifiedType.toString());
}

The only difference is that today's reified type objects have static type Type, and they don't have any (public) members other than the big five from Object (that is, the run-time type is probably some private class _Type, but it doesn't do more than Type). With the proposal in this issue, the reified type objects can implement additional interfaces (as specified with static implements), whose implementation is generated implicitly during compilation, and this implementation will forward each member invocation to the corresponding static/constructor member of the underlying declaration for that type.

But when the class implements an interface X, which extends Object, the methods like runtimeType should be available: if (T is X) print(T.runtimeType); WDYT?

Yes, I think we agree on that. Let me restate the situation:

The reason why int.toString() is an error, but (int).toString() is allowed, is that AClassName.aMember(42) is generally used to invoke static members (and constructors) of AClassName, and it was considered unhelpfully confusing if the same syntax could have the following effect: (1) Evaluate the class name as an expression, yielding an object of type Type, then (2) invoke the given member as specified. Also, it wouldn't add much to allow it because Type only has the members of Object.

The syntactic sugar I mentioned would be applicable (only) in the case where the receiver is a type literal X which denotes a type variable. The point is that we could then use X(some, arguments) to invoke a constructor of the value of X, which might be considered more readable than (X)(some, arguments), or we could call a static method called foo using X.foo(other, arguments) rather than (X).foo(other arguments).

@tatumizer
Copy link

tatumizer commented Feb 18, 2025

A small (self-)nitpick. The type will automatically static-implement Object regardless of anything, because today,
String is Object is true. With the new interpretation of is, it would mean that String statically implements Object. So the syntax
String.hashCode, String.runtimeType, not allowed today, will have to be allowed tomorrow for consistency - this won't break any code. This makes the syntactic quirk (String).runtimeTime unnecessary.

Yes, it looks like a consistent concept, congrats! 😄
(It took time for me to find the right perspective, sorry).

@benthillerkus
Copy link

What's also pretty cool about this proposal is that you could write a code generator that looks for static implements Reflectable or something like that, and that would then generate a function that returns something like a map or so with the names and types of all members of an instance.

@eernstg
Copy link
Member Author

eernstg commented Feb 19, 2025

Thanks for the kind words, @benthillerkus!

It's very tempting to introduce a way to generalize the behaviors of the reified type objects. One way to do it would be to support an extends relationship rather than an implements relationship to the class/mixin/... which is the operand of the static implements / static extends clause. With static extends, the reified type object could inherit arbitrary (user-written or generated) member implementations.

@eernstg
Copy link
Member Author

eernstg commented Feb 21, 2025

I developed the idea about allowing the reified type objects to inherit behavior a bit further. You might love or hate the following kind of function (more details in the original post, in the section 'Customized behavior'):

void main() {
  String v1 = build('Hello!');
  List<num> v2 = build(1);
  Set<List<int>> v3 = build(2);
  print('v1: ${v1.runtimeType} = "$v1"'); // 'v1: String = "Hello!"'.
  print('v2: ${v2.runtimeType} = $v2'); // 'v2: List<num> = [1]'.
  print('v3: ${v3.runtimeType} = $v3'); // 'v3: _Set<List<int>> = {[2]}'.
}

@tatumizer
Copy link

tatumizer commented Feb 22, 2025

@eernstg:
Q: now, with "more capable type objects", does it make sense for the runtime to continue hiding the information it already has?
E.g. suppose we have Type t=something. Why can't we say List<Type> argTypes=t.typeArguments;?. I wonder how much power will be added to the language if this becomes possible.

EDIT: the same functionality can be achieved by a syntax like

if (map case Map<var K, var V>) ...

In the previous discussion, the counterargument was that there's not much you can do with K and V. But now you can do something. Not clear how much though.


I think the problem you are trying to solve (illustrated by build method) is: (again illustrated y example, otherwise it becomes too abstract):
PROBLEM: how to denote a type, such that it has a no-arg constructor, such that said constructor returns an instance of a class such that the latter supports a method add(A value)?
My attempt:

// returns an initialized instance of Addable 
Addable f(T static implements ProducerOf<A extends Addable<A>> type, A value) {
   var a = type(); // returns Addable<A>
   a.add(value);
   return a;
}
var addable = f(List<int>, 5);
addable.add(10); // must work

Is this correct? 1 (Assuming we can somehow make List<T> statically implement said Producer)
But the syntax like this is not supported today. I googled "existential open" - there's not much info about it2, but my impression is that the above style of declaration is exactly what's called an "existential open". Just a wild guess.

Footnotes

  1. in the examples of existential open, they use the notation like any T or some T, just to make clear that T is a type parameter, but in dart, T extends something already tells that T is a type parameter. Otherwise you would never know what to write: any T or some T, and which of them is better than the other.

  2. most articles refer to existential crisis, existential risk, the philosophy of Sartre, etc - you can get bogged down in this stuff for the rest of your life and never find out what "existential open" was).

@eernstg
Copy link
Member Author

eernstg commented Feb 24, 2025

does it make sense for the runtime to continue hiding the information it already has?

That's a good question! I do think it makes a difference in the case where anything gets more expensive because a particular kind of information is available.

So if every reified type object t supports t.typeArguments then we might prevent a significant amount of optimization, in which case it's much more promising to have a feature that allows us to implement the same behavior for a user-selected set of types. That could be a small subset of the classes/mixins/... of a large program, and we don't want everything to run more slowly (100%, or 10%, or even 1%) if we can just choose to have that feature for a small number of classes (say, the ones that we're serializing/deserializing), and not for all the other classes.

We will definitely get more flexibility and more expressive power if anything (such as t.typeArguments) is available universally, but I'm not convinced that it is something that we can have for free.

Also, if we're using a feature like 'more capable type objects' to express something like t.typeArguments then we can specify it to work in any particular way we want. If it's a language feature or even a built-in capability of Type then we have to use one, single definition of typeArguments, and it had better work as desired for all purposes. In that sense, it's a safer bet to get a feature that allows us to build something than it is to get that thing directly that we'd otherwise build, as long as we're using a foundation of versatile and well-understood primitives.

In the previous discussion, the counterargument was that there's not much you can do with K and V

I'd claim that you can do a lot with K and V that you can't do if you don't have any kind of existential open. The most important point is that covariance allows us to forget the precise type arguments of instances of generic classes, which is a problem if you want to, say ..

  • Safely add something to a data structure (List<num> xs = <int>[]; and then xs.add(1.5) /*throws*/).
  • Create a similar data structure. Now and then we're lucky! - List<num> list = <int>[1, 1]; and then Set<num> set = list.toSet(); just works. But you may not be that lucky in other cases where nobody saw the need to provide something like the toSet method. For instance, how would you create a Set<List<int>> from a given List<int> when the static type only knows that it is a List<num>?
  • Create a function literal that works in spite of the fact that uses a type parameter in a non-covariant position:
void main() {
  final xs = <int>[2, 3];
  List<num> ys = xs;
  print(xs.reduce((a, b) => a + b)); // OK.
  print(ys.reduce((a, b) => a + b)); // Throws.
}

I tend to think that these things are important. ;-)

PROBLEM: how to denote a type, such that it has a no-arg constructor, such that said constructor returns an instance of a class such that the latter supports a method add(A value)?

Let me try:

class A {}

abstract class HasNoArgConstructor<X> {
  X call();
}

class AType static implements HasNoArgConstructor<AType> {
  // The implicitly induced constructor will do, but w can also write it explicitly.
  AType();
  void add(A value) {...}
}

class ASubType extends AType static implements HasNoArgConstructor<ASubtype> {
  // The constructor is implicitly induced, `add` is inherited, so we don't
  // have to write anything here. But we could of course override, if needed.
}

Y foo<Y static extends HasNoArgConstructor<Y>() => Y();

void bar<Y extends AType static extends HasNoArgConstructor<Y>>() {
  final Y y = Y();
  y.add(A());
}

void main() {
  AType x = foo();
  y.add(A());

  ASubtype y = bar();
}

OK, we can do those things.

// returns an initialized instance of Addable 
Addable f(T static implements ProducerOf<A extends Addable<A>> type, A value) {
   var a = type(); // returns Addable<A>
   a.add(value);
   return a;
}
var addable = f(List<int>, 5);
addable.add(10); // must work

I can't quite see how some of the details would work. For instance, I wouldn't put a static implements clause on a formal parameter type, that would be a clause on a type parameter declaration. Also ProducerOf<A extends Addable<A>> seems like it contains a type parameter declaration (A extends Addable<A>), but I would expect it to be a type (more like ProducerOf<A> where some other location would declare a type parameter named A and require A extends Addable<A>).

Perhaps it could be expressed as follows?:

abstract class Addable<X> {
  void add(X value);
}

abstract class ProducerOf<X> {
  X call();
}

Addable<A> f<A, T static implements ProducerOf<Addable<A>>(A value) {
   var a = T(); // Returns an `Addable<A>`.
   a.add(value);
   return a;
}

void main() {
  var addable = f<int, List<int>>(5);
  addable.add(10); // OK.
}

We have to assume that List<E> implements Addable<E> static implements ProducerOf<List<E>>. The former (implements Addable<E> seems rather unlikely, but it could in principle be true). For the latter (static implements ProducerOf<List<E>>), it could be expressed using an extension (using the most recent fancy subfeature ;-).

extension<E> on List<E> {
  static extends ProducerOf<Addable<E>>;
}

We would then have to use Type.reify<T, ProducerOf<Addable<A>>>()?.call() rather than T(), but it should work (and if the type argument doesn't support ProducerOf<.../*anything useful*/..> then Type.reify will return null, which we'd need to handle).

Again, I think it's reasonable to say that the 'more capable type objects' feature can do it.

@eernstg
Copy link
Member Author

eernstg commented Feb 24, 2025

About the 'existential open' operation: Granted, that's not so easy to look up. You can find it on page 5 of this paper: http://lucacardelli.name/Papers/SRC-056.pdf (where it is actually a language construct using the keyword open).

The intuition is that we have a type (like List<num>), and something about that type is not known at compile time (for example, a given expression of type List<num> could evaluate to a value whose run-time type is List<int>). In this situation we could just do things that will work for all possible values of that type parameter, e.g.,

void foo(List<num> xs) {
  num n = xs.first; // If the list isn't empty, this will _always_ work.
}

So we just treat xs as if it had run-time type List<num>, and it works. OK, it's actually _SomeSecretDartPlatformListClass<T> for some T, but let's pretend it is just a List<T> for some T.

It only works, though, as long as we are using the list in ways that are compatible with the variance that makes List<int> a subtype of List<num> (that is, covariance). This is roughly the same as using the list in a read-only fashion.

In any case, "for some T" can be described as "there exists a type T such that this is a List<T>, and it is guaranteed that T is a subtype of num". The word "exists" in that phrase gives the name to "existential types".

The only missing bit is that we can do more if we can denote the secret type that "exists" by a name (in particular, we can safely use it in a read-write manner):

void foo(List<num> xs) {
  if (xs is List<final T>) { // Guaranteed to succeed, and binds `T`.
    // It is now known that `xs` has type `List<T>`, and `T` is the
    // _precise_ value of the type argument (no subtype will work).
    T x = xs.first; // Assuming that it isn't empty.
    xs..add(x)..add(x); // Safe!
  } 
}

void fooWithoutExistentials(List<num> xs) {
  num x = xs.first; // Assuming that it isn't empty.
  xs..add(x)..add(x); // Adding a `num` to `xs` may throw.
}

The traditional way to do this involves the keyword open, but for Dart types it works better to use something like final T that declares a type variable and binds it to the actual type argument of the tested object xs.

I hope this illustrates that existential types may be theoretically heavy, but the core idea is rather accessible.

@tatumizer
Copy link

tatumizer commented Feb 24, 2025

Thanks for the explanation! I even thought for a moment that I understood it, but this part is a bit baffling:

void fooWithoutExistentials(List<num> xs) {
  num x = xs.first; // Assuming that it isn't empty.
  xs..add(x)..add(x); // Adding a `num` to `xs` may throw. ???
}

Why can it throw? The method xs.first doesn't do anything to the runtime type of the object retrieved from the list. The assignment num x = xs.first just tells the compiler to consider it a num statically, but in runtime, it's the same object as before! E.g. if the original list had the runtime type List<int>, then the value of x couldn't be anything but int - e.g. 5. And then we add 5 to the list again - why can it throw?

I checked it:

void f(List<num> list) {
  num x = list.first;
  print(identical(x, 5)); // true
  list.add(x);
}
main() {
  var list=<int>[5];
  f(list);
}

It doesn't throw.

Here's an example I can understand:

bool tryToAddElement(List<Object> list, Object value) {
  if (list is List<final T> && value is T) { 
     list.add(value);
     return true;
  }
  return false;
 
}

This one is impossible to implement without existential types.

@eernstg
Copy link
Member Author

eernstg commented Feb 25, 2025

Why can it throw?

You're right that the concrete example won't throw: The element which is added twice is obtained from the list, and this implies that it has a run-time type which is a subtype of the actual value of the type argument of that list.

However, adding a value whose static type is num or a subtype thereof to a list whose static type is List<num> is not safe in general:

void foo(List<num> list) {
  list.add(1.5); // `1.5` has type `double` which is a subtype of `num`: OK!
}

void main() {
  foo(<int>[]); // But it throws when we try to add `1.5`.
}

So the fooWithoutExistentials example here is relying on something which is beyond the knowledge of the type system ("the object came from the same list") in order to ensure that the operations (xs..add(x)..add(x)) are safe, and this means that the type system isn't going to help us if we stop doing it in a way where there's an argument outside the type system that supports the safety:

void fooWithoutExistentials(List<num> xs) {
  num x = xs.first; // Assuming that it isn't empty.
  xs..add(x)..add(1.5); // _Will_ throw if `xs` is a `List<int>`.
}

According to the type system this is just as safe as the previous version, but it throws. With the existential open operation, the actual type argument is denoted by the type variable T, and the type system does recognize that 1.5 can not be assumed to have the type T, so add(1.5) is rejected as a compile-time error.

bool canAdd(List<Object> list, Object value) {
  if (list is List<final T>) { // Guaranteed to succeed, and binds `T`.
     return value is T;
  } 
}

This one is impossible to implement without existential types.

Yes, that's a better example.

You can do it, if needed:

bool canAdd(List<Object> list, Object value) {
  final emptyClone = list.take(0).toList();
  try {
    emptyClone.add(value);
  } catch (_) {
    return false;
  }
  return true;
}

But one thing which is not so easy to do is to create a new object that needs to have a type that depends on the unknown type variable:

void addInListIfPossible(List<List<Object?>> list, Object? value) {
  if (list is List<List<final T>>) {
    if (value is T) list.add(<T>[value]);
  }
}

In this case we can't use <Never>[] as the intermediate list, because it should contain value. In general, the intermediate lists should be of type List<T> where T is the unknown type which was "opened", such that it can contain everything which is is "promised" by the outer type List<List<T>>. On the other hand, the intermediate list can't be a List<Object?> unless T is a top type (such as Object?), because the outer list can only contain a List<S> when S is a subtype of T. In other words, the intermediate list must be a List of precisely T, and we can't create that if we can't even talk about T.

@tatumizer
Copy link

tatumizer commented Feb 25, 2025

I think I understand it now, thanks a lot!

Q: have you ever considered a shorter notation for "extends", "static implements" etc. Using a strawman symbol ~ for extends, ~~ for static implements:

Addable<A> f<A, T ~~ ProducerOf<Addable<A>>(A value) {
   var a = T(); // Returns an `Addable<A>`.
   a.add(value);
   return a;
}

It looks more algebraic this way, no?

@eernstg
Copy link
Member Author

eernstg commented Feb 26, 2025

It looks more algebraic this way, no?

True, and that's probably the main reason why more-punctuation-fewer-keywords is so controversial. It's less verbose, but it is often considered significantly less readable (above all, for developers who are new to the language, and especially if the alternative is punctuation which isn't already well-known from other languages as a notation with a similar purpose).

On the other hand, it could be argued that it takes a day or two to get used to a punctuation based notation for a well-known mechanism (e.g., extends is replaced by : in superclass clauses, and by <: in type variable bound declarations), and then it quickly becomes second nature.

In any case, there's no reason to expect that there will be a lot of agreement on this type of proposal. ;-)

@tatumizer
Copy link

tatumizer commented Feb 26, 2025

... and especially if the alternative is punctuation which isn't already well-known from other languages as a notation with a similar purpose

There's one form that is well-known from other languages, based on the colon notation:
class Foo<A: SomeInterface static OtherInterface>
This shouldn't be too controversial.

@stan-at-work
Copy link

stan-at-work commented Feb 26, 2025

... and especially if the alternative is punctuation which isn't already well-known from other languages as a notation with a similar purpose

There's one form that is well-known from other languages, based on the colon notation: class Foo<A: SomeInterface static OtherInterface> This shouldn't be too controversial.

Yea, : is less verbose and common in a lot of languages

@eernstg
Copy link
Member Author

eernstg commented Feb 27, 2025

Here's a place to have that discussion: #4275.

@halildurmus
Copy link

halildurmus commented Feb 27, 2025

@eernstg I'm trying to understand the More capable type objects as an extension section you recently added and I created the below example:

abstract class A {
  String get foo;
}

class C static implements A {
  static String get foo => 'fooC';
}

abstract class _StringA implements A {
  @override
  String get foo => 'fooString';
}

extension E on String {
  static extends _StringA;
}

void main() {
  void f<X>() {
    final reified = Type.reify<X, A>();
    if (reified != null) {
      print(reified.foo);
    } else {
      print('null');
    }
  }

  f<C>(); // Prints 'fooC'.
  f<String>(); // prints 'fooString'.
  f<int>(); // prints 'null'.
}

Does this example accurately demonstrate how the feature is intended to work?

Also, I noticed a minor syntax issue in the following example:

Such extensions can be declared in an extension declaration. For example:

extension E<X> on List<X> {
  static extends _CallWithOneTypeParameter<X>;
}

The static extends clause should be placed in the declaration header instead:

extension E<X> on List<X> static extends _CallWithOneTypeParameter<X> {}

@tatumizer
Copy link

I also have an issue with the syntax Type.reify<X, A>. I'm not exactly sure what it means, but whatever it is, why not X.reify<A> ?

@eernstg
Copy link
Member Author

eernstg commented Feb 27, 2025

why not X.reify<A>?

It does look nicer (we're "asking X to reify itself as an A", which makes a lot of sense).

However, Type.reify as proposed is not a static method, it's a static member name that is used by the compiler as a trigger to generate some code at each call site. In particular: it needs to investigate every extension which is in scope, and there's no way you can write code in the body of a static method in Type that would do this.

We can (and will!) make it a compile-time error to tear off Type.reify, because it cannot work if all we have is a function object. Every invocation of Type.reify must be hand-processed by the compiler.

If we use the syntax X.reify<A> then it looks more like a regular invocation of a method on the reified type objects for the value of X.

abstract class Reify {
  Type reify<X>();
}

class C static implements Reify {
  static Type reify<X>() => int;
}

void doit<X>() {
  var f = X.reify<A>; // It's a `Type Function()`.
  print(f()); // 'int'.
}

void main() {
  doit<String>();
}

This means that there can be conflicts between mechanisms that already give syntax like X.reify<A> a meaning, and this new thing where the compiler generates code at the call site for whatever syntax we're using.

Moreover, X.reify<A> seems to imply that X has already been reified (because that's the syntactic sugar that I've proposed, and that's the only reason why we can write X.reify<A> in the first place, it would otherwise have to be (X).reify<A>. But the whole point with this mechanism is that it is a different (and more powerful) way to obtain the reified type object for a given type.

So we'd have to give X.reify<A> a very special semantics that differs from the existing semantics of the same syntax. With that in mind, I think it's better to use syntax like Type.reify<X, A>() to denote this "magic" behavior, rather than using syntax that otherwise already has a meaning (and a very different one).

@eernstg
Copy link
Member Author

eernstg commented Feb 27, 2025

@halildurmus wrote:

I'm trying to understand the More capable type objects as an extension section

abstract class A {
  String get foo;
}

class C static implements A {
  static String get foo => 'fooC';
}

/* The implicitly induced class of the reified type object is something like this:
class ReifiedTypeForC implements Type, A {
  String get foo => C.foo;
}
*/

abstract class _StringA implements A {
  @override
  String get foo => 'fooString';
}

extension E on String {
  static extends _StringA; // This is like a member of the extension, should be in the body.
}

/* The implicitly induced "reified type extension" would be something like this:
class ExtensionE_ReifiedTypeForString extends _StringA implements Type {}
*/

void main() {
  void f<X>() {
    final reified = Type.reify<X, A>();
    if (reified != null) {
      print(reified.foo);
    } else {
      print('null');
    }
  }

  f<C>(); // Prints 'fooC'.
  f<String>(); // prints 'fooString'.
  f<int>(); // prints 'null'.
}

Does this example accurately demonstrate how the feature is intended to work?

Yes, that's exactly how it would work.

The static extends clause should be placed in the declaration header instead

I'm sure that syntax could be given a similar meaning. In any case, the intention is that a static implements T clause implicitly induces a class declaration which is a subtype of T and of Type, and it can be returned from Type.reify<X, A>() when A is a supertype of T. Similarly, with static extends S, it implicitly induces a class declaration which is a subclass of S and a subtype of Type, and it can be returned from Type.reify<X, A>()whenAis a supertype ofS`.

The reason why I put it in the body of the extension is that, firstly, we may wish to have several of these declarations (because the given type, e.g., String, might benefit from being able to support Type.reify<X, A>() invocations for several different values of A). Secondly, being able to support Type.reify<X, A>() for several different values of A is perhaps similar to having several different members on the type:

extension E on String {
  static extends _DeserializeForString;
  static extends _StringA;
  static extends _CallWithZeroTypeParameters;
}

void foo<X>() {
  final myDeserializer = Type.reify<X, Deserializable<X>>();
  final myAThing = Type.reify<X, A>();
  final myCallWithTypeParametersThing = Type.reify<X, CallWithTypeParameters>();
  
  X x = myDeserializer.readFrom(someReader);
  print(myAThing.foo);
  assert(myCallWithTypeParametersThing.numberOfTypeParameters == 2);
}

// Somewhat reminiscent of the following:
void fooLookingInAlicesMirror<X>() {
  X x = X.asDeserializer.readFrom(someReader);
  print(X.asA.foo);
  assert(X.asCallWithTypeParameters.numberOfTypeParameters == 0);
}

void main() {
  foo<String>();
}

The difference is that Type.reify allows us to add these capabilities to the types in a post-hoc manner (that is, without needing edit rights on classes like String), whereas the image in Alice's mirror is too magic to exist in the real world (when we don't have that edit right).

It might be quite common for the extension reified objects to use static extends, because we need to write a class DoTheJob that will do the actual job at hand, and the compiler will then implicitly induce a class that simply inherits DoTheJob and thus gets the behavior.

However, it is also possible to use static implements as long as the on-type denotes a class that actually has the required static members / constructor. The implicitly induced class would then be a correct concrete class (that is, it implements every member in its interface) because every one of them can be expressed as a forwarder to a static member or a constructor of the on-type.

This is pretty much like being able to declare post-hoc that a given class C implements a certain type T, even though we don't have the edit rights (so we can't actually add extends T or implements T or with T to the declaration of C). The reason why this is so relatively easy is that we're using a completely new object (namely the reified type object), and then Type.reify<X, A> can choose which reified type object it wants to give us based on the 2nd type argument A, as long as we don't have several possible choices that all have a subtype of A.

@halildurmus
Copy link

Got it! Thanks for the detailed explanation.

@tatumizer
Copy link

@eernstg: Type.reify is still a mystery for me. Even bigger mystery than before. :-(

First off, why do we need it at all? If type X statically implements A, I can say X is A, X as A. What does reify add to this?

Some hint to the root cause of my confusion can be discerned in your phrase:

Moreover, X.reify seems to imply that X has already been reified

Is it not? How come? What kind of type is that? And how can the runtime know that this non-reified type statically implements A?
And if it's a compile-time construct (which, based on your description, looks quite plausible), then I have a very hard time imagining what it's doing. E.g. the same function f<X>() {} can be called with different type arguments for X (e.g. String, int, etc), and somehow this parameter type doesn't have a runtime representation (which is already strange enough) and then the code invokes Type.reify<X, A>, which in one case is equivalent to Type.reify<String, A>, in another - to Type.reify<int, A> - and then what?
What gets created? If it's a compile time thing, then what kind of code does it generate?

@eernstg
Copy link
Member Author

eernstg commented Feb 28, 2025

First off, why do we need it at all? If type X statically implements A, I can say X is A, X as A. What does reify add to this?

The point is that Type.reify can specify the desired type as well as the type which is being reified, and the semantics of Type.reify includes a traversal of all extensions in scope to find all the relevant static implements and static extends clauses.

If you're just reifying a type directly (as in final myReifiedTypeObject = X;) then we have no choice other than creating the reified type object for the value T of X as specified in the declaration of T. So if T is void Function() then we just get the same reified type object as today, and similarly for other structural types. However, if T is a type of the form C or C<T1 .. Tk> where C is a class declaration then it could have a static implements S or static extends S clause, in which case the reified type object for X will be an instance of a subtype of S which has been generated by the compiler, based on that clause. So there's no choice, we'll get an instance of one particular compiler generated class.

We may then investigate that reified type object and discover that it has various types (that's the X is A part), which will allow us to call the members of that interface in a statically type checked manner.

Returning to Type.reify<X, A>, we aren't just going to get an instance of one specific class. At first, we will get that same instance if it does have the type A (as usual, "the class wins"). But if the reified type object which is declared at the class itself doesn't have the requested type, Type.reify<X, A> will traverse all the extension members of the form static implements Sj or static extends Uj where the on-type of the extension can be instantiated to be a superinterface of the type that we're reifying, and including only the most specific ones (so if we have one static implements A in an extension on String and another one in an extension on Object then we only use the former).

This means that we could have 10 different ways to create a reified type object for any given value of X. If exactly one of them will yield an object of type A or a subtype thereof then that's what we get. If zero of them has this type then Type.reify returns null. If more than one has type A then we will probably have a run-time error (or Type.reify could again return null, or we could try to choose the most specific one, etc).

The point is that reified type objects from extensions will allow us to handle more than one task, and it will allow us to do this with classes that we don't have edit rights on, and it will allow those extensions to be independent of each other. So we could import one extension that makes List deserializable and another one that enables call-with-type-parameters, etc.

If you can't specify the desired type of the reified type object (and we can't do that with a plain reification as in final reifiedTypeObject = X; or X.someMethod()) then you can't choose the one that works among that set of possible reified type objects that we may get from extensions.

code invokes Type.reify<X, A>, which in one case is equivalent to Type.reify<String, A>, in another - to Type.reify<int, A> - and then what?

In the first case the semantics of Type.reify<X, A> is that it determines that String doesn't static implements A or static extends A or anything which is a subtype of A, so we aren't getting the reified type object directly from the class String. But then we might have an extension in scope whose on-type is String (or a supertype of String) that has such a static implements/extends member, and then we check that there is exactly one candidate which is best (that's the part about handling multiple possible choices), and then we get that reified type object for String. The story is exactly the same for another type like int, except that it would be an entirely different set of extensions.

@tatumizer
Copy link

Oh, the device tries to incorporate the information from the extensions into runtime, right?
I missed this point. Is it something new? Previously, the extension was treated as a purely static concept. Then I need to rethink the whole thing.

@eernstg
Copy link
Member Author

eernstg commented Feb 28, 2025

For each Type.reify call site, it is known statically that a specific set of extensions are in scope (declared here, or imported and not hidden), and it is known statically whether each of them contains any static implements T or static extends T members, and it can be determined statically or dynamically whether each of them has a subtype relation to the desired type (that is, for Type.reify<X, A>(), whether or not T is a subtype of A).

The typical case will surely be that A is a compile-time constant: It's a specific interface that we are requesting because we want to call its members, so we probably know exactly which interface we want. In the situation where A is also a type which isn't known at compile time, we just need to take all those static implements/extends members and eliminate the ones whose T isn't a subtype of the given A.

Next, we need to check whether the given type to reify (X in Type.reify<X, A>()) has an instantiation of the on-type of each of the extensions as a superinterface. For example, if X is List<int> then extension<E> on Iterable<E> can be instantiated to Iterable<int> which is a superinterface of List<int>. But extension on String won't work for List<int>, and neither will extension on List<double>.

The remaining static implements/extends members that are still candidates are exactly the ones that occur in an extension which has an on-type that matches.

If there are none, then Type.reify returns null; if there are multiple then we'll need to disambiguate (or throw, or return null, that's to be decided); and if there is exactly one candidate then we create an instance of the reified type object class for the instantiated on-type (which is X or a superinterface thereof).

So there are some elements which are handled at compile time, but some other elements will necessarily be handled at run time. However, the run-time work consists exclusively of finding the applicable cases among a finite (and small) set of cases which is known at compile time. The final step will be to create an instance of the selected implicitly induced reified type object class, and that's exactly the same job as it is when we're use the implicitly induced reified type object class of the class itself. The only difference is that Type.reify<X, A>() allows us to specify that we want an A, and it allows the compiler to search for an A-typed reified type object among all the kinds that are offered by extensions.

@tatumizer
Copy link

tatumizer commented Feb 28, 2025

@eernstg: I think I understand the rationale for Type.reify - I may comment on that later, as I'm reading your updated OP, and my focus has shifted a bit.

Here's a summary of my impressions so far.
(Please take it as a humble opinion - I can be wrong on any of the points below).

  • Everything up to "customized behavior" is clear as day :-)
  • Clever trick with the use of mixin
  • Existential open: the example in the OP is not the simplest one. You provided a much better explanation in this comment. The example with xs is List<final T> is easy to understand, especially after the motivation given earlier in the same comment. The syntax with <final T> didn't make it into an OP. Not sure why, especially given that it is a centerpiece of existential open.
  • type parameter management: why is it called "management"?. Nobody manages anybody there. :-) You need a better title. Even "type parameters" would be better IMO
  • Type.reified(...): will add later, still processing.

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 meta-classes
Projects
None yet
Development

No branches or pull requests

10 participants