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] Allow a redirecting factory constructor to redirect to a representation type constructor #3794

Open
eernstg opened this issue May 10, 2024 · 9 comments
Labels
extension-types small-feature A small feature which is relatively cheap to implement.

Comments

@eernstg
Copy link
Member

eernstg commented May 10, 2024

This is a proposal that we should allow a redirecting factory constructor of an extension type to redirect to a constructor of a type which is the representation type or some subtype thereof. For example:

class _C<X> {
  final X x;
  const _C(this.x);
  X get value => x;
}

extension type C<X>._(_C<X> _) implements _C<X> {
  const factory C(X x) = _C; // Currently an error; proposal is to allow it.
  X conditionalValue(bool condition, {required X Function() orElse}) =>
      condition ? x : orElse();
}

void main() {
  C<num> c = C<int>(14);
  num n = 2;
  n = c.conditionalValue(true, orElse: () => n); // OK, yields 14.
}

The point is that there is no soundness related reason to not allow this, and it is helpful in the case where we wish to use the statically known receiver type to provide the value of a type argument like X (which is often desirable for named arguments with the name orElse ;-).

In contrast, consider the following:

final class C<X> {
  final X x;
  const C(this.x);
  X get value => x;
  X conditionalValue(bool condition, {required X Function() orElse}) =>
      condition ? x : orElse();
}

void main() {
  C<num> c = C<int>(14);
  num n = 2;
  c.conditionalValue(true, orElse: () => n); // Throws!
}

In this variant, a run-time type error occurs because the argument passed to orElse has type num Function(), but the covariance based run-time type check requires an int Function(). In this kind of situation, the covariance based run-time type check is actually harmful, because there is nothing in the logic of the given method that requires this function to have the more special type, it is all fully well-typed as seen from the call site and at run time when we rely on the statically known value of the type variable.

