Skip to content

Enum bound inheritance #2548

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

Closed
SharbelOkzan opened this issue Oct 1, 2022 · 6 comments
Closed

Enum bound inheritance #2548

SharbelOkzan opened this issue Oct 1, 2022 · 6 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@SharbelOkzan
Copy link

The feature basically allows us to create a class, that must, and can only be extended by certain classes. These certain classes are defined by an enum.

I'll introduce two new keywords in the following examples boundTo and typeOf.
I don't mean that the suggested features will necessarily require new keywords, on the contrary, I'd like to see it implemented in a simpler way. The two new keywords helped me to deliver my perception easier and eliminate confusion with normal user-defined methods.

The syntax can look something like this:

abstract class Animal boundTo AnimalType {}

class Bird extends Animal {
  void fly() {}
}

class Land extends Animal {
  int? legs;
  void walk() {}
}

class Sea extends Animal {
  bool? isMamel;
  void swim() {}
}

The binding enum should of course has some constraints. For example, the constraint could be implementing a certain class BindingEnum which requires overriding a Type getter.

enum AnimalType implements BindingEnum<Animal> {
  land(Land),
  sea(Sea),
  bird(Bird);

  const AnimalType(this._type);

  final Type _type;

  @override
  Type get type => this._type;
}

Then we can use it as

