dart-lang / language

Design of the Dart language
Other
2.62k stars 198 forks source link

Abstract static methods #356

Open HugoKempfer opened 5 years ago

HugoKempfer commented 5 years ago

Hi, trying to produce some generic code, I discovered that interfaces can't have non-implemented static methods.

But it would be a nice feature to allow this.

I can illustrate it by this piece of Rust code:

struct A {
    damn: i32
}

trait Serializable {
    fn from_integer(nb: i32) -> Self;
}

impl Serializable for A {
    fn from_integer(nb: i32) -> Self {
        A {
            damn: nb
        }
    }
}

fn bar<T: Serializable>(nb: i32) -> T {
    T::from_integer(nb)
}

pub fn main() {
    let wow = bar::<A>(10);
    println!("{}", wow.damn);
}

I tried to produce a non-working equivalent in Dart:

abstract class Serializable {
  static fromInteger(int);
}

class A implements Serializable {
  int foo;

  A(this.foo);

  A fromInteger(int nb) {
   return A(nb);
  }
}

T bar<T extends Serializable>(int nb) {
    return T.fromInteger(nb);
}

main() {
    var wow = bar<A>(42);

    print(wow.foo);
}
eernstg commented 5 months ago

Looks like I never spelled out how it is possible to use standard object oriented techniques to achieve something that is quite similar to virtual static methods by creating a shadow hierarchy of singletons, playing the role as "the static member holders" of the primary hierarchy.

This is in particular something you can do if you "own" the entire hierarchy (that is, if you have marked all of the classes as final, or something equally strong), because it relies on creating a global mapping (_companion below).

abstract final class Serializable {
  static const Map<Type, _SerializableCompanion> _companion = {
    Serializable: const _SerializableCompanion<Serializable>(),
    A: const _ACompanion<A>(),
    B: const _BCompanion<B>(),
  };

  static _SerializableCompanion<X> type<X extends Serializable>() =>
      _companion[X] as _SerializableCompanion<X>;
}

final class A implements Serializable {
  int memberOfA;
  A(this.memberOfA);
}

final class B implements Serializable {
  void memberOfB() => print('Running memberOfB!');
}

class _SerializableCompanion<X extends Serializable> {
  const _SerializableCompanion();
  X fromInteger(int i) => throw "AbstractInstantionError";
  X fromNothing() => throw "AbstractInstantionError";
  void virtualStaticMethod() => print("Most general virtual static method.");
}

class _ACompanion<X extends A> extends _SerializableCompanion<X> {
  const _ACompanion();
  X fromInteger(int i) => A(i) as X;
  X fromNothing() => A(0) as X;
  void virtualStaticMethod() => print("Virtual static method for `A`.");
}

class _BCompanion<X extends B> extends _SerializableCompanion<X> {
  const _BCompanion();
  X fromNothing() => B() as X;
}

T deSerialize<T extends Serializable>(int nb) {
  return Serializable.type<T>().fromInteger(nb);
}

main() {
  // Similar to dependency injection.
  var TypeA = Serializable.type<A>();
  var TypeB = Serializable.type<B>();

  // Companion methods resemble static methods or constructors.
  var a = TypeA.fromNothing();
  var b = TypeB.fromNothing();
  a = TypeA.fromInteger(42); // We have more than one way to create an `A`.

  try {
    // We may or may not have support for some constructors with some types.
    Serializable.type<B>().fromInteger(-1);
  } catch (_) {
    print("We can't create a `B` from an integer.\n");
  }

  // `a` is an `A`, and it gets the type `A` by inference.
  print('a is A: ${a is A}.');
  print('a.memberOfA: ${a.memberOfA}.');

  // `b` is similar.
  print('b is B: ${b is B}.');
  b.memberOfB();
  print('');

  // Call a static method on the given type `X`.
  void doCall<X extends Serializable>() {
    // Note that we do not know the value of `X` here.
    Serializable.type<X>().virtualStaticMethod();
  }

  doCall<A>(); // Calls "A.virtualStaticMethod".
  doCall<B>(); // Calls "Serializable.virtualStaticMethod".
}

