dart-lang / language

Design of the Dart language
Other
2.68k stars 205 forks source link

Questions about "extension types" #3532

Open matanlurey opened 11 months ago

matanlurey commented 11 months ago

Hi folks! Really excited, but I admit I have a hard time reading the specification and couldn't find good examples to refer to.

Feel free to close if these are all handled, or shouldn't be handled (though I suspect they should). Most of these questions come from wanting to avoid so-called "primitive obsession" in our code, i.e. over-using primitives such as int for dubious reasons (convenience, terseness, performance):

  1. Can the "constructor" have logic similar to below?

    I understand it will be possible to do so-called "unsafe" casts (possibly catchable with a lint), but for the "normal" use case, can I require validation before (explicit) conversion? For example, can I limit the Level type to integer values of 1 through 20?

    /// A level of a character in D&D 5e. 1-20 inclusive.
    final class Level {
     final int value;
     Level(this.value) {
       RangeError.checkValueInInternval(value, 1, 20);
     }
    }
    
    extension type Level(int i) {
     /* what do I write here? */
    }
  2. Is == and hashCode forwarded for me? If so, how about these cases?

    In short, if IdNumber(1) == 1, I'm going to be sad.

    extension type Strength(i) {}
    extension type Dexterity(i) {}
    
    void main() {
     final strength = Strength(3);
     final dexterity = Dexterity(3);
     final constitution = 3;
    
     print(strength == strength); // true (I hope)
     print(strength == dexterity); // false (I hope), if it's true I ... understand, but hope a lint will catch this.
     print(strength == constitution); // false (I hope), if it's true I ... understand, but hope a lint will catch this.
    }
  3. Is my/can my extension type be, say, Comparable?

    extension type Level(int i) implements Comparable<Level> {
     /* what do I write here? */
    }

    Is it comparable by default because int is?

    What if I'm wrapping a type that isn't comparable, such as Uri?


I'm worried that without support for these 3 scenarios, it's still going to be attractive to either continue using primitives, or extension types will be used, but they'll lack a lot of the flair of the pattern in say, Rust's newtype idiom.

Thanks for listening!

lrhn commented 11 months ago

Ad 1:

extension type Level._(int value) {
  Level(this.value) {
     RangeError.checkValueInInterval(value, 1, 20);
  }
}

You need the "primary constructor" declaration as part of the type declaration, but you can make it private and provide another public constructor instead.

As you say, you can't prevent someone from writing -1 as Level.

Ad 2: Yes. All the "members of Object" are forwarded directly to the representation type. You can't redeclare them, so they always work as the representation object.

Ad 3. You can implement any interface type that's a supertype of the representation type. The extension type does not implement an interface implicitly, you have to ask for it.

In this case, you can't implement Comparable<Level> because that's not a supertype of the interface implemented by the representation type, int implements Comparable<num>, and even if Level had implemented int, Comparable<Level> is a subtype, not a supertype, of Comparable<num>. (If we had variance annotations, Comparable would be contravariant, and then it would have worked. But we don't.) As it is, Level is unrelated to int and num.

matanlurey commented 11 months ago

Thanks for the context (and @mraleph for engaging on the bird app).

I believe as written, I won't be able to utilize extension types much, as they seem only suitable for providing methods on variants of objects - potentially super useful for stuff like FFI or JS Interop, but less useful for domain objects. I do wonder what that means for something like:

assert(foo is Pointer); // will this basically always be true? will lints/diagnostics try to catch these problems?
lrhn commented 11 months ago

If we change Pointer to be an extension type wrapping an int, then 2 is Pointer will be true. Because it is a pointer, if you want it to be. You can write Pointer<Void>.fromAddress(2) today, and you can write the same for the extension type. And you can then also do 2 as Pointer<Void> in good C-style.

There are many use-cases that extension types won't be good for. They do not intend to replace classes. Most likely, almost all of your types will keep being classes, because classes provide virtual methods, encapsulation, abstraction, and retained type parameters. All the reasons you use object oriented programming today, instead of just passing data around and calling static functions in good imperative style.

Extension types are primarily aimed at things like integration and interoperability, where the representation value is an artificial Dart container for the real non-Dart data, which has a Dart API which doesn't reflect the API that you want. Like an FFI struct on top of a pointer or typed data buffer. Those could also be Dart wrapper classes, but the allocation overhead of repeatedly boxing and unboxing for a large API is measurable (fx in JS integration or protobuf). Extension types are intended to address that problem.

Now, if you find other uses for it, that's fine, even expected, but those uses will come with inherent limitations, which a class based solution wouldn't have.

For example, extension types do not attempt to allow you to define subset types, like EvenInt which only allows the even integers. You can try, by only providing a public constructor that rejects odd integers, but you can't prevent 3 as EvenInt. That's just not a supported use-case for extension types, whereas a class would be able to trust that creation goes through a constructor.