Open eernstg opened 6 months ago
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.
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.
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.
can't make a subtype run a supertypes
const
constructor asconst
, 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;
}
That one is tricky because we can't make a subtype run a supertypes
const
constructor asconst
, 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.
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.
@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
}
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.
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.
;
}
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:
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 nameorElse
;-).In contrast, consider the following:
In this variant, a run-time type error occurs because the argument passed to
orElse
has typenum Function()
, but the covariance based run-time type check requires anint 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 https://github.com/dart-lang/language/issues/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).