If you do not own the entire hierarchy then it's going to be less straightforward to create _companion (you'd need to ask everybody to register their subtypes of Serializable in this map, and the companion classes would have to be public).

This is probably not 'progress', but it may still be useful to be aware of. ;-)

Levi-Lesches commented 2 months ago

@eernstg

It would be a hugely breaking change to require every Dart class to implement constructors with the same signature as that of all of its supertypes, and similarly for static methods. Already the signature conflicts could be difficult to handle.

What about a keyword, similar to @munificent's proposal? How about adding abstract static to an interface to force that method to be overridden by every subclass? This could avoid the problem of needing to declare and extend from two separate interfaces, and tie them together more logically. Here's an example:

/// Ensures subclasses have both a [fromJson] constructor and [toJson] method.
abstract class JsonSerializable {
  /// Normal constructors and static members work as they do today. 
  static void alwaysThrows() => throw UnimplementedError(); 

  /// This constructor must be overridden in all subclasses because `abstract` was used.
  abstract static JsonSerializable.fromJson(Map json);

  /// A normal abstract method
  Map toJson();
}

class User extends JsonSerializable {
  final String name;

  @override  // <-- must be overridden
  User.fromJson(Map json) : name = json["name"];

  @override
  Map toJson() => {"name": name};

  @override
  bool operator ==(Object other) => other is User && other.name == name;
}

bool testJson<T extends JsonSerializable>(T obj) => T.fromJson(obj.toJson()) == obj;
lrhn commented 2 months ago

What about a keyword, similar to @munificent's proposal? How about adding abstract static to an interface to force that method to be overridden by every subclass?

First of all, for the idea to work, the type JsonSerializable itself must implement the fromJson constructor, not just declare it abstractly. It's possible (based on the type constraints) to call

   var user = User.fromJson({"name": "foo"});
   testJson<JsonSerializable>(user); // Calls `JsonSerializable.fromJson`.

The JsonSerializable type can obviously implement fromJson so by throwing an UnsupportedError, but so can any other subclass, and then it's not so different from just not implementing it at all. Which means that even if we can enforce that all subclasses has a method or constructor, we can't actually force them to implement it meaningfully. And there will be cases where they don't want to, or cannot.

Can a class opt out, fx an abstract class or interface that doesn't intend to have a generative constructor at all? Since you cannot call the constructor directly on an abstract class, does it matter that it doesn't implement it? But what about static methods then, which you can call?

The way JsonSerializable itself doesn't have an implementation suggests that there will be such classes. And then we're back to "implementing" it by throwing, if we enforce that there must be an implementation when it's not desired.

This also effectively introduces a kind of "Self type" in that it can generalize over constructors which return something of their own type. It also means that, fx, a copy constructor isn't really possible.

class Copyable {
  abstract static Copyable.copy(Copyable self);
}
class Duplicati implements Copyable {
  final int state;
  Duplicati(this.state);
  @override
  Duplicate copy(Duplicati other) : this(other.state);
}

This override has the wrong signature, it should accept any Copyable, but it only accepts Duplicati. That means that we need either to design it as:

class Copyable<T extends Copyable<T>> {
  abstract static Copyable.copy(T self);
}
class Duplicati implements Copyable<Duplicati> {
  final int state;
  Duplicati(this.state);
  @override
  Duplicate copy(Duplicati other) : this(other.state);
}

T clone<T extends Copyable<T>>(T original) => T.copy(original);

or we may need to allow convariant overrides:

  Duplicate copy(covariant Duplicati other) => Duplicate(other.state);

which is a weird concept, and pretty unsafe, so probably not what we want.