We could get a similar effect by introducing support for lower bounds on the type parameters of generic functions (see #1674), but this proposal seems simpler, and none of these proposals subsume each other completely (for example, the lower bounds feature wouldn't allow the C extension type constructor to be constant).

@eernstg eernstg added small-feature A small feature which is relatively cheap to implement. extension-types labels May 10, 2024
@lrhn
Copy link
Member

lrhn commented May 11, 2024

This is a variant of the "implicit cast from representation type to extension type" idea.

It's shorter than expanding to => Superclass(args); and allows being a const factory constructor.

There needs to be a cycle check, and that cycle check can make some refactorings breaking changes (but probably only between already cooperating declarations).

final class C {
  factory C() = D;
  C._();
}
extension type D._(C _) {
  factory D() = C._;
}

If the author of C decides to change which of the constructors is forwarding to the other, making C._ forward to D, then we have a factory cycle.
That should only happen if the superclass has a forwarder to the extension type, which implies cooperating with that type. (Forwarding to an extension type is a very particular use case, because being the extension type is lost in the upcast.)
Still, it's a cross class dependency on whether and where constructors are forwarding, what we don't have today, because subtypes cannot forward to supertypes.

@matanlurey
Copy link
Contributor

I ran into this in a small hobby project. I would have loved to do something like:

// Internal representation.
final class _Vec2 {
  const _Vec2(this._dx, this._dy);
  final int _dx;
  final int _dy;
}

extension type const Size._(_Vec2 _impl) {
  const factory Size(int width, int height) = _Vec2;
  
  int get width => _impl._dx;
  int get height => _impl._dy;
}

extension type const Point._(_Vec2 _impl) {
  const factory Point(int x, int y) = _Vec2;
  
  int get x => _impl._dx;
  int get y => _impl._dy;
}

But instead I need to define a base type and 2 classes.

@lrhn
Copy link
Member

lrhn commented Jun 19, 2024

That one is tricky because we can't make a subtype run a supertypes const constructor as const, and we can't redirect to a supertype constructor.

But with records, we don't need to, because a record expression is potentially constant!(!!! For emphasis!)

Could do:

extension type const Size._(({int x, int y}) _) {
  const factory Size(int width, int height) : this._((x: width, y: height));
  
  int get width => _.x;
  int get height => _.y;
}

extension type const Point._(({int x, int y}) _) {
  const factory Point(int x, int y) : this._((x: x, y: y));
  
  int get x => _.x;
  int get y => _.y;
}

The field names can't be private, though.

@eernstg
Copy link
Member Author

eernstg commented Jun 19, 2024

can't make a subtype run a supertypes const constructor as const, and we can't redirect to a supertype constructor.

This issue is a proposal to allow exactly that, for the special case where "the supertype" is the representation type of an extension type which is "the subtype". The fact that this allows the constructor to be constant is the main motivator for the proposal.

Otherwise, we could just have done this:

// Internal representation.
final class _Vec2 {
  const _Vec2(this._dx, this._dy);
  final int _dx;
  final int _dy;
}

extension type const Size._(_Vec2 _impl) {
  Size(int width, int height): this._(_Vec2(width, height));
  
  int get width => _impl._dx;
  int get height => _impl._dy;
}

extension type const Point._(_Vec2 _impl) {
  Point(int x, int y): this._(_Vec2(x, y));
  
  int get x => _impl._dx;
  int get y => _impl._dy;
}

@mmcdon20
Copy link

That one is tricky because we can't make a subtype run a supertypes const constructor as const, and we can't redirect to a supertype constructor.

But with records, we don't need to, because a record expression is potentially constant!(!!! For emphasis!)

Could do:

extension type const Size._(({int x, int y}) _) {
  const factory Size(int width, int height) : this._((x: width, y: height));
  
  int get width => _.x;
  int get height => _.y;
}

extension type const Point._(({int x, int y}) _) {
  const factory Point(int x, int y) : this._((x: x, y: y));
  
  int get x => _.x;
  int get y => _.y;
}

The field names can't be private, though.

This works in current dart language if you remove the factory keyword.

@matanlurey
Copy link
Contributor

But with records, we don't need to, because a record expression is potentially constant!

This works in current dart language if you remove the factory keyword.

Both of these suggestions has different semantics. It seems reasonable to want to have other methods on Vec2, and to not have them apply to all ({int x, int y}) by means of an extension.

@mmcdon20
Copy link

mmcdon20 commented Jun 20, 2024

@matanlurey How about this?

extension type const _Vec2._((int, int) _impl) {
  int get _dx => _impl.$1;
  int get _dy => _impl.$2;
  // other methods here
}

extension type const Size._(_Vec2 _impl) {
  const Size(int width, int height) : this._((width, height) as _Vec2);

  int get width => _impl._dx;
  int get height => _impl._dy;
}

extension type const Point._(_Vec2 _impl) {
  const Point(int x, int y) : this._((x, y) as _Vec2);

  int get x => _impl._dx;
  int get y => _impl._dy;
}

void main() {
  const size = Size(20, 10);
  const point = Point(5, 30);
  print(size.width); // 20
  print(size.height); // 10
  print(point.x); // 5
  print(point.y); // 30
}

@eernstg
Copy link
Member Author

eernstg commented Jun 20, 2024

One thing that makes a difference is the level of control: With a record type it is always possible, in any library, to write an expression that evaluates to a record with the required components. With a class, and using this proposal, it is guaranteed that the underlying representation has been created in a specific way.

We can use this to ensure that the representation satisfies some invariants (that is, it is in some sense well-formed). For example, let's assume that we wish to maintain the invariant that the first component is less-equal than the second:

// In the library that provides `Size` and `Point`.

extension type _Vec2._((int, int) _impl) {
  int get _dx => _impl.$1;
  int get _dy => _impl.$2;
  // other methods here
}

extension type const Size._(_Vec2 _impl) {
  const Size(int width, int height)
      : assert(width <= height),
        _impl = (width, height) as _Vec2;
  int get width => _impl._dx;
  int get height => _impl._dy;
}

extension type const Point._(_Vec2 _impl) {
  const Point(int x, int y)
      : assert(x <= y),
        _impl = (x, y) as _Vec2;
  int get x => _impl._dx;
  int get y => _impl._dy;
}

// In some other library.
import 'size_and_point.dart';

void main() {
  // We can use the constructors.
  const size = Size(20, 100);
  const point = Point(5, 30);
  print(size.width); // 20
  print(size.height); // 100
  print(point.x); // 5
  print(point.y); // 30

  // But we can easily violate the invariant if we wish to do so.
  const badSize = (100, 20) as Size;
}

If we use a class (and this proposal is supported) then we can provide a guarantee that the invariant is satisfied (assuming that assertions are enabled):

final class _Vec2 {
  final int _dx, _dy;
  const _Vec2(this._dx, this._dy): assert(_dx <= _dy);
  // other methods here
}

extension type const Size._(_Vec2 _impl) {
  const factory Size(int width, int height) = _Vec2;
  int get width => _impl._dx;
  int get height => _impl._dy;
}

extension type Point._(_Vec2 _impl) {
  const factory Point(int x, int y) = _Vec2;
  int get x => _impl._dx;
  int get y => _impl._dy;
}

// In some other library.
import 'size_and_point.dart';

// We can't create a subtype that we control:
// `_Vec2` is final, and not even accessible here.
class CheatingVec2 implements _Vec2 {...} // Error, `_Vec2` is undefined.

void main() {
  // We can use the constructors.
  const size = Size(20, 100);
  const point = Point(5, 30);
  print(size.width); // 20
  print(size.height); // 100
  print(point.x); // 5
  print(point.y); // 30

  // We can not violate the invariant, const or not.
  var badSize = ...
    // (100, 20) as Size; // Throws, representation type is the class `_Vec2`.
    // true as Size; // Throws, on _every_ other type than `_Vec2`.
    // _Vec2(100, 20) as Size; // But `_Vec2` isn't accessible here.
    // CheatingVec2(100, 20) as Size; // `CheatingVec2` doesn't even exist.
}

It might also be significant that the class _Vec2 can be modified in many ways without breaking client code, whereas the approach where _Vec2 is a record type allows clients to depend on that record type (e.g., they could execute expressions like (mySize as dynamic).$1).

So the overall point is that a private final class with private members yields stronger encapsulation than a record type.

@eernstg
Copy link
Member Author

eernstg commented Jun 20, 2024

Not that const is the only missing piece, we can easily express all other elements of the example using a private final class, yielding all the encapsulation that I mentioned:

final class _Vec2 {
  final int _dx, _dy;
  _Vec2(this._dx, this._dy) {
    // We might want to check this every time, not just when assertions are enabled.
    if (_dx > _dy) throw "Invariant violation: $_dx > $_dy";
  }
  // other methods here
}

extension type Size._(_Vec2 _impl) {
  Size(int width, int height) : _impl = _Vec2(width, height);
  int get width => _impl._dx;
  int get height => _impl._dy;
}

extension type Point._(_Vec2 _impl) {
  Point(int x, int y) : _impl = _Vec2(x, y);
  int get x => _impl._dx;
  int get y => _impl._dy;
}

// In some other library.
import 'size_and_point.dart';

// We can't create a subtype that we control:
// `_Vec2` is final, and not even accessible here.
class CheatingVec2 implements _Vec2 {...} // Error, `_Vec2` is undefined.

void main() {
  // We can use the constructors.
  final size = Size(20, 100);
  final point = Point(5, 30);
  print(size.width); // 20
  print(size.height); // 100
  print(point.x); // 5
  print(point.y); // 30

  // We can not violate the invariant, const or not.
  var badSize
    // = (100, 20) as Size // Throws, representation type is the class `_Vec2`.
    // = true as Size // Throws, on _every_ other type than `_Vec2`.
    // = _Vec2(100, 20) as Size // But `_Vec2` isn't accessible here.
    // = CheatingVec2(100, 20) as Size // `CheatingVec2` doesn't even exist.
  ;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
extension-types small-feature A small feature which is relatively cheap to implement.
Projects
None yet
Development

No branches or pull requests

4 participants