dart-lang / language

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

Self type #3025

Open Hixie opened 1 year ago

Hixie commented 1 year ago

It'd be nice if a type could reliably refer to an implicit generic type argument representing the type of the instance.

Today you have to do something like:

abstract class Foo<Self extends Foo<Self>> { }

Now subclasses have to specify themselves as the type argument:

class BarFoo extends Foo<BarFoo> { }

This leads to some weird errors when people aren't explicit about types (see https://github.com/dart-lang/sdk/issues/52204).

It also isn't completely safe. For example, I could copy and paste BarFoo to make BazFoo and make a mistake and not notice for a while:

class BazFoo extends Foo<BarFoo> { } // no error, but the Self type is wrong

Use case

I use this for classes that declare an API that I want to use in other generic contexts, e.g.:

@immutable
abstract class Status<S extends Status<S>> {
  const Status();
  S copyWith();
  S lerpTo(S other, double t);
  bool matches(S other);

  @override
  operator ==(Object other) {
    if (other.runtimeType != runtimeType)
      return false;
    return matches(other as S);
  }
}

Now I have a type that I know I can clone, lerp, and compare, and so algorithms that are generic over that API can work on all the various subclasses of this type without needing to worry about the details.

(Obviously, true metaclasses would make this even more powerful, but I think this stands alone even without metaclasses.)

Hixie commented 1 year ago

An example of this in production is https://master-api.flutter.dev/flutter/material/ThemeExtension-class.html

Hixie commented 1 year ago

Another, this one from the Dart SDK: https://master-api.flutter.dev/flutter/dart-collection/LinkedListEntry-class.html

Hixie commented 1 year ago

The DevTools code also uses this pattern: https://github.com/flutter/devtools/blob/master/packages/devtools_app/lib/src/shared/primitives/trees.dart

eernstg commented 1 year ago

The language team has had some discussions about this feature a while ago. I think it shouldn't be too hard to support it in Dart.

The notion of Self types is traditionally considered tricky (for example, Subtyping is not a good "Match" for object-oriented languages introduces a whole new subtype-ish relationship 'matching' in order to handle methods with a parameter whose type is the Self type.

However, Dart already routinely handles types with a similar nature: Dynamically checked covariant type variables. So we can (and we'll have to) insert dynamic checks in almost all situations where Self or a type containing Self needs to be a supertype of anything (like Self x = someExpression;). The main exception is that Self x = this; is statically safe.

ds84182 commented 1 year ago

I actually don't think a Self type is a good fit for the language outside of trying to satisfy the "Self-like" generic parameters. So LinkedListEntry<Self> is fine to do.

But outside of this it feels like a footgun wrt covariance, and the lack of type parameters in this situation prevents safe usage. What even is Self if you only have Status?

To avoid confusion it's better to pass Self as a type parameter, and I think it should be easier to do. Self should always refer to the enclosing class, like writing it out with all the parameters. It is allowed in any typing position within the class, including its own type parameters. So class LinkedListEntry<E extends Self> and class MyEntry<T> extends LinkedListEntry<Self>. Doing this also means that the meaning of Self in a super type doesn't change meaning throughout the subtypes in a single type hierarchy.

rrousselGit commented 1 year ago

Could an assert possibly catch this?

Like:

abstract class Foo<Self extends Foo<Self>> {
  Foo() {
    assert(Self == runtimeType);
  }
}

This doesn't work with const constructors though

Hixie commented 1 year ago

Another place where a pattern that would benefit from Self types is used today is in the Flutter framework in some of the places where we use covariant arguments. For example, shouldRepaint always gets an instance of the same type as the receiver. (Some of the covariant uses wouldn't benefit from this because they're about the parallel class hierarchy, e.g. State.didUpdateWidget takes the State's parallel Widget, not the State itself.)

osa1 commented 1 year ago

The language team has had some discussions about this feature a while ago. I think it shouldn't be too hard to support it in Dart. ... So we can (and we'll have to) insert dynamic checks in almost all situations where Self or a type containing Self needs to be a supertype of anything

What about when Self needs to be a subtype? For example:

abstract class Clone {
  Self clone();
}

class A implements Clone { ... } // clone returns A

class B extends A {} // clone still returns A, but it needs to return B

For B to be a subtype of A, B.clone needs to be overridden to return B.

Do we have any option other than rejecting inheriting members that use Self in covariant position?

eernstg commented 1 year ago

B.clone needs to be overridden to return B.

Exactly. It is not a common concept, but it should not be hard to implement: An instance member may or may not be inherited by subclasses, in which case a member may be considered unimplemented even though there is a superclass which has an implementation. It is then an error for that subclass to be concrete, unless it declares an overriding implementation (in this case: that returns B).

It would make sense to require a special modifier on those non-inheritable members, and it would then be a compile-time error if a member has return type Self (or anything where Self occurs non-contravariantly), and it doesn't have that modifier.

By the way, the body of a class that uses the Self type could have an instance variable of type List<Self>, and there could be many other ways to use the type Self, and there would be many ways to use those members that are type safe. We basically know just as much about Self when used as a type argument as we know about any type variable of the class, and they aren't considered particularly type-unsafe.

So it is certainly not the same thing to have support for Self and to say that "we just added a bunch of unsafe typing." It's actually making certain things more type safe because we are able to express the actual concept which is relevant to those situations.

Hixie commented 1 year ago

For B to be a subtype of A, B.clone needs to be overridden to return B.

Do we have any option other than rejecting inheriting members that use Self in covariant position?

I'm not sure why you ask "other than rejecting". Rejecting is exactly the right answer for the use cases where the clone API comes up. If there was a way to say "must be overridden in descendants" I would totally use this.

With the current state of things, I imagine most people will start marking A in this kind of setup as final class to avoid the risk that people will inherit from it and break the clone API.

lrhn commented 6 months ago

No need for Self for that. Not sure it would work either, since the extension only knows the static type is called at.

extension LetNullable<T extends Object> on T? {
  R? let<R>(R function(T) f) => switch (this) {
   var v? => f(v),
   _ => null,
  };
skylon07 commented 6 months ago

Sorry... I deleted my original comment because I posted it mistakenly. I actually did find a solution, and it looks very similar to yours. Today I learned Dart is cool because you can write extensions for generic types! Whaaaat?

extension ObjectLet<ObjT extends Object> on ObjT {
  RetT? let<RetT>(RetT Function(ObjT) application) => application(this);
}

You actually don't need to even pass the non-null this through application. Dart is smart enough to promote the variable's type after seeing ?.let (unless you want an easy way to promote instance fields in methods by shadowing the name).

Anyway, just for reference (since you're just replying to the void now), what I posted before was a "possible use case" of Self:

extension ObjectLet on Object? {
  RetT? use<RetT>(RetT Function(Self) application) =>
    this != null ? application(this) : null;
}