So either F-bounded polymorphism (with its issues) or unsafe code, if we allow abstracting over functions that take themselves as argument. Or a lack of ability to have a static method which returns the same type. (I guess both factory and generative constructors can satisfy the requirement, since this is all about calling the constructor. We can't extend a type variable, so no

class C<T extends JsonSerializable> extends T {
  C(Object? v) : super.fromJson(v); /// Calls the known-to-exist `T.fromJson`
}

But what about mixins:

mixin M on JsonSerializable {
  final int bananas;
  M(this.bananas, Object? o) : super.fromJson(o);
  String toJsonString() => toJson().toString();
}

If we know that the super-class has a specific constructor, can we then allow a mixin to have a constructor? Then the mixin application would get the mixin's declared constructors instead of just forwarding all the superclass constructors. It could work. If we don't know anything about superclass constructors, then the mixin cannot declare a constructor either (but also wouldn't have to, if there are no inherited constructor requirements - and if it declares no constructors, it can still inherit/forward to every superclass constructor, and satisfy an inherited constructor requirement.).

Still, requiring every subclass to implement the same static interface is bothersome. Unless we make them inherited, like instance methods. That's what we're mimicing with the @override, so why not take the full step.

Virtual statics

A virtual static method is a static method declared on a type, which is inherited by all subtypes. A subclass may override the member, but must do so with a signature that is a valid override of the inherited member, just like an instance member. Invoking a virtual static on a type is just like invoking a static. Invoking a virtual static on a type variable will use the static declared or inherited by the actual type bound to the type variable at runtime.

A virtual constructor is a constructor which must be implemented by all subtypes. It is not inherited, since the constructor must return the self-type. It cannot be omitted, even on abstract types, but it'll have to be a factory constructor on those. (Mixins can have factory constructors!) It would be nice to not have to declare the constructor on an abstract class, but an abstract class can be a type argument, so if it's possible to call the constructor on a type argument, it must be there.

If we require that a generative virtual constructor is overridden by another generative virtual constructor, not a factory, then we can allow mixins with on types to have constructors that call a superclass generative virtual constructor. If we don't require that, mixins still can't know which generative constructors their superclasses have.

It's annoying to have to implement constructors on every subclass, even those which don't need it.
So what if we made the classes opt-in to a static interface, rather than having it follow the normal subtyping rules.

Static interfaces

That is, if we're going to make methods callable on type variables, I'd rather introduce static interfaces that class can choose to implement, but which is not tied to its normal interfaces.

static interface Copyable<T extends Copyable<T>> {
  Copyable.copy(T original);
}

class Duplicati implements static Copyable<Duplicati> {
  final int state;
  Duplicate(this.state);
  @override // if you want to write it
  Duplicati.copy(Duplicati original) : this(original.state);
}

T clone<T extends static Copyable<T>>(T value) => T.clone(value);

Here the <T static implements Copyable<T>> puts a restriction on which types can be passed as T, one which is not subtype based. It's a further restriction that any concrete type argument must have (the T may also have an extends SomeClass bound), which means that someone writing clone<Foo> must ensure that Foo satisfies the restriction. Which it does if it's a concrete class which satisfies the restriction, or if it's a type variable which is already restricted.

Then we can define sub-static-interfaces

static interface MultiCopy<T extends MultiCopy<T>> implements Copyable<T> {
  static List<T> copyAll(Iterable<T> originals);
}
class Triplicati static implements MultiCopy<Triplicati> {
  final int state;
  Triplicati(this.state);
  Triplicati.copy(Triplicati original) : this(original.state);
  static List<Triplicati> copyAll(Iterable<Triplicati> originals) => [for (var o in originals) Triplicati.copy(o)];
  String toString() => "<$state>";
}
Set<T> setCopy<T static implements Copyable<T>(Iterable<T> values) => {for (var value in values) T.copy(value)};
List<List<T>> deepCopy<T static implements MultiCopy<T>>(List<List<T>> lists) =>
  [for (var list in lists) T.copyAll(list)];

void main() {
  var trips = [Triplicati(1), Triplicati(2)];
  var tripOfTrips = [trips, trips];
  print(setCopy(trips)); // {<1>, <2>}
  print(deepCopy(tripOfTrips)); // [[<1>, <2>], [<1>, <2>]];
}

A subclass of a class that implements a static interface, doesn't have to implement the same static interface. It can't be used as a type argument where that static interface is required.

abstract class JsonEncodable {
  Object? toJson();
}
static interface JsonDecodable {  
  JsonDecodable.fromJson(Object? _);
}

Here JsonEncodable is a normal interface, which means that any concrete subclass must implement it. The JsonDecodable is a static interface, which means no class implements it without doing so explicitly.

Then you can write:

class JsonSerializer<T extends JsonEncodable static implements JsonDecodable> {
  T decode(Object? source) => T.fromJson(source);
  Object? encode(T value) => value.toJson();
}

(We'd probably want to give a name to that kind of combination constraint. Maybe typedef JsonCodable = JsonEncodable & static JsonDecodable;. But that's not a type, it's a composite type constraint, which is something we don't currently have a separate concept for, because currently a bound is always a single type.)

It's a kind of intersection type on types themselves, but it's only intersection static interfaces on top of a single type.

(Proposed as-is. All syntax subject to change. No guarantees of soundness. May contain nuts.)

I start to get why Kotlin defined companion objects as objects with normal methods and able to implement interfaces. Not that it solves everything, you still cannot get the companion object from a generic type parameter.

Levi-Lesches commented 2 months ago

First of all, for the idea to work, the type JsonSerializable itself must implement the fromJson constructor, not just declare it abstractly. It's possible (based on the type constraints) to call

   var user = User.fromJson({"name": "foo"});
   testJson<JsonSerializable>(user); // Calls `JsonSerializable.fromJson`.

The JsonSerializable type can obviously implement fromJson so by throwing an UnsupportedError, but so can any other subclass, and then it's not so different from just not implementing it at all. Which means that even if we can enforce that all subclasses has a method or constructor, we can't actually force them to implement it meaningfully. And there will be cases where they don't want to, or cannot.

Good points, I didn't think of those. But:

So at the call site, the compiler can tell that an abstract method will be used and flag it. This is a case that doesn't need to be handled today -- it's impossible to get an instance of an abstract class -- but there is a similar example, calling super.method() on a subclass of an abstract class. The compiler can see that there is no case where the parent class implements that method. So too, at the call site, the compiler should be able to tell that testJson will use a static method that just isn't declared. But if that's too complicated, the throw Unimplemented() solution works too


It also means that, fx, a copy constructor isn't really possible.

Putting the generic on the class works, but is annoying, as you said. What about putting it on the method itself?

class Copyable {
  abstract static T copy<T extends Copyable>(T self);
}

Note this isn't a constructor anymore but it's still called and used the same way.


Still, requiring every subclass to implement the same static interface is bothersome. Unless we make them inherited, like instance methods. That's what we're mimicing with the @override, so why not take the full step.

Agreed, the usual semantics with extends and implements could apply here as well.


That is, if we're going to make methods callable on type variables, I'd rather introduce static interfaces that class can choose to implement, but which is not tied to its normal interfaces.

Seems like what @munificent was suggesting earlier in the thread. My main problems with that are:

  1. Class declarations and generic functions can get pretty verbose, even with sugar like &
  2. This notion of "composite types" is a new concept entirely and will require more work (see #2709)
  3. A static interface can't depend on a normal interface and vice-versa. In your example, you had to split JsonSerializable into JsonDecodable and JsonEncodable. You can't write an interface that works with testJson, and therefore it wouldn't work with Firestore either, which forces you to provide a toJson and fromJson for every collection. Situations like these are the primary motivation for abstract interfaces. Like you said, you could do:
abstract class JsonEncodable { Map toJson(); }
static interface JsonDecodable { JsonDecodable.fromJson(); }
typedef JsonSerializable = JsonEncodable & static JsonDecodable;

But then what about methods like:

JsonSerializable copyWith(String key, Object? value) {
  final json = toJson();
  json[key] = value;
  return fromJson(json);
}

Where would that go? JsonEncodable doesn't have .fromJson, and JsonDecodable doesn't have toJson. You'd have to make a third type like JsonSerializable... and then you're back to square one, where every subclass must have both. It would be far more natural to have subclasses inherit both the instance and static interfaces.

In general, I'm not really as sympathetic to "what if the subclasses don't want it?" because by using abstract static, the parent class/interface is demanding static methods be implemented as part of its interface. As with instance interfaces, you can have "mischievous" subclasses that do just enough to compile but break expected functionality, and there's no real way to stop them in the instance or static cases. But at least we can be clear about what the expected behavior is and abstract over both static and instance members in doing so.


As a final example, consider Cloud Firestore's CollectionReference.withConverter

typedef FromFirestore<T> = T Function(snapshot, options);
typedef ToFirestore<T> = Map<String, dynamic> Function(T value, options);

CollectionReference<R> withConverter<R extends Object?>({
  required FromFirestore<R> fromFirestore,
  required ToFirestore<R> toFirestore,
});

class User {
  User.fromFirestore(snapshot, options) { ... }
  Map toFirestore(options) => { ... };
}

final users = firestore.collection("users").withConverter(
  fromFirestore: User.fromFirestore,
  toFirestore: (value, options) => value.toFirestore(options),
);

This could instead be:

abstract class FirestoreObject {
  abstract static FirestoreObject fromFirestore(snapshot, options);
  Map<String, dynamic> toFirestore(options);
}

CollectionReference<T extends FirestoreObject> withConverter();

class User extends FirestoreObject {
  @override 
  static User fromFirestore(snapshot, options) { ... }

  @override 
  Map toFirestore(options) => { ... };
}

final users = firestore.collection("users").withConverter<User>();
insinfo commented 2 months ago

think that the syntax of the class that is inheriting should remain the same, that is, keeping the reserved word static in the inherited method

that is, focusing on overriding and/or implementing static methods, and perhaps leaving constructors aside in abstract interfaces

abstract class FirestoreObject {
  abstract static FirestoreObject fromFirestore(snapshot, options);
  abstract Map<String, dynamic> toFirestore(options);
}

CollectionReference<T extends FirestoreObject> withConverter();

class User implements FirestoreObject {
  @override 
  static User.fromFirestore(snapshot, options) { ... }
  @override 
  Map toFirestore(options) => { ... };
}
lrhn commented 2 months ago

Since static methods are called, well, statically, can't the analyzer catch these?

The point of virtual abstract methods is that they are not resolved statically. If you call T.fromJson on a type variable, then the code should run the fromJson of the type bound to T at runtime.

All the compiler can see is a type parameter with a bound <T extends JsonSerializable> and somewhere else T.fromJson. That can cause two responses:

Since the goal here is to allow T.fromJson, we need to either:

My "static interfaces" idea above is the third item. I'd at least go for the second item. Number one is just too unsafe.

class Copyable {
 abstract static T copy<T extends Copyable>(T self);
}

Not sure how that would be implemented. It means that every Copyable class must have a method that can copy every Copyable object. If both Document and DanceStyle are Copyable, nothing in the signature prevents me from doing Document.copy<DanceStyle>(someDanceStyle), which Document surely doesn't know anything about.

I agree that static interfaces are probably too complicated, because they don't mix with normal interfaces, so you can't require a type to implement a static interface.

If we make virtual static methods be carried along with the interface, like instance methods, then:

It still means that JsonSerializable must implement its own interface, including the fromJson constructor that nobody can call because the type is abstract. Which suggests one extra constraint on type parameters: Not being abstract.

Consider:

  T copyWith<T extends new JsonSerializable>(T object, String key, Object? value) =>
     T.fromJson(object.toJson()..[key] = value);

The new in front of the type requires the type to be non-abstract. That is what allows calling a constructor on it.

Or we can just require every subclass of JsonSerializable to have a callable constructor called fromJson, some will just have to be factory constructors. Which likely throw.

Or we can introduce structural constraints instead of static interfaces:

  T copyWith<T extends JsonSerializable{T.fromJson(Object?)}>(T object, String key, Object? value) =>
     T.fromJson(object.toJson()..[key] = value);

which means copyWith can only be called with a T that has a fromJson constructor. It's not a static interface that you can name, which again means there is no way to enforce that every subclass of JsonSerializable has one, but if every place the type is going also requires the static constraint, then it'll probably be added soon enough.

(But then there is surely no way to promote to satisfy such a constraint, which would be something like if (o is JsonSerializable{new.fromJson(Object?)}) return copyWith(o);. I don't want to do runtime reflection of static methods of the runtime type of an object.)

So yes, "all subtypes must implement static virtual members too" is probably the only thing that can fly, and abstract classes will just have to implement constructors as factories.

Levi-Lesches commented 2 months ago

Not sure how that would be implemented. It means that every Copyable class must have a method that can copy every Copyable object. If both Document and DanceStyle are Copyable, nothing in the signature prevents me from doing Document.copy<DanceStyle>(someDanceStyle), which Document surely doesn't know anything about.

Very good point 😅. Yeah, the issue of having a self type is a bit hard to avoid. I'm still not sure I understand a path forward without the standard trick of class X<T extends X<T>>, but I hope I'm missing something.

My "static interfaces" idea above is the third item. I'd at least go for the second item. Number one is just too unsafe.

Totally agree. I don't love the structural constraints, it's way too different and the name is important imo.

Which suggests one extra constraint on type parameters: Not being abstract.

I kinda like this idea. I can see why you would want to allow an abstract class though, as many abstract classes have concrete factory constructors that pick a preferred concrete subclass to construct.

insinfo commented 2 months ago

Couldn't the dart implementation be similar to the C# Static abstract members implementation, with just a few syntax differences such as replacing the ":" with "extends" among other small differences from the dart style instead of the C# code style?

c

// Interface specifies static properties and operators
interface IAddable<T> where T : IAddable<T>
{
    static abstract T Zero { get; }
    static abstract T operator +(T t1, T t2);
}

// Classes and structs (including built-ins) can implement interface
struct Int32 : …, IAddable<Int32>
{
    static Int32 I.operator +(Int32 x, Int32 y) => x + y; // Explicit
    public static int Zero => 0;                          // Implicit
}

// Generic algorithms can use static members on T
public static T AddAll<T>(T[] ts) where T : IAddable<T>
{
    T result = T.Zero;                   // Call static operator
    foreach (T t in ts) { result += t; } // Use `+`
    return result;
}

// Generic method can be applied to built-in and user-defined types
int sixtyThree = AddAll(new [] { 1, 2, 4, 8, 16, 32 });

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-11.0/static-abstracts-in-interfaces

@Levi-Lesches @lrhn This link has the specification and discussion about the design https://github.com/dotnet/csharplang/blob/main/proposals/csharp-11.0/static-abstracts-in-interfaces.md https://github.com/dotnet/csharplang/issues/4436

lrhn commented 2 months ago

The C# design is basically that interfaces can have static members too, and implementing classes have to implement those static members.

They solve the issue of interfaces, abstract classes in Dart, not implementing the interface, by not allowing an interface type as argument to a type parameter which has a type with static members as bound. An alternative could be to mark the type parameter specifically if it's allowed to use static members, and then only make the restriction for those type variables. (But that's probably not worth it, why use a type with static members as part of its API if you're not using it. Well unless we start adding superclasses with statics to existing types like num, which we could, but since Dart operators are instance methods, not static, it's probably not as relevant as in C#.)

C# already has a separate way to specify constructors on type parameters, so they don't need to incorporate the self type into the static interface to support constructors. On the other hand, Dart should be able to just assume that a constructor on type variable T returns a T. If abstract classes are not allowed, we know that we can invoke constructors on the type variable.

C# also has virtual static methods. It's not necessary for having abstract static methods, if needed it can be added later.