language icon indicating copy to clipboard operation
language copied to clipboard

Allow factory constructors to specify a return type

Open eernstg opened this issue 1 year ago • 4 comments

Constructors generally don't specify a return type because they will return an instance of the enclosing class, so it's redundant.

However, this is not the whole truth when it comes to factory constructors. They may in general return an instance of an arbitrary subtype of the enclosing class. For example:

abstract class A {
  const A._();
  const factory A() = _AImpl;
}

class _AImpl extends A {
  const _AImpl(): super._();
}

This is a typical use case, and the point is that the class A provides a public (possibly widely used) interface, and the actual implementation is provided by one or more subtypes like _AImpl. In general, client code does not need (or want!) to depend on those subtypes, so they are removed from public access (and may even be private) and client code only gets to use them because they can call some redirecting factories like A.

However, we can also have situations where the subclasses are public in nature, and we'd like to combine the ability to call constructors declared by the supertype, and still have access to the interface of each of the subtypes. In this case we could allow the redirecting factory constructor to declare a return type (which must be a subtype of the type of the class, including any type arguments as declared):

abstract class A<X> {
  const A._();
  const factory B<X> A() = B;
}

class B<X> extends A<X> {
  int i = 0;
  const B(): super._();
}

void main() {
  A<String>()..i = 1;
}

It may seem contradictory to wish to have the interface of B<T> in an expression that looks like A<T>() for some T. However, the discussion in #357 has repeatedly brought up a situation where it may be convenient, namely the case where an expression like EdgeInsetsGeometry.all(16.0) is abbreviated to .all(16.0) based on having EdgeInsetsGeometry as the context type.

The idea is that we provide constructors of subtypes like EdgeInsets and EdgeInsetsDirectional as constructors of EdgeInsetsGeometry. That's nothing new, but we do want to have access to members of those subclasses, and this will only work if we can declare the return type:

abstract class EdgeInsetsGeometry {
  const EdgeInsetsGeometry();

  // Forward to EdgeInsets:
  const factory EdgeInsets EdgeInsetsGeometry.fromLTRB(
    double left,
    double top,
    double right,
    double bottom,
  ) = EdgeInsets.fromLTRB;

  const factory EdgeInsets EdgeInsetsGeometry.all(double value) = EdgeInsets.all;

  const factory EdgeInsets EdgeInsetsGeometry.only({
    double left,
    double top,
    double right,
    double bottom,
  }) = EdgeInsets.only;

  const factory EdgeInsets EdgeInsetsGeometry.symmetric({
    double vertical,
    double horizontal,
  }) = EdgeInsets.symmetric;

  static const zero = EdgeInsets.only();

  // Forward to EdgeInsetsDirectional:
  const factory EdgeInsetsDirectional EdgeInsetsGeometry.fromSTEB(
    double start,
    double top,
    double end,
    double bottom,
  ) = EdgeInsetsDirectional.fromSTEB;

  const factory EdgeInsetsDirectional EdgeInsetsGeometry.onlyDirectional({
    double start,
    double top,
    double end,
    double bottom,
  }) = EdgeInsetsDirectional.only;

  const factory EdgeInsetsDirectional EdgeInsetsGeometry.symmetricDirectional({
    required double horizontal,
    required double vertical,
  }) = EdgeInsetsDirectional.symmetric;

  const factory EdgeInsetsDirectional EdgeInsetsGeometry.allDirectional(double value) =
      EdgeInsetsDirectional.all;

  static const zeroDirectional = EdgeInsetsDirectional.only();
}

A couple of brevity features (#4135 and #4144) would allow us get exactly the same behavior with less noise:

abstract class EdgeInsetsGeometry {
  const .new();

  // Forward to EdgeInsets:
  const factory EdgeInsets .fromLTRB = EdgeInsets.fromLTRB;
  const factory EdgeInsets .all = EdgeInsets.all;
  const factory EdgeInsets .only = EdgeInsets.only;
  const factory EdgeInsets .symmetric = EdgeInsets.symmetric;

  static const zero = EdgeInsets.only();

  // Forward to EdgeInsetsDirectional:
  const factory EdgeInsetsDirectional .fromSTEB = EdgeInsetsDirectional.fromSTEB;
  const factory EdgeInsetsDirectional .onlyDirectional = EdgeInsetsDirectional.only;
  const factory EdgeInsetsDirectional .symmetricDirectional = EdgeInsetsDirectional.symmetric;
  const factory EdgeInsetsDirectional .allDirectional = EdgeInsetsDirectional.all;

  static const zeroDirectional = EdgeInsetsDirectional.only();
}

We might even say that if a redirecting factory constructor declares a return type then it will serve as the default class name in the redirection:

abstract class EdgeInsetsGeometry {
  const .new();

  const factory EdgeInsets .fromLTRB = .fromLTRB;
  const factory EdgeInsets .all = .all;
  const factory EdgeInsets .only = .only;
  const factory EdgeInsets .symmetric = .symmetric;

  static const zero = EdgeInsets.only();

  const factory EdgeInsetsDirectional .fromSTEB = .fromSTEB;
  const factory EdgeInsetsDirectional .onlyDirectional = .only;
  const factory EdgeInsetsDirectional .symmetricDirectional = .symmetric;
  const factory EdgeInsetsDirectional .allDirectional = .all;

  static const zeroDirectional = EdgeInsetsDirectional.only();
}

Note that . is punctuation, which means that the parser will have no difficulty parsing the declaration with no space before the ., in case that's the preferred style (as in const factory EdgeInsets.all = .all;).

The point is that we can now tailor the static namespace of EdgeInsetsGeometry such that it offers constructors of various subtypes of that class (such that we can use the .identifier(...) syntax to invoke it when the context type is EdgeInsetsGeometry). It preserves the ability to use the enhanced interface of the given subtype in the instance creation expression itself (as in .all(10).copyWith(right: 15)), and it preserves the ability to be constant.

Finally, non-redirecting factory constructors could also allow for a declared return type:

abstract class A {
  A._();
  factory B A(int x) => B(x + 1);
}

class B extends A {
  final int x;
  B(this.x): super._();
}

This could be used with the .identifier syntax as well, for similar reasons.

eernstg avatar Nov 05 '24 10:11 eernstg

I feel like this is almost solved by doing

class Foo {
  static const bar = Bar.new;
}

With the caveat that you can't use it as a const constructor as we do not have constant function returns outside of constructors (as they are not actually functions, even if they look like one)

This could be fine for non-const classes though.

It's not an ideal pattern for official implementation though...

Could be an acceptable stop-gap with static extensions until we hopefully do get const functions

TekExplorer avatar Mar 11 '25 12:03 TekExplorer

Agreed, including the almost part. ;-)

eernstg avatar Mar 11 '25 13:03 eernstg

Somewhat related to:

  • https://github.com/dart-lang/language/issues/782

FMorschel avatar Apr 18 '25 03:04 FMorschel

With the caveat that you can't use it as a const constructor as we do not have constant function returns outside of constructors

Also see https://github.com/dart-lang/sdk/issues/61290, which may be something to consider.

FMorschel avatar Oct 01 '25 15:10 FMorschel