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

Extension types #2727

Closed
mit-mit opened this issue Dec 16, 2022 · 85 comments
Closed

Extension types #2727

mit-mit opened this issue Dec 16, 2022 · 85 comments
Assignees
Labels
feature Proposed language feature that solves one or more problems

Comments

@mit-mit
Copy link
Member

mit-mit commented Dec 16, 2022

[Edit, mit-mit, Feb 2024 This feature launched in Dart 3.3; we have docs here.]

[Edit, eernstg: As of July 2023, the feature has been renamed to 'extension types'. Dec 2023: Adjusted the declaration of IdNumber—that example was a compile-time error with the current specification.]
[Edit, mit: Updated the text here too; see the history for the previous Inline Class content]

An extension type wraps an existing type into a new static type. It does so without the overhead of a traditional class.

Extension types must specify a single variable using a new primary constructor syntax. This variable is the representation type being wrapped. In the following example the extension type Foo has the representation type int.

extension type Foo(int i) { // A new type that wraps int.
 
  void function1() {
    print('my value is $i');
  }
}

main() {
  final foo = Foo(42);
  foo.function1(); // Prints 'my value is 42'
}

Extension types are entirely static. This means there is no additional memory consumption compared to using the representation type directly.

Invocation of a member (e.g. a function) on an extension type is resolved at compile-time, based on the static type of the receiver, and thus allows a compiler to inline the invocation making the extension type abstraction zero-cost. This makes extension types great for cases where no overhead is required (aka zero-cost wrappers).

Note that unlike wrapper creates with classes, extension types do not exist at runtime, and thus have the underlying representation type as their runtime type.

Extension types can declare a subtype relation to other types using implements <type>. For soundness, implements T where T is a non-extension type is only allowed if the representation type is a subtype of T. For example, we could have implements num in the declaration of IdNumber below, but not implements String, because int is a subtype of num, but it is not a subtype of String.

Example

// Create a type `IdNumber` which has `int` as the underlying representation.
extension type IdNumber(int i) {
  // Implement the less-than operator; smaller means assigned before.
  bool operator <(IdNumber other) => i < other.i;

  // Implement the Comparable<IdNumber> contract.
  int compareTo(IdNumber other) => this.i - other.i;

  // Verify that the IdNumber is allocated to a person of given age.
  bool verify({required int age}) => true; // TODO: Implement.
}

class Foo implements Comparable<Foo> {
  @override
  int compareTo(Foo other) => 1;
}

void main() {
  int myId = 42424242; // Storing an id as a regular int.
  myId = myId + 10;    // Allowed; myId is just a regular int.

  var safeId = IdNumber(20004242); // Storing an id using IdNumber.
  myId = safeId + 10; // Compile-time error, IdNumber has no operator `+`.
  myId = safeId;      // Compile-time error, type mismatch.

  print(safeId.verify(age: 22)); // Prints true; age 22 matches.
  myId = safeId as int; // Extension types support type casting.
  print(safeId.i);      // Prints 20004242; the representation value can be read.

  final ids = [IdNumber(20001), IdNumber(200042), IdNumber(200017)];
  ids.sort();
  print(ids);

  dynamic otherId = safeId;
  print(otherId.i); // Causes runtime error: extension types are entirely static, ..
  // .. the static type is `dynamic`, and the dynamic type of `otherId` has no `i`.
}

Specification

Please see the feature specification for more details.

This feature realizes a number of requests including #1474, #40

Experiment

A preview of this feature is available under the experiment flag inline-class.

Implementation tracking

See dart-lang/sdk#52684

@mit-mit mit-mit added the feature Proposed language feature that solves one or more problems label Dec 16, 2022
@mit-mit mit-mit added this to Ready for implementation in Language funnel Dec 16, 2022
@ds84182
Copy link

ds84182 commented Dec 19, 2022

I like the simplicity of this proposal in particular. It has all the semantics expected of a wrapper/newtype and in its current form does not seem to have many caveats.

How does this interact with generics? I'm guessing <int>[] as List<IdNumber> succeeds but assignment without a cast is a compile time error. At runtime the cast is still a no-op.

Also, wondering if this could avoid the field. Like:

inline class IdNumber on int {
  IdNumber(super);

  operator <(IdNumber other) => super < other.super;
}

This may be breaking though, as it'll start treating super as an expression and not as a receiver.

@eernstg
Copy link
Member

eernstg commented Dec 19, 2022

I'm guessing <int>[] as List<IdNumber> succeeds but assignment without a cast is a compile time error. At runtime the cast is still a no-op.

Exactly.

wondering if this could avoid the field

That would certainly be possible. The use of an on clause was part of some earlier proposals for this feature, and we also had some proposals for using super to denote "the on object" (now known as the representation object). By the way, it shouldn't create any big issues to treat super as an expression.

However, the main reason why the proposal does not do these things today is that we gave a lot of weight to the value of having an inline class declaration which is as similar to a regular class declaration as possible. For instance, the use of constructors makes it possible to establish the representation object in many different ways, using a mechanism which is well-known (constructors, including redirecting and/or factory constructors).