void doSomething(Animal animal) {
switch( typeOf (animal ) )  // this is possible because [Animal] was declared with [boundTo]. [typeOf] will return an enum [AnimalType] 
{
case AnimalType.land:
      animal.walk();   // Optimally, we don't need to cast here.
      break;
    case AnimalType.sea:
      animal.swim();
      break;
    case AnimalType.bird:
      animal.fly();
      break;
}

void main() {
Animal cat = Land();
doSomething(cat);
}

Usage:
This feature is extremely useful when we know that a certain class will only have a pre-determined set of subclasses, which happens a lot.

Two major benefits:

  1. As illustrated in the example above, it will allow efficient use for the switch statement in such scenarios. Which in turn, guarantees covering all possible cases (not possible with a sequence of if is statements).
  2. It will allow for creating extensions that make use of frequent patterns. Take the Bloc library for example, a very frequent pattern in the "state" class is SomeFeatureState then the subclasses are SomeFeatureInitial, SomeFeatureLoading, SomeFeatureIdle and SomeFeatureError. By utilizing a feature like boundTo, libraries can take advantage of this pattern to reduce boilerplate for the users. ( I don't want to expand much on this particular usage, it's just an example).

I'm aware that I'm not suggesting a full-fledged feature; syntax is too verbose, examples are somehow specific, and lack of resources. I'll try to improve my idea but first would like to know wether it's doable.

If this idea doesn't qualify to become a language feature, I'm interested in learning if other languages have such a feature, a workaround in dart, or a term to describe it if it exists (it's different than sealed classes).

@SharbelOkzan SharbelOkzan added the feature Proposed language feature that solves one or more problems label Oct 1, 2022
@Levi-Lesches
Copy link

Levi-Lesches commented Oct 2, 2022

Usage:
This feature is extremely useful when we know that a certain class will only have a pre-determined set of subclasses, which happens a lot

If this idea doesn't qualify to become a language feature, I'm interested in learning if other languages have such a feature, a workaround in dart, or a term to describe it if it exists (it's different than sealed classes).

Can you elaborate a bit on how this is different than sealed classes + patterns? You could write:

/// Sealed classes can only be extended in their own library, 
/// so the only subclasses are [Bird], [Land], and [Sea].
sealed abstract class Animal { }

class Bird extends Animal {
  void fly() { }
}

class Land extends Animal {
  int? legs;
  void walk() { }
}

class Sea extends Animal {
  bool isMammal;
  void swim() { }
}

void doSomething(Animal animal) {
  // Since Dart knows all possible subclasses of [animal], it knows this is exhaustive
  switch(animal) {
    case Land: animal.walk(); break;
    case Sea: animal.swim(); break;
    case Bird: animal.fly(); break;
  }
}

@SharbelOkzan
Copy link
Author

Thank you for the response @Levi-Lesches !

I was mistaken in my understanding of sealed classes. You're right, what I'm suggesting is only sealed classes with twisted syntax.
I brought up sealed classes because I knew it's a similar concept. However, I thought that the last only restrict classes from outside the library to extend the sealed class, I didn't know about the exhaustive coverage (which is the bulk of what I'm suggesting). Then I read about the sealed classes in Kotlin and learned how they can be used with the when statement. I've then come across #349 and can see now that this issue is a duplicate.

btw, are sealed classes still on the dart team agenda? It's not clear there where the discussion had settled eventually.

@eernstg
Copy link
Member

eernstg commented Oct 2, 2022

Note that the notion of 'sealed' classes hasn't been fully clarified. Mainly, there are two different proposals:

One rule is that every non-bottom subtype of a sealed class C must be declared in the same library as C. This means that switches can be statically recognized as exhaustive (we just need to have one case for each concrete type in this type hierarchy), and also that every method invocation can be statically resolved (and inlined, if the inlining heuristics indicate that this is a good idea) when there is no overriding declaration in that same library.

The other rule is that every non-bottom direct subtype of C must occur in the same library as C. This still means that exhaustiveness can be statically checked (we just need to have one case for each type in the set of subtypes of C in that library). But other libraries can freely create additional subtypes of each of the subtypes of C (they just can't create a direct subtype of C itself).

// Library 'lib.dart'.
sealed abstract class C {}
class D1 extends C {}
class D2 implements C {}
class D3 extends Object with C {}

// Library 'lib2.dart'.
import 'lib.dart';

class D1a extends D1 {} // Error with rule 1, allowed with rule 2.
class D1b extends D1 {} // Ditto.

// With rule 1, assume that D1a, D1b do not exist.
void main() {
  C c =...;

  // With rule 1, this switch handles every concrete run-time type explicitly.
  // With rule 2, it handles every possible type, but the compiler
  // won't tell us that `case D1` could also be a `D1a` or a `D1b`.
  var x = switch (c) {
    case D1 _ => 1;
    case D2 _ => 2;
    case D3 _ => 3;
  };

  // Anyway, we could as well have a switch that covers all possible
  // types without matching on the most special type on each branch.
  var y = switch (c) {
    case D2 _ => '2';
    case C => 'not 2'; // Includes `D1` and `D3`, so we're done.
  };
}

@munificent
Copy link
Member

munificent commented Oct 3, 2022

btw, are sealed classes still on the dart team agenda?

Yes, we're still working on it and the current plan is to make it part of the patterns feature.

I'm going to close this issue because I think sealed types do what you want.

@SharbelOkzan
Copy link
Author

@eernstg Thank you so much for taking the time to write this clarification!

From my humble understanding, the tradeoff is in performance and usability.

Performance-wise, rule-1 is obviously better as methods resolving and inlining are easier.
But let's focus on usability. Rule-2 gives developers further freedom and allows for more creative solutions, especially if true exhaustiveness can be achieved. For that, can we inspire a solution from the observer pattern in SE?
Of course, there's no reactivity or change to be "observed" here, but the idea is similar; let the implementers register themselves at the parent (similar to the registerListener mechanism).

Let's assume that instead of using the standard switch statement, a subclasses of a sealed class have a special private property that archives the switching logic. Let's call it when.

So when (c) is somehow just a syntactic sugar for c.when().
c.when itself, takes care of unfolding the type to its subtypes with the help of the "list of registered implementers/subclasses".
This entails that when a class implements a class that has the when property, the former registers itself at the last's list of implementers, consequently, propagating this information to the topmost class (the sealed class).
Accordingly, when the compiler/analyzer sees when(c) and c is of type C a sealed class, it will keep unfolding its subtypes recursively until it reaches the last non-bottom class in the hierarchy using the "lists of registered implementers" at each level.

P.s. By using words like "list", "property" and "register", I don't mean them in the way we use them in programming. I'm aware that, if my suggestion makes any sense, these concepts need to be implemented on a language level in a much different way using formal linguistics notions far beyond my comprehension. I'm only using the analogy of the observer pattern to deliver my idea.

I'd appreciate your guidance on where I can start fairly light research on this subject (a link or a term to look for). In case what I'm suggesting is fundamentally wrong and there's no single mistake you can point out, you can tell me so by a dislike on this comment 😄.

@eernstg
Copy link
Member

eernstg commented Oct 3, 2022

No dislikes here! ;-)

Maybe you could think about when as a normal instance method. They implement a different kind of exhaustiveness, which is a built-in property of almost every object oriented language: If an abstract class C declares an abstract method m then every concrete subtype of C will have an implementation of m (anything else is a compile-time error).

Traditionally, the approach used in a switch statement has been associated with algebraic datatypes (as in many functional languages). They trivially support exhaustiveness checks because each algebraic datatype has a locally known set of forms.

This tension has been studied for many years, and the phrase 'the expression problem' is a very good starting point.

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

No branches or pull requests

4 participants