We may very well get rid of the field in a different sense: We are considering adding a new kind of constructor known as a 'primary' constructor. This is simply a syntactic abbreviation that allows us to declare a constructor with a parameter, and implicitly get a final field with the parameter's type. This is similar to the primary constructors of Scala classes: It is part of the header of the declaration, it goes right after the name, and it looks like a declaration of a list of formal parameters.

inline class IdNumber(int value) {
  bool operator <(IdNumber other) => value < other.value;
}

The likely outcome is that primary constructors will be added to Dart, and they will be available on all kinds of declarations with constructors (that's classes and inline classes, for now). However, the design hasn't been finalized (and, of course, at this point we can't promise that Dart will have primary constructors at all), so they aren't available at this point.

@purplenoodlesoop
Copy link

Does this feature replace the previous proposal of views?

@torellifr
Copy link

Probably you have already considered it, but I wonder if it could be useful to extend the specification to support also inline classes with multiple fields.
Similarly to an inline class with a single field, no additional runtime instance would exist, but only the values of the fields would exist at runtime. A variable which static type is an inline class having n fields, would correspond at runtime to a set of n (hidden) variables (one per field of the inline class).
The effect would be simular to an inline class with a single field that wraps an immutable record, but without the need to have the immutable record and without the disadvantage of having the actual fields nested in another field.
I guess that inline classes with multiple fields could be useful to create efficient code that imposes a discipline on the usage of multiple objects created by third party libraries (e.g. to relate objects created by different libraries), in the same way that an inline class with a single field imposes a discipline on the usage of a single object.

@lrhn
Copy link
Member

lrhn commented Jan 2, 2023

An inline class with multiple fields cannot be assignable to Object, which requires (the reference to) the value to be stored in a single memory location. That means you cannot have a List<MultiValueInlineClass>.

With records, you can store several values in one object, so an inline class on a record type could behave like an inline class on multiple fields, with an extra indirection.

We could consider allowing multiple fields, and then implicitly making the representation type into a record, but then we'd have to define the record structure (all positional in source order, or all named?).
By requiring the class author to choose a record shape, it becomes more configurable.

@eernstg
Copy link
Member

eernstg commented Jan 3, 2023

@purplenoodlesoop wrote:

Does this feature replace the previous proposal of views?

Yes. The main differences are that the construct has been renamed, and the support for primary constructors has been taken out (such that we can take another iteration on a more general kind of primary constructors and then, if that proposal is accepted, have them on all kinds of classes).

@mateusfccp
Copy link
Contributor

@eernstg It seems that this new proposal doesn't have the possibility to show or hide members of the original type. Or am I missing something?

@eernstg
Copy link
Member

eernstg commented Jan 4, 2023

this new proposal doesn't have the possibility to show or hide members of the original type

True. I'm pushing for an extension to the inline class feature to do those things. It won't be part of the initial version of this feature, though.

@timsneath
Copy link
Contributor

I'm curious whether a future version of this feature might include some standardized support for enforcing that the type is a subset of all available values? For example, the IdNumber type used in the example might be required to be a positive eight digit integer, or a US social security identifier might match against a String-based regex.

Even without a formal ranged value feature, it would be nice to have a convention for asserting correctness of a value, for example, having something like bool get isValid that could be overridden by an inline class.

@lrhn
Copy link
Member

lrhn commented Jan 18, 2023

The current version definitely do not provide any such guarantee.
That's what allows us to erase the type at runtime. Because we do that, there is no way to distinguish a List<int> from a List<IdNumber> at runtime, and if we allow a cast of as List<IdNumber> at all, then it has mean as List<int> at runtime where the IdNumber type has been erased.

An alternative where we retain the inline-class type at runtime, so the type List<IdNumber> is different from, and incompatible with, a List<int>, and where we simply do not allow object as IdNumber (or force it to go through a check/constructor invocation defined on IdNumber) would be a way to keep the restriction on which values are accepted at runtime.

I don't think it's impossible. I think making is T go through an implicit operator is constructor-like validator is more viable than disallowing as T entirely when T is a reified inline class type, because that would be hard on generics.
(Unless inline class types are moved outside of the Object hierarchy, and we only allow is/as checks on Object?s, but that'll just make lots of generic classes not work with them, and I don't even know what covariant parameters would do. I guess the inline class types would also have to be invariant.)

That'd be a feature with much different performance tradeoffs, and would need deep hooks into the runtime system in order to have types (as sets of values) that are detached from our existing types (runtime types of objects).

@eernstg
Copy link
Member

eernstg commented Jan 18, 2023

@timsneath wrote:

enforcing that the type is a subset of all available values

We could choose to use a different run-time representation of an inline type (so the representation of IdNumber would be different from the representation of int, but the former probably has a pointer to the latter).

We could then make 2 as IdNumber throw (or it could be a compile-time error), and we could make 2 as X throw when X is a type variable whose value is IdNumber. Similarly, List<IdNumber> could be made incomparable to List<int>, and a cast from one to the other (involving statically known types or type variables in any combination) would throw.

I proposed a long time ago that we should create a connection between casts and execution of user-written code (so e as IdNumber and e as X where X has the value IdNumber would cause an isValid getter to be executed), but this is highly controversial and didn't get much support from the rest of the language team. One reason for this is that it is incompatible with hot reload (where the whole heap may need to be type checked, and runtimes like the VM do not tolerate execution of general Dart code during this process).

It would probably be possible to maintain a discipline where every value of an inline type is known to have been vetted by calling one of the constructors of the inline type, and similarly it might be possible to prevent that the representation object leaks out (with its "original" type).

So there are several things we could do, but we haven't explored this direction in the design very intensively. With the proposal that has been accepted, the run-time representation of an inline type is exactly the same as the run-time representation of the underlying representation type (for instance identical(IdNumber, int) is true, and so is 2 is IdNumber as well as myIdNumber is int).

I believe it would be possible to introduce a variant of the inline class feature which uses a distinct representation of the type at run time and gives us some extra guarantees. We could return to that idea at some point in the future (or perhaps not, if it doesn't get enough support from all the stakeholders ;-).

@timsneath
Copy link
Contributor

Thanks to both of you. Yeah, the feature you describe sounds pretty exciting.

I guess I'm wondering whether the current proposal leads us to having two slightly different solutions to the same use case: inline classes and type aliases. In both cases, the type is erased at runtime; the former is available at compile-time.

I'm very much speaking as a layperson here, so my comments don't have a ton of weight. But if we can only have two of the three (erased at compile/runtime, hybrid, distinct at compile/runtime), would we want to pick the two that have the greatest distinction in usage scenarios, to maximize their value? Inline classes seem to make type aliases redundant, at least for the things I use them for today.

@purplenoodlesoop
Copy link

@timsneath

Both type aliases and inline classes can be directly mapped to Haskell's Type synonyms and Newtypes, I think I even saw direct references in the feature specifications.

I think both have their uses – typedefs carry a semantic value when inline classes explicitly offer new functionality. I see it as two separate use cases.

@eernstg
Copy link
Member

eernstg commented Jan 20, 2023

@timsneath, I agree with @purplenoodlesoop that the two constructs are different enough to make them relevant to different use cases. Here's a bit more about what those use cases could be:

An inline class allows us to perform a replacement of the set of members available on an existing object (a partial or complete replacement as needed, except members of Object). We do this by giving it an inline type rather than its "original" type, aka its representation type (typically: a regular class type).

So we can make a decision to treat a given object in a customized manner—because that's convenient; or because we want to protect ourselves from doing things to that object which are outside the boundary of the inline class interface; or because we want to raise a wall between different objects with the same representation type, so that we don't confuse objects that are intended to mean different things (e.g., SocialSecurityNumber vs. MaxNumberOfPeopleInThisElevator, both represented as an int).

A typedef makes absolutely no difference for the interface, and it doesn't prevent assignment from the typedef name to the denoted type, or vice versa.

On the other hand, we can use a typedef as a simple type-level function, and this makes it possible to simplify code that needs to pass multiple type arguments in some situations. Here is an example where we also use the typedef'd name in an instance creation:

class Pair<X, Y> {
  final X x;
  final Y y;
  Pair(this.x, this.y);
}

typedef FunPair<X, Y, Z> = Pair<Y Function(X), Z Function(Y)>;

void main() {
  var p = FunPair((String s) => s.length, (i) => [i]);
  print(p.runtimeType);
  // 'Pair<(String) => int, (int) => List<int>>'.
}

FunPair provides a customized version of the Pair type which is suitable for creating pairs of functions that we can compose (so we're making sure that we don't make mistakes by writing Y Function(X), Z Function(Y) again and again).

In the first line of main we create a new FunPair. We need to write the type of the first argument (String), but the type inference machinery ensures that we can get the other two types correct without writing any more types. If you change FunPair to Pair in that expression, the second argument gets the type List<dynamic> Function(dynamic).

I think this illustrates that typedefs and inline classes can at least be used in ways that are quite different, and my gut feeling is that there won't be that many situations where we could use one or we could use the other, and there's real doubt about which one is better.

@timsneath
Copy link
Contributor

This is a really helpful explanation, thanks @eernstg! (We should make sure that we add something like this to the documentation when we get to that point...)

@mit-mit mit-mit moved this from Ready for implementation to Being implemented in Language funnel Jan 23, 2023
@yjbanov
Copy link

yjbanov commented Feb 19, 2023

I could be missing a bunch of aspects to this, but I think given Dart's direction towards a sound language, it's worth exploring how much the soundness can be preserved with this feature. Specifically, the most useful soundness guarantee would be during the int => SocialSecurityNumber conversion. The inverse - SocialSecurityNumber => int - can be relaxed to enable compatibility with general purpose containers, and Object/Object?. There's not much to validate when going from a subset to a superset anyway, since by definition the superset allows all elements of the subset.

One issue we'd want to avoid is reintroduce the wrapper object problem for containers, since to get a type-safe conversion from List<int> one would need to wrap it into a List<SocialSecurityNumber>, e.g. by calling map<SocialSecurityNumber>(...).toList().

This is where having as invoke the constructor of the inline class would be useful. However, we only need a limited one. Namely, as would only invoke the constructor of the outer type. It would not include any magic for converting generic types. Since List<int> as List<SocialSecurityNumber> is disallowed, one could create an inline class that validates the contents of List<int> and produces a soundly checked collection of SocialSecurityNumber. For example:

inline class InlineList<F, inline T on F> {
  InlineList(this._it);

  final List<F> _it;

  T operator[] (int index) => _it[index] as T;

  List<T> cast() => List.generate(_it.length, (i) => _it[index] as T);
}

Here InlineList is a wrapperless view on List<F>. It denotes that type T is an inline class type that wraps F. This is so that expression _it[index] as T inform the compiler that T's constructor must be called and that T is an inline type that accepts F as its underlying representation. Note that the language does not need to specify, and the compiler does not need to implement, how to iterate over and type check the contents of the underlying representation (which would be the case if we demanded support for List<int> as List<SocialSecurityNumber> expression to be supported and be sound). That would be difficult to do since there could be any number of collection types, including those not API-compatible with those from the standard libraries.

Now we can safely view a List<int> as a collection of SocialSecurityNumber:

typedef SocialSecurityNumberList = InlineList<int, SocialSecurityNumber>;

void main() {
  final rawList = <int>[1, 2, 3, 4, 5];
  final ssnList = rawList as SocialSecurityNumberList;
  print(ssnList[3]); // SocialSecurityNumber constructor invoked here
}

The following key properties are preserved:

  • int => SocialSecurityNumber conversion does not bypass validation.
  • There are no wrapper objects, with one caveat:
    • If SocialSecurityNumberList must be passed to a utility that takes List, it either needs to be cast back to List<int> or wrapped using InlineList.cast() method.

I'm not sure what the issue is with hot reload. Object representations are still the same. If the issue is with soundness, then for hot reload it can be relaxed. Hot reload already has type related issues. After all, all we do is validate the permitted value spaces. This is already an issue with hot reload. Consider expression:

list.where((i) => i.isOdd)

It extracts odd numbers out of a list, and potentially stores it somewhere in the app's state. Now what happens when you change it to:

list.where((i) => !i.isOdd)

Then hot reload. Any new invocations will produce even numbers. However, all existing outputs of this function stored in the app state will continue holding onto odd numbers. All bets are off at this point, and you have to restart the app (luckily there's hot-restart).

So I think it's totally reasonable for hot reload not preserve that level of soundness.

@lrhn
Copy link
Member

lrhn commented Feb 20, 2023

The as you're talking about here would be like a "default constructor" or default coercion operator, from representation type to inline class type, carried by the inline class somehow.

It cannot work with the current design. When we completely erase the inline class at runtime, the _it[index] as T cannot see that T is an inline class type, because it isn't. The type of T at runtime is the representation type.

Even if we know that the T was originally an inline class, from the inline T on F bound, we don't know which one, not unless we let the T carry extra information at runtime, which is precisely what we don't do. All that information is erased. It ceased to be. It's an ex-information.

In short: You cannot abstract over inline classes, because abstracting over types is done using type variables, which only get their binding at runtime, and inline classes do not exist at runtime.
Same reason extension methods of a subclass doesn't work with type parameters, all you know statically is the bound of the type parameter, not the actual value, and anything statically resolved will see only that.

We could make all inline classes automatically subtype a platform type InlineClass<T> where T is their representation type. Just like we make all enums implement Enum, functions implement Function and records implement Record.

That will allow one to enforce that a static type is an inline class type, and be used to bound type parameters like suggested above. (No need for special syntax like inline T on F.)
Not sure how useful that is going to be in practice with the current inline class design, but it can allow us to add operations that work on all inline class types.

We can make InlineClass<T> assignable to T by "implicit side-cast" if we want to, without making the actual inline types so. Not particularly necessary, since you can always do an explicit as T instead. Probably not worth it.

So, basically, the current inline class design does not provide a way to guard a type against some values of the representation type, no way to create subsets of types. Anything which requires calling a validation operation at runtime is impossible to enforce, because we can skirt it using the completely unsafe as, and going through generics.

The only security you get against using an incompatible int as a social security number is that there is no subtype relation between the two, and no assignability, so you can't just assign a List<int> to List<SocialSecurityNumber>. You need to do a cast. Which makes the SocialSecurityNumber abstraction safe, but not sound. (Normal casts are sound, not safe. They throw if the conversion isn't valid. Casts from representation type to inline class types always succeeds, but doesn't necessarily preserve all notions of soundness that the inline class might want to preserve, because it cannot do checks at runtime.)

If your program contains no as casts, or dynamic values, it's probably type sound.

@yjbanov
Copy link

yjbanov commented Feb 22, 2023

@lrhn

Thanks for the detailed explanation! Yeah, this would need extra RTTI to work.

If your program contains no as casts, or dynamic values, it's probably type sound.

Cool. If we require explicit casts, then maybe this is safe enough as is.

Anyway, looking forward to trying this out! This is my favorite upcoming language feature so far 👍

@Peng-Qian
Copy link

Is this feature will be shipped in Dart 3.0? It seems perfect to create value objects by the inline class.

@mraleph
Copy link
Member

mraleph commented Feb 24, 2023

Is this feature will be shipped in Dart 3.0? It seems perfect to create value objects by the inline class.

It is not part of 3.0.

@eernstg
Copy link
Member

eernstg commented Feb 24, 2023

@yjbanov wrote:

it's worth exploring how much the soundness can be preserved with this feature.

Note that we had some discussions about a feature which was motivated by exactly this line of thinking:

In an early proposal for the mechanism which is now known as inline classes, there is a section describing 'Protected extension types'. The idea is that a protected extension type is given a separate representation at run time when used as a part of a composite type (that is, as a type argument or as a parameter/return type of a function type). This means that we can distinguish a List<SocialSecurityNumber> from a List<int> at run time (but we still don't have a wrapper object for an individual instance).

The main element of this feature is that an inline class can be protected, which means that it must have a bool get isValid getter which will be invoked implicitly at the beginning of every generative constructor body, and there's a dynamic error if the given representation object isn't valid.

protected inline class SocialSecurityNumber {
  final int value;
  SocialSecurityNumber(this._value); // Implicit body: { if (!isValid) throw SomeError(...); }
  bool get isValid => ...; // Some validation.
}

void main() {
  var ssn = SocialSecurityNumber(42); // Let's just assume that the validation succeeds.

  // Single object.
  int i = ssn.value; // The inline class can always choose to leak the value.
  i = ssn as int; // We can also force the conversion: Succeeds at run time.
  ssn = i as SocialSecurityNumber; // Will run `isValid`.

  // Higher-order cases.
  List<int> xs = [42];
  List<SocialSecurityNumber> ys = xs; // Compile-time error.
  ys = xs as List<SocialSecurityNumber>; // Throws at run time.
  xs.forEach((i) => ys.add(i as SocialSecurityNumber)); // Runs `isValid` on each element.

  void f<Y>(List xs, List<Y> ys) {
    xs.forEach((x) => ys.add(x as Y)); // Runs `Y.isValid` on `x` when `Y` is a protected inline type.
  }
  f(xs, <SocialSecurityNumber>[]); // OK.
  f([41], <SocialSecurityNumber>[]); // Throws if 41 isn't valid.
}

This mechanism would rely on changing the semantics of performing a dynamic type check. This is the action taken by evaluation of i as T for some T, but also the action taken when executing ys.add(e as dynamic) when ys is a List<I> where I is a protected inline type, and also the action taken during covariance related type checks, etc.

The fact that the invocation of isValid occurs whenever there is a dynamic check for a protected inline type is quite promising, in the sense that it would probably suffice to ensure that no object o will ever have static type I where I is a protected inline type, unless o.isValid has returned true at some point.

Of course, if o is mutable and isValid depends on mutable state then o.isValid can be false in the current state. This is an issue that developers need to reason about "manually".

This would work also in the case where the source code has a type variable X whose value is a protected inline type: A run-time check against a given type which is a type variable is already performed using a thunk (a low-level function), at least on the VM. This thunk is specific to the given value of the type variable. This means that there is no additional cost for checking against any type which is not a protected inline type (they would just have a thunk which does exactly the same thing as today), and the thunk for a protected inline type can run isValid on the given object.

We could express the InlineList as follows:

inline class InlineList<X, I> {
  final List<X> _it;
  InlineList(this._it);
  I operator [](int index) => _it[index] as I; // Runs `isValid` if `I` is protected.
  List<I> cast() => List.generate(_it.length, (i) => _it[index] as I);
}

The whole idea about protected inline classes was discussed rather intensively for a while, e.g., in #1466, but it did not get sufficient support from the rest of the language team. Surely there would still be a need to sort out some details, and in particular it would be crucial that it is a pay-as-you-go mechanism, not an added cost for every dynamic type check.

@yjbanov
Copy link

yjbanov commented Mar 1, 2023

I totally agree that it should be pay-as-you-go. The zero-cost abstraction is the main feature of this. Otherwise, the current classes would be just fine. The kind of safety I'm looking for is mostly development-time safety, where performance is not as important as ability to catch bugs. There's not much recovery to do if isValid returns false while one is trying to stuff a value in a list. It's like a RangeError and indicates a bug in the program. In practice, having isValid contain assertions only, and when compiled in release mode, evaporate entirely (e.g. so that a huge JSON tree can be wrapped in a typed data structure in O(1) time in release mode, even if it's validated in O(N) time during development).

But yeah, if this makes the language too complex, then this is fine as specified.

@eernstg
Copy link
Member

eernstg commented Mar 1, 2023

Silly idea: We could have a special variant of inline classes: assert(protected) inline class I .... It would be required to declare a bool get isValid member, and it would work exactly as a protected inline class as described above when assertions are enabled. In particular, it would implicitly run isValid to check the validity of every dynamic type check for being an I, and it would throw if the given object isn't valid and the type is required (as), or it would return false if the type isn't required (is).

When assertions are not enabled it would be a plain inline class. isValid would no longer be invoked implicitly, and the run-time representation of the inline type I would now be identical to the run-time representation of the representation type.

This means that myObject as I would be a no-op (and recognized as such by the compiler) when the static type of myObject is the representation type of I. For higher-order "conversions" we'd use a few helper functions:

List<I> castList<X, I>(List<X> xs) {
  if (X == I) return xs as List<I>;
  var ys = <I>[];
  for (var x in xs) ys.add(x as I);
}

During a production execution we would have X == I (considering the intended invocation where X would be int and I would be SocialSecurityNumber or something like that), and hence we would shortcut the conversion. During a development execution we would have X != I, and the validation would occur on each element of the list.

It is a silly idea, of course, because we would make production execution substantially different from development execution (some types are different in one mode and identical in another), and that is always a serious source of difficulties, e.g., because some bugs occur in one mode and not in the other. But it's still kind of fun. ;-)

@lrhn
Copy link
Member

lrhn commented Jul 25, 2023

While extensions do have a "constructor-invocation-like" syntax for explicit invocations, it's very fundamental that they do not introduce any new types. And extension types do, which is an equally fundamental part of that feature. So I disagree that extensions create types in any way.

I also want to allow the syntax

extension Foo(int it) {
  …
}

for extensions, giving them an easy way to name the receiver object, instead of using the overloaded on token and using this, so it gets more clearly associated with mixins. Let's see if I succeed with this idea.

@torellifr
Copy link

torellifr commented Jul 26, 2023

@lrhn

I also want to allow the syntax extension Foo(int it) {…} for extensions

If you mean to adopt this syntax for 'extension types', OK.
If you mean to adopt this syntax also for 'extension methods', I agree that then we need the 'type' token for the syntax of the extension type feature. Anyway I'm not sure that such a change to the extension methods syntax would be an advantage. I have the feeling that it would make less clear that in the case of the extension methods you are actually extending the existing type 'int'.

P.S.: from the 'conceptual' point of view, when we add extension methods to an existing type T, we are adding one or more super types to T. The names 'NumberParsing' and 'NumberParsing2' in the examples of the official documentation of the extension methods can be conceived as the names of such 'conceptual' super types. With such an interpretation in mind I would have preferred a cast notation for disambiguation and explicit invocation of extension methods (instead of the "constructor-invocation-like" one), for example:

('42' as NumberParsing).parseInt()
('42' as NumberParsing2).parseInt()

@modulovalue
Copy link

There's an incorrect statement in the issue description which could lead to some confusion:

The issue description says:

Extension types can implement the API of other types using implements <type>.

That seems to imply that extension types can implement any type. However, extension types can only implement types that are supertypes of the representation type.

Error: The implemented interface 'int' must be a supertype of the representation type 'String' of extension type 'Foo'.
extension type Foo(String s) implements int {

@lesnitsky
Copy link

lesnitsky commented Dec 23, 2023

It's probably too late to bring this up, but the fact that the extension type and the original one are indistinguishable at runtime is very confusing.

I was hoping that extension types could solve the problem of variable shadowing in Flutter.
Here's a problem I'm trying to solve:

provide<T>(BuildContext context, T value, [Object? token]) {... }
// mounts an InheritedModel that has a value of type Map<(Type, Object?), Box> 

consume<T>(BuildContext context, T value, [Object? token]) {...}
// subscribes to an InheritedModel with an `aspect` that holds (Type, Object?)

(Type, Object?) is a workaround that allows to distinguish different kinds of the same type, e.g.:

provide('US', #country);
provide('NY', #city);

I wanted to get rid of this workaround since the very moment I implemented it, so my first instinct, after learning extension types are available under the experimental flag, was to try this:

extension type Country(String name) {}
extension type City(String name) {}

provide(Country('US'));
provide(City('NY'));

//...

consume<City>() // 'NY' – ok
consume<Country>() // 'NY' – Country is shadowed by City

I understand all the benefits of having zero overhead for extension types, but not being able to distinguish between an extension type and an original one makes the whole feature less usable.

Is there a way to have the same level of overhead and performance but add a way to distinguish types? (e.g. have a view of a Type object with only hashCode and ==)
If not, are there any language features planned that could help to solve the same problem (little boilerplate, low overhead mechanism for defining type aliases to add more meaning to the primitive type and be able to use a type as a lookup key)?

@lrhn
Copy link
Member

lrhn commented Dec 24, 2023

There is pretty much no chance of adding runtime overhead to extension types, because then it would be a different feature.

What you're asking for is just small classes,

class Country {
  final String name;
  const Country(this.name);
}

Shorter syntax for that is possible, for example the "primary constructor" proposal which would make it abbreviable to:

class const Country(final String name);

Alternatively, you can use extension types to hide the tag-record hack:

extension type const Tag._((Symbol?, Object?) _) {
  const Tag(Object? key) : this._((null, key));
}
extension type const City._((Symbol, String) _) implements Tag {
  const City(String name) : this._((#_city, name));
}
extension type const Country._((Symbol, String) _) implements Tag {
  const Country(String name) : this._((#_country, name));
}

so you can do:

provide(City("NY'), ...);
provide(Country("US"), ...);
provide(Tag(SomethingUnique()), ...);
...
var city = consume(City("NY"));
var country = consume(City("US"));

and provide and consume takes Tag as argument (name it as appropriate for your use-case).

@lesnitsky
Copy link

Primary constructor solves the "less bolierplate" part of it, but has an overhead of creating a wrapper object.

extension types to hide the tag-record hack

This looks interesting, but given these tags should be declared by user, it looks even more hacky :) (extension type itself). Possibly could be solved with macros

I was also thinking about something like this:

abstract class Kind<T> {}
class Country extends Kind<String> {}
class City extends Kind<String> {}

which could then theoretically be used as

provide<City>("NY");
provide<Country>("US");

but Dart can't infer a generic type unless specified like this:

provide<T, K extends Kind<T>>(T);

which makes the example above unnecessarily verbose

provide<String, City>("NY");
//      ^^^^^^ we already know that City extends Kind<String>

Maybe there is a way to allow "optional" generic type

provide<K extends Kind<T>, [T]>(T);

or have a keyword to enforce type inference of a generic type

provide<K extends Kind<infer T>>(T);

@lrhn
Copy link
Member

lrhn commented Dec 24, 2023

Primary constructor solves the "less bolierplate" part of it, but has an overhead of creating a wrapper object.

If you don't create a wrapper object, you get the current extension types, which are indistinguishable from the original object.
There needs to be more data at runtime to be able to make a distinction. That data has to be somewhere, and currently that means there has to be an object to store it in.

(The possible alternative would be "fat pointers" that remember the representation object and the extension types, uses two memory slots for it, but ensures that they are always inlined. That's basically just an always-inlined wrapper object. And it's fundamentally what Rust traits do, and if we wanted to do something like that, we should do it properly, not as a way to add reified types to extension types. They could probably replace extensions, extension types, and possibly mixins.)

@FMorschel
Copy link

Could #3024 be generalized to that?

@eernstg
Copy link
Member

eernstg commented Feb 5, 2024

@lrhn wrote:

(The possible alternative would be "fat pointers" ... it's fundamentally what Rust traits do ...)

I wanted to comment on this topic at some point, but apparently never got around to doing it. Before now. ;-)

I think it's important to note that Rust has no subtyping. See, for example the introduction to the section about subtyping and variance in the Rust Reference.

Subtyping is restricted to two cases: variance with respect to lifetimes and between types with higher ranked lifetimes. If we were to erase lifetimes from types, then the only subtyping would be due to type equality.

As it says, the only subtype relation that exists if we ignore lifetimes is T <: T. (It's reasonable to ignore lifetimes when comparing Dart types and Rust types, because they are only concerned with memory management, and that's handled by the garbage collector in Dart).

Rust uses coercions in a number of situations where it might be tempting (from an OO point of view) to assume that the operation is a plain assignment of a reference value to a storage location such as a variable or a formal parameter (this kind of assignment would rely on subtyping—which tells us that the actual semantics must be different, because we don't have subtyping).

What really happens with dynamically resolved trait member invocations is that an entity of a type T is wrapped in a small trait object (that's the 'fat pointer'), and this is only possible because it is known at compile-time that the entity has type T (precisely T, since there are no subtypes) and the specific trait implementation delivers the implementation of the relevant trait members for an entity of type precisely T.

In Dart, this corresponds to the following:

abstract class MyTrait {
  void aTraitMethod();
  void anotherTraitMethod();
}

class MyTraitForT implements MyTrait {
  final T t;
  MyTraitForT(this.t);
  void aTraitMethod() {...}
  void anotherTraitMethod() {...}
}

class T {...}

void main() {
  var t = T();
  MyTrait myTrait = MyTraitForT(t);
  myTrait.aTraitMethod();
}

The other common case is when the invocation of the trait member occurs in a situation where the type of the underlying entity is known. That is, the trait member implementation is resolved statically. In Dart, this corresponds to the following:

extension type MyTraitExtension(T it) implements T {
  void aTraitMethod() {...}
  void anotherTraitMethod() {...}
}

void main() {
  var t = T();
  var myTrait = MyTraitExtension(t);
  myTrait.aTraitMethod();
}

The extension type has implements T because we should be able to invoke members of T as well as members of MyTrait.

Some earlier versions of the extension type proposal had support for "boxing" an extension type. That is, the extension type could be used with statically resolved member invocations (just like the extension types that we actually have in the upcoming release), and then there was a reified version as well. That is, the extension type declaration would automatically give rise to a class declaration with the same members and member signatures (this class would be automatically generated by the compiler, if needed).

You'd go from the unboxed (statically resolved) version to the boxed version by invoking .box on an expression whose type is the extension type, and you could write a member, say, a getter named unbox, returning the representation object typed by the extension type in order to support the opposite transition.

In the example above, the declaration MyTraitExtension would implicitly cause a declaration like MyTraitForT to be generated, and .box would be used to obtain an instance of the latter from an expression whose type is the former.

It wouldn't be hard to reintroduce some variant of the boxing feature, but it does give rise to some distinctions that we don't have with the current extension type mechanism. Let E be an extension type and EBox the corresponding class that "boxes" E:

  • In the boxed form, we have a real object with identity and it contains a reference to the representation object; in the unboxed form the run-time representation is the representation object; this means that the latter will be different from the former (according to identical), and the former can be different from each other even though we may think that it's "the same object". This means that object identity is going to be a lot more tricky if we work very hard to enable a seamless transition from the boxed to the unboxed form and vice versa. We might then want to use something like a struct (that doesn't have identity, similarly to records) rather than a class for EBox, and that might help, but this is also a non-trivial extension of the language.
  • Member signatures may return the declared type. For example, the extension type E may have a method m whose return type is E). However, does this mean that EBox should have a method m that returns an EBox? How about return types like List<E> vs. List<EBox>? How about parameter types?

The main reason why we don't support boxing of extension types is that there are many questions of this nature, and no obviously optimal answers.

Dart (and other OO languages) offer a huge amount of flexibility based on subtyping, and that makes it a lot harder to know exactly when and how to perform coercions (and we probably don't want implicit coercions to occur all over the place, especially if they create pervasive identity confusion). Rust omits much of the flexibility by removing subtyping; this makes it possible for Rust to use coercions to make it seem like a given datatype supports the methods declared directly for that datatype as well as a bunch of traits members using compile-time resolution, and also the trait members using run-time dispatch.

However, this is a non-trivial trade off. Consider, for example, this stackoverflow question illustrating how difficult it is to compare two trait objects for equality.

My conclusion is that Rust is an extremely interesting language with meaningful and well-orchestrated elements, but also that OO languages (and in particular: Dart) have chosen a very different set of trade offs, and the OO side does get a large amount of expressive power, flexibility, and encapsulation, in return for knowing less about the run-time situation at compile time. In other words, Rust traits are great for Rust, but it doesn't make sense to say that we should add them to Dart, because they won't fit, and we don't necessarily want to make the (deep and radical) changes to Dart that would make them fit. ;-)

@mit-mit
Copy link
Member Author

mit-mit commented Feb 20, 2024

Extension types launched in Dart 3.3 🥳

@mit-mit mit-mit closed this as completed Feb 20, 2024
@mit-mit mit-mit moved this from Being implemented to Done in Language funnel Feb 20, 2024
@mit-mit
Copy link
Member Author

mit-mit commented Feb 20, 2024

Documentation available here: https://dart.dev/language/extension-types

@cedvdb
Copy link

cedvdb commented Feb 25, 2024

I find the documentation on redeclaring a bit confusing.

Why can't I do this ?

import 'package:meta/meta.dart';

void main() {
  final Seats remainingSeats = Seats(3) - Seats(1);
}

extension type const Seats(int _value) implements int {

   // ...

  @redeclare
  Seats operator +(Seats other) => Seats(_value + other._value);

  @redeclare
  Seats operator -(Seats other) => Seats(_value - other._value);
}

@eernstg
Copy link
Member

eernstg commented Feb 26, 2024

@cedvdb, please create a new issue for a new topic: This issue was concerned with adding the extension type feature to the language. All the details about how this feature works or should work are being handled in other issues.

For issues dealing with the implementation of the feature: Please use the SDK repository,

For issues dealing with the language design that is now known as extension types: Check out the extension-types topic as well as extension-types-later.

@eernstg
Copy link
Member

eernstg commented Feb 26, 2024

That said, I can't see why you wouldn't be able to do as shown here. The example compiles and runs just fine (using a fresh SDK, a slightly older one, 3.4.0-140.0.dev, and the Stable channel of DartDev), and the @redeclare metadata annotations are used exactly as intended.

[Edit: OK, tried DartDev one more time, and this time I got the error. So @lrhn's comment is the relevant one: This behavior has been fixed and will be in a released version soon, just not yet.]

@lrhn
Copy link
Member

lrhn commented Feb 26, 2024

As Erik says, the code compiles and runs. There is a bug in the analyzer in the 3.3.0 release, which appears to use the special typing rules for int + int even if the + operator being called is not the one from int. That's why it complains that the type int is not assignable to remainingSeats. The analyzer is wrong here. It has been fixed.
I don't know if the fix will be in a 3.3.1 release.

@masreplay
Copy link

How to use assert with extension type?

@albertms10
Copy link

How to use assert with extension type?

You might use a named private constructor in the extension type shortand and declare a public constructor as usual:

extension type const Ratio._(num value) implements num {
  const Ratio(this.value)
      : assert(value > 0, 'Value must be positive, non-zero');
}

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
Status: Done
Development

No branches or pull requests