language icon indicating copy to clipboard operation
language copied to clipboard

Let redirecting factory constructors specify default values

Open eernstg opened this issue 4 months ago • 13 comments

It is currently a compile-time error for a redirecting factory constructor to specify a default value for a formal parameter (the idea is that we will always just use the ones that the redirectee has declared and hence it would be misleading to allow the default value to be specified):

class A {
  A._([String message = "Hello, world!"]) {
    print(message);
  }
  factory A([String message]) = A._;
}

class B {
  B._([String message = "Hello, world!"]) {
    print(message);
  }
  factory B([String message = "Got yer!"]) = B._; // Compile-time error!
}

This issue is a proposal to allow both A and B. This means that a redirecting factory constructor behaves like a forwarding function rather than a mere alias for the redirectee. Note that this is already true for a tear off (with factory C() = D;, the reified return type of the torn-off C constructor C.new is C and not D).

Also, it should be a mere implementation detail that invocations of redirecting constructors can be inlined at compile time, so we shouldn't let that prevent redirecting factories from having their own default values (and, in general, being full-fledged functions, whenever needed).

Note that this would also allow developers to handle a typing conflict which has been discussed previously:

class E {
  E._([num x = 3.14]);
  factory E([int x]) = E._; // Specified to be an error.
}

The declaration of the constructor named E is specified to be an error because the default value of x isn't a type correct value for a parameter whose type is int. There is no easy solution today (in particular, for a constant constructor), because the redirecting factory can't specify a different (and type correct) default value for x, but with the proposal in this issue it could easily be specified, e.g., as int x = 3.

The current behavior is actually to skip the issue (and not report any errors), but this means that developers can't inspect the constructor named E and conclude that the actual argument for x will always be an int when an object is created using that constructor. Note that this situation could arise after the redirecting constructor has been written, because the redirectee could be updated to have a more general parameter type.

@dart-lang/language-team, WDYT?

eernstg avatar Aug 22 '25 15:08 eernstg

Further discussions here: https://github.com/dart-lang/language/issues/3427.

eernstg avatar Aug 22 '25 15:08 eernstg

I'm up for making some kind of change here, but I want to make sure we do so while keeping in mind #4172.

There's already weird irregularities in the language around this:

  • We allow you to put a default value on an abstract instance method, even though it will never be used and means nothing. It's there purely to document intent.
  • We don't allow you to put a default value on a redirecting factory constructor, because it will never be used and is confusing since it's the redirectee's default value who gets used.
  • Also, we don't allow you to put a default value on a function type annotation, because it will never be used and means nothing. I guess we don't consider it worthwhile to let you document intent here.
  • But we do allow you to put a positional parameter name on a function type annotation, even though it will never be used and means nothing. I guess we do consider it worthwhile to let you document that intent.

This all gets weirder if we supported non-constant default values. Because then you have an expression whose evaluation might actually be user visible and they might care more if it gets silently skipped.

If it were me, I think I'd make a distinction between:

  1. A declaration of a function/constructor/getter/operator/etc. that "has a body". It is creating some meaningful callable entity.
  2. A declaration of a function signature that it only seen by the static type system and doesn't create a thing you can call.

Abstract methods and function type annotations are 2. Other function/constructor/whatever declarations are 1.

Then I would say that default values can only appear in 1 and not 2. That means it would be an error to put a default value on an abstract method. (That would be a hard breaking change to roll out.)

I would characterize redirecting constructors as 1 and thus they can have default values and those default values should be used. Which I think is what you are also proposing when you say "This means that a redirecting factory constructor behaves like a forwarding function rather than a mere alias for the redirectee"?

On the other hand, this approach would make augmentations harder. Because then specifying a default value for a method locks it in to not being abstract and needing to get a body provided by the augmentation. I'm maybe OK with that.

munificent avatar Sep 04 '25 00:09 munificent

A declaration of a function signature that it only seen by the static type system and doesn't create a thing you can call.

And then there are external functions, which can be called, but the function signature is just a signature.

The real distinction is between signature and implementation, and redirections are signature only because the implementation is somewhere else with its own parameter list.

A function parameter list is part of the implementation of a function if the declaration contains code which can refer to the parameters. A default value only makes sense in a parameter list which is part of the implementation. Otherwise nobody will ever use the value of that default value expression. _Except as documentation, but that can be important too.

  • An abstract function is not an implementation.
  • A function type is not an implementation.
  • A redirecting factory constructor is not an implementation, it redirects the argument list to the actual implementation.
  • An external function has an implementation, but is not one, its parameter list is not part of that implementation. It just redirects the argument list to the actual implementation.
  • A redirecting generative constructor is an implementation, because its body contains code in the argument list that refers to the parameters. It's closer to a factory constructor like => this(args) than = this;.
  • A forwarding generative constructor (in a mixin application class) is an implementation as specified, but it really shouldn't be. It should just forward its argument list directly to its superclass with no desugaring introduced. If we ever get the ability to distinguish the default value from no argument, this becomes important.

And if we ever get redirecting functions, they'd be signature-only too, with implementation being declared somewhere else.

If a parameter list is not part of an implementation, then default values should not be required. We can make them optional, as documentation, and if we ever get non-constant default values, we can warn if you have a non-constant value that's not part of an implementation. (But it's just a warning, you can easily imagine slice([int start = 0, int end = this.length]) as useful documentation.)

(And in an IDE, we should color-code them as comments!)

I don't think we can make positional parameter names optional. Today foo(x, y) means foo(dynamic x, dynamic y), not foo(x _, y _), so we'd have a breaking change and potentially large migration if we change that. You can use _, so that's OK.

(In function types, or rather in Function(...) types, you can omit the name. That's nice, but it's only because we added that syntax later, and we'd gotten smarter by then. You still can't write sort<E>(int compare(E, E)), those Es are parameter names.)

For augmentations, setting a default value without setting a body, would have to not make the function concrete. I think that's OK. You can add anything that can exist on both a concrete and abstract declaration without forcing it to be either. If, after all augmentations are accounted for, nothing forces a member to be concrete, it's abstract. If something prevents it from being abstract (aka, forces it to be concrete), it's concrete. Then it's an error if not everything required to be concrete is satisfied. We just move "default values" out of the "forces to be concrete" group.

lrhn avatar Sep 04 '25 10:09 lrhn

@munificent wrote:

I'd make a distinction between:

  • A declaration of a function/constructor/getter/operator/etc. that "has a body". It is creating some meaningful callable entity.
  • A declaration of a function signature that it only seen by the static type system and doesn't create a thing you can call.

Agreed, we should decide on which declarations and similar entities (e.g., function types and function literals) are in each of those categories.

We already have some situations where a default value is required, but if it is not specified explicitly then some other value is implicitly induced (a formal parameter with a nullable type will get the default value null, a super parameter will get the default value from the corresponding formal parameter declaration in the superconstructor). I'd recommend that we allow this kind of situation to occur (in the places where it's used today, and possibly in some new situations as well).

We also have some situations where a default value can be specified even though it is not used (in particular, on formal parameter declarations in abstract instance member declarations). They used to be required, and they can serve as documentation of the intended default value (and a warning could be emitted for the case where some overriding declaration has a different default value). I'd recommend that we do not add further situations along with the ones that we already have, and we could use a lint to help developers who want to avoid having these ignored default value specifications.

About the classifications, I mostly agree. First, a function literal is an implementation. Next:

@lrhn wrote:

  • An abstract function is not an implementation.

Agreed (and I suppose the only abstract functions are abstract instance members).

  • A function type is not an implementation.

Agreed.

  • A redirecting factory constructor is not an implementation, it redirects the argument list to the actual implementation.

Disagreed. First note that we have already decided that they must be treated as separate functions rather than simply as compile-time references to other constructors:

class A {
  A();
  factory A.redir() = B;
}

class B extends A {}

void main() {
  Function f = A.redir;
  print(f is B Function()); // 'false'.
  print(f is A Function()); // 'true'.
}

Next, I'd strongly prefer to consider the redirection itself as an implementation detail rather than an API property. It's perfectly OK (and always possible) to inline an invocation of a redirecting factory constructor, so there's no loss of performance in adopting this perspective. Torn-off constructors must pay the (small) performance hit by representing the redirecting factory as a separate function with its own return type, which means that it is also a neutral choice, performancewise, to consider the redirecting factory as an implementation.

It could be argued that a redirecting factory could never be constant unless it were seen as a mere compile-time reference to a different constructor. However, I'd prefer to say that we do support actual function invocations as constant expressions, at least in this very special case. This doesn't require us to support any other generalizations of the same nature, but it would fit in naturally if we do generalize constant expressions in some way that includes some function invocations.

  • An external function has an implementation, but is not one, its parameter list is not part of that implementation. It just redirects the argument list to the actual implementation.

This one is tricky. I'm not 100% sure which direction to take here.

It doesn't make sense to insist that we should be able to look up the default values of the external function, such that they can be implicitly induced on "the Dart function which is denoted by the external declaration itself". So that Dart function should probably not exist in the first place (given that we can't treat it like other situations that may seem similar, e.g., redirecting factories).

This means that we actually want to expose the implementation details in this particular case, in order to avoid any added cost during invocations. For example, js_interop invocations of external methods should be as fast as possible, and this is handled by generating a regular JavaScript invocation at each call site. This goes deeper than regular inlining.

In the end, I agree that an external function isn't an implementation. In this very special case we probably want to expose the implementation details, and in return we'll get the best possible performance.

  • A redirecting generative constructor is an implementation, because its body contains code in the argument list that refers to the parameters.

Agreed.

It's closer to a factory constructor like => this(args) than = this;.

(I wouldn't say that, but it shouldn't matter.)

  • A forwarding generative constructor (in a mixin application class) is an implementation as specified, but it really shouldn't be. It should just forward its argument list directly to its superclass with no desugaring introduced. If we ever get the ability to distinguish the default value from no argument, this becomes important.

The semantics of mixin application includes an implicit introduction of zero or more forwarding constructor implementations. It may be possible for a compiler to avoid the repeated forwarding calls (that is, to inline the forwarding constructors), but we shouldn't introduce any special rules to be able to claim that those constructors aren't actually there. That's going to be much more complex than just admitting that they're there, and then we can rely on all the normal rules. The compiler can inline the forwarding constructors as much as it wants in any case. I'd recommend that we continue to consider the implicitly induced forwarding constructors as implementations.

Note that this may also be a better match with, for example, a potential future language feature whereby mixins are generalized such that they can declare a function body which must be used as the body of each of the forwarding constructors.

And if we ever get redirecting functions, they'd be signature-only too

Disagreed. I'm assuming that we're talking about forwarding functions a la #3444 here, that is, a function which will call some other function passing the same actual arguments (or some "small" variation thereof). One of the reasons why we might want this feature is that we can change the signature. For instance, we might want to call an existing function with different default values, or with more narrow parameter types, etc. Anyway, we don't have this kind of function yet, so we can return to this dogfight later. ;-)


The main point is that a redirecting factory constructor should be considered to be an implementation, and hence it should (1) be able to declare default values just like any other function implementation, and (2) for backward compatibility, convenience, and consistency, it should be able to implicitly receive default values from the redirectee constructor, if needed. That's actually exactly what I have proposed in this issue.

This is also compatible with #4172, as mentioned here. (Arguably, it's an improvement of the situation which is the topic of #4172.)

eernstg avatar Sep 05 '25 12:09 eernstg

Disagreed. First note that we have already decided that they must be treated as separate functions rather than simply as compile-time references to other constructors:

That doesn't mean it has an implementation that depends on the parameter declaration. You can say the same about an external function.

And tear-offs are not the declaration, they're closures forwarding arguments to the declartion (and they should also properly forward the argument list, not try to match default values and pass individual arguments).

I'd strongly prefer to consider the redirection itself as an implementation detail rather than an API property

I agree, and it is.

Whether you write factory Foo([int x = 2]) => SubFoo(x); or factory Foo([int x]) = SubFoo;, what it does is an implementation choice. The signature is the same, Foo Function([int]). The default value is not part of the signature.

The user of the class cannot tell the difference from the API.

If you want to document the value in the declaration, in case someone looks at it, we can allow you to write factory Foo([int x = 2]) = SubFoo;. It's just a comment, it has no effect on anything. Still consistent.

In the end, I agree that an external function isn't an implementation. In this very special case we probably want to expose the implementation details, and in return we'll get the best possible performance.

I don't know what those implementation details are or who we're exposing them to. The author of the function is the only one who needs to know that it's external. Anyone else just has to call it with a valid argument list.

The semantics of mixin application includes an implicit introduction of zero or more forwarding constructor implementations. It may be possible for a compiler to avoid the repeated forwarding calls (that is, to inline the forwarding constructors), but we shouldn't introduce any special rules to be able to claim that those constructors aren't actually there.

I don't want "special rules", I just don't want to introduce an extra desugaring. That means that:

  • A forwarding construtor introduced by a mixin application has the same parameter signature as the superclass constructor with the same base name.
  • When invoked with an arugment list A to initialize the object:
    • it initializes each instance variable introduced by the mixin application (each instance variable of that class).
    • Then it invokes the superclass constructor with the same base name with the argument list A.
    • When that completes, it returns.

That's it. The semantics are clear and well-defined. They're not defined by desugaring, which is a good thing, because that would only introduce more complication and more restrictions on how the backends can implement it. (For example, they couldn't just tail-call the superclass constructor if the argument list needs to be changed by introducing default values.)

Note that this may also be a better match with, for example, a potential future language feature whereby mixins are generalized such that they can declare a function body which must be used as the body of each of the forwarding constructors.

That seems complicated. Just allow mixins to have real constructors, and allow you to designate which one to call in the class that does the mixin:

mixin M(final int value) {
  M.zero() : this(0);
}
class C(int v) extends S(v + 1) with M(v) {}

You don't need forwarding constructors then, the S(v+1) is not calling through the mixin classes, it's calling the constructor of S directly, invoked as the continuation of the mixin constructors.

And if we ever get redirecting functions, they'd be signature-only too

Disagreed. I'm assuming that we're talking about forwarding functions a la https://github.com/dart-lang/language/issues/3444 here, that is, a function which will call some other function passing the same actual arguments (or some "small" variation thereof).

Precisly that, with no variations in the argument list. Something like:

  int parseInt(String value) = int.parse;

would accept an argument list with one string, and parse it to int.parse directly. Since int.parse also accepts {int? radix}, it won't get that argument when called through parseInt.

I'd want the samantics of that to be:

  • When invoked with argument list A.
    • Evaluate the RHS to a function f.
    • Invoke f with argument list A.
    • Let v be the value returned by that invocation.
    • return v.

(But possibly recognizing member invocations and not doing a tear-off. It's visible in noSuchMethod behavior.) The function type of the forwarding function must be a supertype of the static function type of its RHS.

One of the reasons why we might want this feature is that we can change the signature. For instance, we might want to call an existing function with different default values, or with more narrow parameter types, etc.

And more narrow parameters works, different default values do not. I can live with that.

Anyway, we don't have this kind of function yet, so we can return to this dogfight later. ;-)

Will do!

The main point is that a redirecting factory constructor should be considered to be an implementation

It's an implementation (it's not abstract), but it doesn't have an implementation. And it shouldn't have, it unnecessary. It doesn't have to have a default value for an optional parameter, because it never needs to access the parameter's value itself.

It's just cleaner.

If we ever get non-constant default values, and the ability to refer to earlier parameters in those expressions, evaluation order will matter. Moving a default-value expression from one function to another can change that order. And it's a complicated, under-defined operation to move an expression into a different context.

Assume we have non-constant default values:

// file 1
class C<T> {
  C(Object? v, [Object o]) = D<T>;
}
// file 2
class D<_X> extends C<_X> {
  Object o;
  D(Object? q, [this.o = q ?? _X]); // Or even `q ??= _X` if we allow assigning to prior parameters too.
}

If the behavior of C.new is to have a parameter list with the same default values as D, now meaning same default value expressions, then we need to remember to rename the type parameter and reference to prior parameters when we move that expression. (Or rather, we never move an expression, we say that we evaluate it in a context where t

Just forward the argument list, ignore default values (if allowed at all), and even the original problem goes away:

class C {
  C([int x]) = D;
}
class D {
  int? _value;
  D([this._value]);
}

This was never a problem if you just forward the argument list. It becomes a problem when we insiste on not doing that. All these problems are self-inflicted, and the solution is trivial: Forward the actual argument list.

You should never (need to) write a default value if you are not writing the code that uses the parameter's value. And you should never have a default value that you didn't write yourself.

lrhn avatar Sep 05 '25 14:09 lrhn

Disagreed. First note that we have already decided that they must be treated as separate functions rather than simply as compile-time references to other constructors:

That doesn't mean it has an implementation that depends on the parameter declaration. You can say the same about an external function.

The redirecting factory constructor declaration specifies a signature (name, formal parameters, and the return type is implied by the enclosing declaration as it is with all constructors) and it specifies an implementation (the redirectee, plus the factory and = elements in the syntax which imply that this declaration has a behavior which is to invoke the redirectee passing on its formal parameters).

The tear-off semantics is consistent with this perspective on the redirecting factory: It is a function implementation declaration. In particular, there is no way we can have the correct return type for the tear-off if we insist that the redirecting factory declaration is like an abstract method declaration and all properties of the tear-off are determined by the redirectee:

abstract class A { num foo(); }
class B extends A { int foo() => 1; }

void main() {
  final fun = B().foo;
  print(fun is int Function()); // 'true'.
}

With the instance method foo, it is indeed true that all properties of the tear-off are determined by the implementation declaration in B, and the abstract member in A is just a set of constraints that the implementation is guaranteed to satisfy.

But we do not treat redirecting factories in this way. They are treated as implementation declarations. It's just an arbitrary and unnecessary limitation that they can't declare default values on their formal parameters.

And tear-offs are not the declaration, they're closures forwarding arguments to the declartion (and they should also properly forward the argument list, not try to match default values and pass individual arguments).

I think it's implied that "proper" forwarding amounts to passing the same actual arguments (or not) as in the invocation. So if we're calling the redirecting factory with 2 arguments and omitting a third one then it is compiled to an invocation that passes those two arguments to the redirectee and omits the third one.

This is the same semantics as passing all the actual arguments to the corresponding formal parameters, and having the same default values as in the forwardee (of course, the latter has more expressive power because we can also use different default values).

We could introduce this kind of forwarding (e.g., as proposed in https://github.com/dart-lang/language/issues/3444). This could definitely be useful with forwarding invocations of instance members, where the late binding implies that the default values aren't known statically (which means that we cannot emulate the feature by writing Dart code).

I'd strongly prefer to consider the redirection itself as an implementation detail rather than an API property

I agree, and it is.

Whether you write factory Foo([int x = 2]) => SubFoo(x); or factory Foo([int x]) = SubFoo;, what it does is an implementation choice. The signature is the same, Foo Function([int]). The default value is not part of the signature.

You're right that it is purely an implementation choice. factory Foo([int x]) = SubFoo; should not leak the fact that it is implemented by redirection.

Also, if SubFoo accepts an optional positional argument with default value 3 then I don't see why we shouldn't allow writing factory Foo([int x = 2]) = SubFoo; to obtain the same behavior as the non-redirecting factory. In particular, this could be helpful if the factory must be redirecting because it needs to be constant.

... If you want to document the value in the declaration, in case someone looks at it, we can allow you to write factory Foo([int x = 2]) = SubFoo;. It's just a comment, it has no effect on anything. Still consistent.

I think it would be highly confusing and error prone to treat these default values as comments.

In the end, I agree that an external function isn't an implementation. In this very special case we probably want to expose the implementation details, and in return we'll get the best possible performance.

I don't know what those implementation details are or who we're exposing them to.

It is surely an implementation detail that a function is external in the first place, but this is leaked to clients, for example, in the case where this function cannot be torn off (which is true for external @JS() functions, and may be true in other cases as well).

As I mentioned, external functions is a very tricky case, and we could treat them consistently as full-fledged Dart functions. However, in the end I have concluded that external functions are inherently non-encapsulated. We want the best possible performance, and it's OK that clients can distinguish external functions from regular Dart functions. External functions should generally be part of an implementation, they shouldn't appear as widely used elements of an API.

The semantics of mixin application includes an implicit introduction of zero or more forwarding constructor implementations. It may be possible for a compiler to avoid the repeated forwarding calls (that is, to inline the forwarding constructors), but we shouldn't introduce any special rules to be able to claim that those constructors aren't actually there.

I don't want "special rules", I just don't want to introduce an extra desugaring. That means that:

  • A forwarding construtor introduced by a mixin application has the same parameter signature as the superclass constructor with the same base name.

  • When invoked with an arugment list A to initialize the object:

    • it initializes each instance variable introduced by the mixin application (each instance variable of that class).
    • Then it invokes the superclass constructor with the same base name with the argument list A.
    • When that completes, it returns.

That's it. The semantics are clear and well-defined. They're not defined by desugaring, which is a good thing, because that would only introduce more complication and more restrictions on how the backends can implement it. (For example, they couldn't just tail-call the superclass constructor if the argument list needs to be changed by introducing default values.)

This sounds a lot like special rules to me.

And if we ever get redirecting functions, they'd be signature-only too

Disagreed. I'm assuming that we're talking about forwarding functions a la #3444 here, that is, a function which will call some other function passing the same actual arguments (or some "small" variation thereof).

Precisly that, with no variations in the argument list.

Every discussion I've seen about forwarding as a language feature has brought up the need to make small adjustments. That is, we want to call the forwardee with the same arguments, except [something].

Something like:

int parseInt(String value) = int.parse; would accept an argument list with one string, and parse it to int.parse directly. Since int.parse also accepts {int? radix}, it won't get that argument when called through parseInt.

I'd want the samantics of that to be:

  • When invoked with argument list A.

    • Evaluate the RHS to a function f.
    • Invoke f with argument list A.
    • Let v be the value returned by that invocation.
    • return v.

This allows us to omit the rest of the actual arguments in the invocation of the forwardee, but it doesn't allow things like receiving an int i and passing i + 1 in the forwarding invocation (or any other computed value), or reordering some positional arguments, or receiving an optional named parameter with a default value and passing it on to a required default parameter with the same name, etc etc. I'd prefer something which is a bit more expressive.

...

The main point is that a redirecting factory constructor should be considered to be an implementation

It's an implementation (it's not abstract), but it doesn't have an implementation. And it shouldn't have, it unnecessary.

I really don't see how the distinction between "is" and "has" an implementation could make sense.

It doesn't have to have a default value for an optional parameter, because it never needs to access the parameter's value itself.

This is less expressive, and it leaves us with the typing loophole of https://github.com/dart-lang/language/issues/3331.

It's just cleaner.

I don't see anything clean about not being able to avoid an actual argument of null for invocations of the constructor named A:

class A {
  const A._([int? i]);
  const factory A([int i]) = A._;
}

When the developer wrote int as the parameter type here, the intention surely was that clients (in other libraries, where A._ is not available) would have to provide a non-null value for i. However, A() will happily create an A where the parameter i is bound to null.

When the developer tries to fix it by writing const factory A([int i = 2]) = A._;, it's a compile-time error, so the developer is forced to write factory A([int i = 2]) => A._(i);. So we lost the const property, and had to write more code.

Who are we helping?

eernstg avatar Sep 08 '25 10:09 eernstg

which imply that this declaration has a behavior which is to invoke the redirectee passing on its formal parameters

And that's what I disagree with. I think it should pass on its actual parameters.

We can obviously do either, if we pass on the formal parameters, then the formal parameters must be valid, which means that they must declare a default value for non-nullable optional parameters. Otherwise there is no formal parameter value to pass on.

Trying to implicitly use the default value of the target constructor's parameter is a losing game. We've tried that, and it gives errors for otherwise reasonable code.

We could allow copying the default value when it's valid and requiring you to write one when it isn't. That means changing the default value of a subclass constructor can break the superclass. Probably not a big issue, if the superclass constructor factory-forwards to the subclass constructor, then the classes are probably written by the same author who can fix the issue.

It requires introducing a value that may not otherwise be expressible in the library where the declaration is written. That's still just a technical problem, the superclass was forwarding to that constructor, so they probably owns the code.

That makes non-constant default values even harder to introduce. We'd probably have to say that you only get the default value of the target constructor's parameter if it's valid and constant. I don't want to evaluate an expression in a different scope than where it was written. Then you'll have to write a default value anyway.

Might as well (other than obviosly being breaking) just make it mandatory everywhere.

Or we could forward the actual argument list and not have any problems to solve at all.

The tear-off semantics is consistent with this perspective on the redirecting factory:

So would a forwarding tear-off semantics be.

The tear-off of a generative constructor C of a class Foo with type parameters <TypeParams> and constructor function signature Foo Function(Args) is a function value with signature Foo Function<TypeParams>(Args) which, when invoked with type arguments T and actual arguments A, will create a new instance of Foo<T> and invoke C to initialize that instance with actual arguments A, and return the new instance.

The tear-off of a factory constructor C of a class Foo with type parameters and a constructor signature of Foo Function(Args) is a function with signature Foo Function<TypeParams>(Args) whic, when invoked with type arguments T and actual arguments A, will invoke C on the class <Code>Foo<A> with arguments A and return the result.

(Plus rules for equality and identity of these functions.)

No extra complexity, just saying the precise minimum needed to get the job done.

Adding anything more on top is unnecessary complexity.

Who are we helping?

We can allow writing const factory A([int i = 2]) = A._;. I cannot come up with any rasonable situation where you want A and A._ having different default values for the same optional parameter, so it shouldn't matter that the = 2 on A is only there as documentation, it'll still be correct when the argument list is forwarded to A._.

Also, if SubFoo accepts an optional positional argument with default value 3 then I don't see why we shouldn't allow writing factory Foo([int x = 2]) = SubFoo; to obtain the same behavior as the non-redirecting factory. In particular, this could be helpful if the factory must be redirecting because it needs to be constant.

We can allow that (and presumably use the 2 value as the passed-on argument), but then we should require the forwarding constructor to have default values, not allow you to omit them in some cases. It's no longer forwarding its argument list to the target constructor, it's creating a new one by reading its own argument list and creating a new and potentially different one. And then the formal parameter variables must be valid, which includes having a default value if they're optional and not nullable.

I don't see the use-case, though. Actually forwarding with a different default value is something I expect to have lints guarding against, because it's 99% certain to be a bug. For the remaining 1% (high estimate) having to use a => seems like a small price to pay.

So, sure, there is something you can do by forwarding formal parameter vaues instead of the incoming argument list. It's just something I don't see a real use for. And it causes a bunch of new and otherwise avoidable problems. I'd rather avoid the problems.

I think it would be highly confusing and error prone to treat these default values as comments.

Only if they differ from what they forward to, which they shouldn't if they're intended as documentation. So, again, I expect lints to protect against that.

I do think that it's also error-prone to have a default value that differes from the function it forwards to, perhaps by accident. You can introduce new values in other ways, using => forwarding.

If we forward parameter values, then (arg1,...,argn) = OtherClass; is just a syntactic shorthand for (arg1, ..., argn) => OtherClass(arg1, ..., argn);. There is no benefit to using it other than brevity (and being able to be const).

If we forward the argument list, then (arg1, ..., argn) = OtherClass is actually different from =>. Maybe that distinction doesn't matter in practice to users, and all they care about is not writing the parameters.

And again, if there is no difference, you shouldn't be allowed to omit default values.

It is surely an implementation detail that a function is external in the first place, but this is leaked to clients,

No?

for example, in the case where this function cannot be torn off (which is true for external @JS() functions, and may be true in other cases as well).

That's not a matter of being external. Being external makes no difference to the language. That it's JS native function makes a difference to some compilers. There are other external functions that are not @JS() functions, and those can be torn off without issue. There could equally well be non-@JS() non-external functions that couldn't be torn off. That's just a compiler special-casing some things.

Being external does not change the signature of a function, and does not by itself change how it can be used. The language does not require default values on external function parameters because they are not declaring any formal parameters, they're just declaring a function signature. And that's the distinction I want to matter.

This sounds a lot like special rules to me.

It's specifying the semantincs of a function. Just like we specify the semantics of invoking a constructor directly, or a function. That's not "rules".

Doing a tear-off creates a function value. We have to specify what that values is. The most direct way to specify that is:

  • It's a function value, and
    • It's signature is ....
    • When invoked it does ....
    • It's dentical and equals behavior is ...

That's how you specify what a function value is and what it does. Alternatively one can try to specify it by desugaring, which never works.

So there is no special rules here, it's just a different specification of what invoking the function does.

Every discussion I've seen about forwarding as a language feature ...

This is not forwarding as a language feature. It's forwarding as a language semantics primitive. The language already has that, even if it hasn't used it, because it's literally just the words "with the same argument list".

If we want it as a language feature too, maybe there will be cases where you want "partial forwarding" or "forwarding plus more arguments". People always want to solve their specific problem with a feature that is even tangentially related.

Nothing more is needed here, and I can come up with couple of other places where plain argument list forwarding would be exactly what I want. (Like abstract declarations in extension types that would forward their argument list directly to the same-named member of the representation type.)

... but it doesn't allow things like ...

No, and I'm fine with that. If you want to shuffle or change parameters, write a parameter list, it's not forwarding any more. Or we can have a feature that allows omitting or forwarding some individual parameters, like super-parameters in constructors, but that's a different feature.

I really don't see how the distinction between "is" and "has" an implementation could make sense.

Let's try other words then:

  • An declaration can either be abstract or concrete.
  • A declaration's parameter list can either declare formal parameter variables or just a signature.

In no case will a declaration be abstract and declare formal parameters.

An external method declaration is concrete and its parameter list only declares a signature. (When invoked, there is no "binding actuals to formals" step which binds argument values to those declarations.)

A forwarding factory constructor is concrete and (IMO) its parameter list only declares a signature. If we forward the argument list blindly, there is no need to bind actuals to formals in the forwarding constructor. If we do bind actuals to formals, then clearly it declares formal variables. Then it should satisfy the requirements assumed by binding-actuals-to-formals (like having a valid default value if it is optional and non-nullable).

I don't see anything clean about not being able to avoid an actual argument of null for invocations of the constructor named A:

class A {
  const A._([int? i]);
  const factory A([int i]) = A._;
}

There are plenty of ways to avoid that. You have picked the one case that's not precisely replicable by a single constructor, but you can get it anyway.

   const A([int i = 0]) : this._(i); // Not factory.
// or
   factory([int i = 0]) => A._(i); // Not const.
// or
   const factory A(int i) = A._; // Not optional.
// or, if you need all of it:
   const factory A([int i]) = A._helper; // Optional, const and factory.
   const A._helper([int i = 1]) : this._(i);

If you want a non-null value as argument to A._, you have to introduce that value somewhere. I'd rather make you do a little extra work for that (I expect) very rare case, than muddy forwarding with modifications of the argument list.

To keep this focused: We have found issues everywhere we copy default values to another declaration with a potentially different signature. I believe the best way to solve those problems, and avoid similar or worse problems in the future, is to not copy default values. That means that a redirecting factory constructor must either:

  • Declared default values for every non-nullable optional parameter, so that it can access the parameter values, or
  • Not bind actuals to formals, and instead forward the argument list directly to the target without trying to interpret it.

The former would be breaking. The latter would handle default values only in the function that actually uses the arguments, rather than handle default values in the forwarding function.

I acknowledge that there is something you can do by having a different default value applied in the forwarding function than the one of the target. I also believe that's a rare and error-prone approach, and I'm willing to lose that in order to have simpler forwarding semantics.

And if we do that, then having a default value on an argument-forwarding declaration, like a redirecting factory constructor, has no effect and is only for documentation. And that's fine. It should match the default value of the function it actually forwards to, and we should lint for that.

(And if it wasn't for noSuchMethod, I'd let nSM-forwarders forward the actual argument list. If we could just introduce a way to check if a parameter was passed, and programmatically avoid passing an argument, then it wouldn't be a problem that noSuchMethod could do the same thing. So we should do that.)

lrhn avatar Sep 08 '25 11:09 lrhn

which imply that this declaration has a behavior which is to invoke the redirectee passing on its formal parameters

And that's what I disagree with. I think it should pass on its actual parameters.

This is the same thing as saying that "Dart should support testing whether an optional parameter was passed or not, and it should support optionally passing an optional parameter".

If we want those features then they should be introduced properly. In particular, they should have a syntax such that this feature can be used in general, not just in the very special case of redirecting factories. (Of course, those features are basically trivial in the case where the redirectee/forwardee is determined statically, so adding them just in that case is really not very useful.)

We can obviously do either, if we pass on the formal parameters, then the formal parameters must be valid, which means that they must declare a default value for non-nullable optional parameters. Otherwise there is no formal parameter value to pass on.

We're perfectly happy to infer a default value for a super parameter when needed, the situation is equally easy here.

Trying to implicitly use the default value of the target constructor's parameter is a losing game. We've tried that, and it gives errors for otherwise reasonable code.

With super parameters, the following is rejected as a compile-time error because the inferred default value violates the declared type of the parameter:

class A {
  A([int? i]);
}

class B extends A {
  B([int super.i]); // Error!
}

That's because the code is not reasonable. The reasonable approach is to expect that this super parameter will be a value which is typable as an int, as declared, and not null.

We could allow copying the default value when it's valid and requiring you to write one when it isn't.

Yep, sounds good.

That means changing the default value of a subclass constructor can break the superclass. Probably not a big issue, if the superclass constructor factory-forwards to the subclass constructor, then the classes are probably written by the same author who can fix the issue.

Being able to omit a default value because it is copied from some other location will always give rise to potential breakage. This can be avoided if the default value is specified in the redirecting factory, which is what I'm arguing we should allow.

If you do want to have an auto-updated default value then you can still choose to implicitly obtain the default value from the redirectee, and then you'll have to fix it when they choose to update it to a value that won't work for your signature.

The new thing is that you can fix it if we adopt this proposal.

It requires introducing a value that may not otherwise be expressible in the library where the declaration is written. That's still just a technical problem, the superclass was forwarding to that constructor, so they probably owns the code.

As long as it is correct to use that value I think it's fine to rely on it being copied from the redirectee. If it starts to violate the declared type then I'd prefer to know about it, and fix it.

That makes non-constant default values even harder to introduce. We'd probably have to say that you only get the default value of the target constructor's parameter if it's valid and constant. I don't want to evaluate an expression in a different scope than where it was written. Then you'll have to write a default value anyway.

A non-constant default value could be a reference to the (possibly non-constant) default value expression of the redirectee, cf. https://github.com/dart-lang/language/issues/2269.

Might as well (other than obviosly being breaking) just make it mandatory everywhere.

We obviously wouldn't tolerate such a massively breaking change. Especially when it isn't necessary at all. ;-)

Or we could forward the actual argument list and not have any problems to solve at all.

I do think it's a problem if a parameter of an implementation declaration is declared as [int i], and the actual value passed can be null — also, If you need the factory to be constant then there's no way to fix it.

The tear-off semantics is consistent with this perspective on the redirecting factory:

So would a forwarding tear-off semantics be.

That's true. I just don't think we should introduce genuine forwarding as a behavior that only occurs with redirecting factory constructors. If we're going to have it then it should be a proper language feature, available with all kinds of function declarations.

Adding anything more on top is unnecessary complexity.

Having real forwarding as a weird exception is unnecessary complexity.

Who are we helping?

We can allow writing const factory A([int i = 2]) = A._;.

We certainly can, that's what I'm proposing here. ;-D

I cannot come up with any rasonable situation where you want A and A._ having different default values for the same optional parameter, so it shouldn't matter that the = 2 on A is only there as documentation, it'll still be correct when the argument list is forwarded to A._.

That's not very difficult. You may wish to have a more narrow parameter type in the a redirecting factory than in the redirectee because the former is public and the latter is private, and you wish to impose a stronger discipline on the arguments passed to the public constructor. This could be because only the clients in the same library can be trusted to use the "extra" argument values that only they can pass.

Also, if SubFoo accepts an optional positional argument with default value 3 then I don't see why we shouldn't allow writing factory Foo([int x = 2]) = SubFoo; to obtain the same behavior as the non-redirecting factory. In particular, this could be helpful if the factory must be redirecting because it needs to be constant.

We can allow that (and presumably use the 2 value as the passed-on argument), but then we should require the forwarding constructor to have default values, not allow you to omit them in some cases.

And why should we refuse to treat them like super parameters? Again, I really can't see how it would help anyone to insist on restrictions like that (and the massive breakage).

I think it would be highly confusing and error prone to treat these default values as comments.

Only if they differ from what they forward to, which they shouldn't if they're intended as documentation. So, again, I expect lints to protect against that.

I do think that it's also error-prone to have a default value that differes from the function it forwards to, perhaps by accident. You can introduce new values in other ways, using => forwarding.

So you'd recommend the following, seriously? ;-)

void f1([int i = 1]) => f2(i); // Lint because the default value is different!
void f2([int i = 2]) => print(i);

I think the developer who uses f1 should be able to ignore the distinction between having an ordinary function body as the implementation, and being a forwarder (f1([int i = 1]) ==> f2;, if we adopt a proposal like #3444).

If we go to the declaration and see an explicitly specified default value on a parameter then we should be able to trust that value to be the actual default value. If there is none (and it could have been specified) then we know that we'd need to look at the redirectee in order to see which default value we're "inheriting". That's a bit less convenient, but no worse than today, and we can probably hover in an IDE and see it immediately.

...

It is surely an implementation detail that a function is external in the first place, but this is leaked to clients,

No?

for example, in the case where this function cannot be torn off (which is true for external @js() functions, and may be true in other cases as well).

That's not a matter of being external. Being external makes no difference to the language. That it's JS native function makes a difference to some compilers. There are other external functions that are not @JS() functions, and those can be torn off without issue. There could equally well be non-@JS() non-external functions that couldn't be torn off. That's just a compiler special-casing some things.

External functions could be full fledged Dart functions (wrapping some other entity which is not Dart), and that might be a 'beautiful' choice. However, we have them in order to enable Dart code to use native features, and we don't want to pay for the additional abstraction in that case.

...

Every discussion I've seen about forwarding as a language feature ...

This is not forwarding as a language feature. It's forwarding as a language semantics primitive. The language already has that, even if it hasn't used it, because it's literally just the words "with the same argument list".

5 words can make a difference. I don't want to introduce a language feature as a very special (and rather useless) case. If we want proper forwarding then we should introduce it as a proper language feature.

...

I really don't see how the distinction between "is" and "has" an implementation could make sense.

Let's try other words then:

  • An declaration can either be abstract or concrete.
  • A declaration's parameter list can either declare formal parameter variables or just a signature.

In no case will a declaration be abstract and declare formal parameters.

OK, and a redirecting factory is concrete. It may be possible to eliminate it by inlining at some call sites, but that's true for every function which is resolved statically.

An external method declaration is concrete and its parameter list only declares a signature. (When invoked, there is no "binding actuals to formals" step which binds argument values to those declarations.)

That's true, an external method is also concrete. In this case, binding formals to actuals may not occur at all (in a way that resembles Dart). For example, the @JS() external invocations on platforms where Dart is compiled to JavaScript are native JavaScript method invocations, which are completely different from the Dart function invocations that the compilers normally generate.

So it's very meaningful to say that an external declaration declares a signature, but no formal parameters.

A forwarding factory constructor is concrete and (IMO) its parameter list only declares a signature. If we forward the argument list blindly, there is no need to bind actuals to formals in the forwarding constructor. If we do bind actuals to formals, then clearly it declares formal variables. Then it should satisfy the requirements assumed by binding-actuals-to-formals (like having a valid default value if it is optional and non-nullable).

Right, what I'm proposing here is that we treat the redirecting factory as a proper function (consistent with the behavior of their tear-offs). So they do bind actuals to formals. But there's no need to remove the inference of default values (consistent with the treatment of super parameters), and it would indeed to massively breaking to do so. There's also no need to stop inlining invocations of redirecting factory constructors whenever possible.

I don't see anything clean about not being able to avoid an actual argument of null for invocations of the constructor named A: class A { const A.([int? i]); const factory A([int i]) = A.; }

There are plenty of ways to avoid that. You have picked the one case that's not precisely replicable by a single constructor, but you can get it anyway.

const A([int i = 0]) : this.(i); // Not factory. // or factory([int i = 0]) => A.(i); // Not const. // or const factory A(int i) = A._; // Not optional. // or, if you need all of it: const factory A([int i]) = A._helper; // Optional, const and factory. const A.helper([int i = 1]) : this.(i); If you want a non-null value as argument to A._, you have to introduce that value somewhere. I'd rather make you do a little extra work for that (I expect) very rare case, than muddy forwarding with modifications of the argument list.

You're relying on having a redirecting factory that redirects to the same class. If you consider the (more typical) case where the redirectee is in a different class then we can see that there are lots of ways that do not work:

class A {
  // Original example.
  const factory A.orig([int i]) = B;

  // const A.n1([int i = 0]) : this.whatever(i); // Won't call a constructor of B, can't redirect to a factory.
  factory A.n2([int i = 0]) => B(i); // Not const.
  const factory A.n3(int i) = B; // Not optional.
  const factory A.n4([int i]) = B._helper; // Optional, const and factory.
}

class B implements A {
  const B([int? i]): assert(i != null);
  const B._helper([int i = 1]) : this(i); // The owner of class `A` wants to add this!
}

void main() {
  A.n2(); // Satisfies the assertion, but isn't const.
  A.n3(1); // Satisfies the assertion, but isn't optional.
  A.n4(); // Satisfies the assertion, but requires that you can edit the class B.
  A.orig(); // Fails at the assertion.
}

I don't think it's a reasonable assumption that we can always edit the class that we're redirecting to. So these approaches are approaching a solution, but none of them is actually working.

To keep this focused: We have found issues everywhere we copy default values to another declaration with a potentially different signature. I believe the best way to solve those problems, and avoid similar or worse problems in the future, is to not copy default values.

I'm sure that's going to be a very popular update in the treatment of super parameters.

eernstg avatar Sep 08 '25 16:09 eernstg

This is the same thing as saying that "Dart should support testing whether an optional parameter was passed or not, and it should support optionally passing an optional parameter".

It is not.

Or rather, the name "Dart" in that sentence is under-defined.

Dart, the user-available language, does not need to support that feature.

Dart, the implementation, needs to be able to forward an argument list without changing it.

The latter can be implemented in a number of ways, like tail-calling which does not require inspecting the argument list. It does not require being able to see which parameters are passed (but the implementation can see that, that's how default values are implemented), and it does but require creating any new argument list at all.

The only case where the language needs these abilities is if the behavior of the forwarding function must be expressible as Dart source code. Existing the behavior using Dart source code, aka. desugaring, is an anti-goal for me. I want to avoid doing that.

So, no.

We're perfectly happy to infer a default value for a super parameter when needed, the situation is equally easy here.

When it works, yes.

class Super {
  final num x;
  Super([this.x = double.nan]);
}

class Sub extends Super {
  Sub([int super.x]);
}

Here we have exactly the same issue again, the default value of the superclass parameter is not valid for the subclass parameter.

And in this case, the dependencies go in the wrong direction. The class can be in different libraries, and this could have been valid when the default value was 0. Then the superclass changed its default value to double.nan, or heck, just 0.0, which should be non-breaking at the API level, because default values are not part of the API.

So, IMO, this should not be an error. Which requires either copying the default value and not complaining that it's not valid for the parameter type, or forwarding the argument-or-lack-of-argument directly. The result is effectively the same... Unless there is another intermediate constructor. So again forwarding the concrete argument is the safest approach.

lrhn avatar Sep 08 '25 18:09 lrhn

Let me start more fundamentally with where I'm coming from.

I take it as given that default values are not part of the public API of a function.

When I say "not part of the public API" of something, I mean very specifically that within the language that thing is not visible to external client code during compilation. It's definitely visible inside the declaration. It may affect the runtime semantics in one way or another, but that's true for all implementation. The "Public API" of a Dart declaration or type are the things that other code can depend on, which also means that changing it can be breaking. A variable being declared const is part of its public API. A getter being declared by a final variable or a get declaration is not.

So when I say that default value of a function parameter is not part of the public API of the function, it means that changing the default value must not break other code. If it does, we have made a mistake, and we should change it. Otherwise the default value, how a function internally represents not being given a value for an optional parameter, stops being an implementation detail. (That's bad enough today, but will be much worse if we ever get non-constant default value expressions or a way to detect an omitted argument directly, without giving the parameter variable a value.)

And we have failed.

With super-parameters:

class A {
   A([num v = 0]);
}
class B extends A {
   B([int super.v]);
}
class C extends B {
   C([int super.v]);
}

This code is supposedly valid, but if you change A's v parameter's default value to 0.0, we have a problem. And, as explained above, this must not become an error.

The "easiest" way to avoid an error is to say that an optional super-parameter forwards the argument or lack of argument directly. That's a local definition of what the subclass constructor does, which does not depend on anything other than the public API of the constructor it forwards to. It may require something of the implementation, but that's what implementations do.

Alternatively we can introduce the default value of the target constructor on the subclass constructor parameter, and ignore that it may not have the correct type, because we know it will be given to a constructor which can accept that type. And then allow passing it to the super-constructor where that default value came from (so it's valid for it, even though its type may also be different). The effect is the same, we let the final transitive target provide the default value and we ignore the types of every parameter until then. Whether we pick the early or late makes no difference.

But anything that will cause an error here is a bug in the language, an we should fix it.

In this case, a subclass can write its own default value, and it will be used, so the second model probably fits this case better.

Forwarding factory constructors:

class A {
  A([int v]) = B;
}
class B extends A {
  B([int v]) = C;
}
class C extends B {
  C([num v = 0]);
}

Again this looks fine, but if we change the parameter to num v = 0.0, it becomes a problem.

And again it must not be an error. The default value of C constructor is an implementation detail and should not affect the validity of the B or A constructors.

And again the easiest solution is to forward the entire argument list from A to B and from B to C, with or without a first positional actual argument, and let C handle the default value assignment like it's defined to. Or, again, we can let the earlier constructors get the default value of the target constructor, then pass that on and ignore any type warnings because the value is known to be the default value of the parameter it's ending up at, so it's guaranteed to be valid.

We could allow the earlier constructors to write a default value, and introduce that as the value they forward. That's OK, it can work and is safe. It'll have to satisfy the type of the parameter it's on, but that's required to be a subtype of the parameter it's forwarded to. I wouldn't do it, because I don't believe there are actual uses for doing it. (And no amount of hypothetical examples will change that). But I guess it's possible, and if there is no different default value, then the implementation can still forward the entire argument list.

nSM-forwarders:

class A {
  noSuchMethod(i) {
    if (i.memberName case == #foo || == #_baz when i.isMethod) {
      print(i.positionalArguments[0] as int);
    }
  }
  void foo(int x);
  void _baz([int x]);
}
// In other library.
class B extends A {
  void foo([int x]);
}
class C extends B {
  void foo([int? x]);
}

Here we specified that B's nSM-forwarder for foo and _baz have parameters with type int and the same default value as ... something. I forget what, and it doesn't matter since it's obviously a bug to assume that there is any default value at all.

This must not be a compile-time error. The unimplementable _baz function is precisely what nSM-forwarders exist avoid being errors.

The foo function is implementable, but it's also within the scope of what noSuchMethod should be able to handle.

The question becomes what the value of i.positionalArguments[0] will be.

Current implementations actually throw a type error in the nSM-forwarder, because the default value is null and they try to assign it to an actual parameter variable with type int. I also think that that should not happen. I'd prefer to just pass that null value on to the Invocation passed to noSuchMethod. It's not that the noSuchMethod implementation can't see a null value there, C().foo() would successfully have a null argument value.

But this is not a compile-time error (and must not become one). A runtime error is annoying, but less problematic.

Summary: Three cases where we have tried to use the default value of one function as the default value of another. In every case it has failed. In the first two in a way that I think is unacceptable in that it makes the default value part of the public API. In the third case, we forgot to account for the case where there is no default value. We have a 0% track record of getting this right, which is why I suggest we stop trying, and start doing something else.

Proposal (and a slight compromise):

  • If an optional super parameter has no declared default value:

    • If the parameter is non-nullable, it's a compile-time error to refer to the local variable introduced by the parameter.
    • If no argument is supplied, the local parameter variable is bound to null, and no argument is passed to the super-constructor for that argument.
  • If an optional super parameter has a declared default value:

    • If no argument is supplied, the declared default value becomes the value of the parameter variable, and becomes the argument passed to the super-constructor.
  • If an optional parameter of a forwarding factory constructor has no declared default value:

    • If no argument is supplied for the parameter, then no argument is supplied to the target constructor's parameter.
    • (Or the default value of the parameter becomes the default value of the corresponding superconstructor parmeter, and if no argument is given, that value is passed to the target constructor's parameter without any type checks.)
  • If an optional parameter of a forwarding factory constructor has a declared default value:

    • If no argument is supplied, the declared default value becomes the argument to the corresponding parameter of the target constructor.

For nSM-forwarders, I still think they shouldn't fail with a runtime type error, but should pass the null value to the invocation, but I care less about noSuchMethod in general.

lrhn avatar Sep 08 '25 20:09 lrhn

And I guess that means I am OK with redirecting factory constructors having the ability to specify the default value.

That's not the cause of any of the problems that I have, and it doesn't make it worse. It's an option, which makes the factory constructor not be purely forwarding its arguments, but it's also optional and you don't need to use it. When you don't, the constructor must work as if it is purely forwarding its arguments. That's the real issue.

lrhn avatar Sep 09 '25 06:09 lrhn

That's a great analysis and write-up, @lrhn! It really clarifies several points.

However, I end up with less absolute conclusions because I disagree on the starting point:

I take it as given that default values are not part of the public API of a function.

In particular:

The "Public API" of a Dart declaration or type are the things that other code can depend on, which also means that changing it can be breaking.

This sounds like it is a statement of a general principle that may serve as a litmus test for the question "Does language feature X constitute a part of the public API of a declaration?". It seems to imply that the decision is based on the relationship "depends on" from client code where this declaration is used. In general, it's used indirectly, e.g., via a reference to a type which is introduced by the declaration, or a static namespace, or some other semantic entity associated with the declaration.

Moreover, it seems likely that it is implied that the associated breakage is a compile time error (so if there's no error, it's not part of the API).

I do not accept this attempt to institutionalize the current static analysis of Dart as the source of truth for the categorization of language features as being part of the API of a declaration or not.

If we consider Dart 1 running in production mode as the context then types in function signatures are not part of the API because type errors are reported as warnings (not errors), and there's no enforcement at run time which means that changing a type (say, the return type of a function) won't break any programs. So we'd be forced to conclude that the types in a function signature are not part of the API of the function (not just relative to that particular version of Dart, but as a general principle).

This is obviously not a useful way to approach discussions about the evolution of a programming language, it is, at most, appropriate for the discussion of whether any particular language feature is part of the API of a declaration in a setting where no language evolution is considered relevant.

Conversely, we could easily envision an evolution of the Dart programming language whereby the default values of a function could be specified in function types and enforced through static analysis and run-time checks, and this would make changes to default values break client code where those default values are specified, e.g., in a function type or in an overriding declaration of an instance method. Function types etc. where default values are not specified would work the same as today. So we could certainly choose to make default values part of the API according to the criterion that you specified.

On the third hand ;-), it is important to note that a default value in a function declaration may definitely be depended on by client code, and the client code may very well break if that default value is changed.

void foo([int i = 0]) {
  if (i == 42) throw "We never use that value!";
  // ... do useful stuff ...
}

void main() {
  foo();
}

Now edit foo such that the default value is 42. This breaks the code in main. It does matter for clients what the default value is because it always, in principle, matters which actual arguments are passed to any given function call.

To me, this implies that it is a counterproductive restriction to say that we only care about compile-time errors (or even warnings), because client code can very well depend on properties of the code that it depends on, even in the case where those properties aren't unquestionably a part of the API.

You could claim that the default value isn't part of the API, and the caller should simply pretend that a default value which can be seen in a function declaration doesn't exist. The developer who is writing the function call may then inspect the DartDoc comment, if any, and conclude something about the semantics of not passing that particular actual argument.

I'm simply arguing that a default value in a function declaration can be taken as harder evidence for the semantics of not passing that actual argument than a description in a comment (because the default value declaration has actual semantics and doesn't get out of sync). The underlying principle is that machine checked properties are always better than comments, all other things equal.

Currently, this type of evidence is completely missing from function types, and it is potentially missing from instance methods (because abstract declarations don't have to specify default values at all). Moreover, there is no enforcement of consistency when it comes to instance methods because an overriding declaration can choose any default values it wants.

This means that a default value declaration amounts to hard evidence only for statically resolved invocations, but even with an instance method it may serve as a hint (and it might be appropriate to report an inconsistency as a bug).

I'd like to have a lint that reports inconsistencies in default values for instance methods. It would also be nice to have syntax to declare default values as an implementation detail (that is, explicitly stating that the given value isn't part of the API, e.g., because we know it's going to be different in overriding declarations). This would also serve as a reminder to DartDoc authors to document the semantics of omitting the corresponding actual argument, based on the understanding that the concrete default value can't be used for that purpose.

I definitely don't think it makes sense to enforce an explicit specification of default values in all function types or all abstract instance methods, but I do think it makes sense to preserve and respect default values as part of the API in cases where the author wishes to commit to a specific value and thus communicate to callers what it means to omit that actual argument.

eernstg avatar Oct 13 '25 10:10 eernstg

I don't want to institutionalize the current static analysis. (In some places I want to change it!)

I do want a consistent (as well as possible within constraints) and predictable line drawn between public API and private implementation, so that an author can trust that they're not promising their clients more than they want to. And I have strong opinions on which side some things should be on.

Any time something ends up being visible to clients, which should have been an implementation choice, we get authors who need to write code differently to avoid making a promise they didn't intend. Sometimes moving away from what would be the direct and obvious implementation.

It's not about whether you can break clients. You always can. We'll assume you don't want to. (Everything here assumes that you don't change the behavior of the function that you change.)

It's about knowing ahead of time which change are API changes and which changes are implementation changes. You can freely change implementation if you preserve semantics. That's refactoring. You should know, ahead of time, if something is an API choice, because then it requires more thought and forethought. You need to think ahead about whether you may want to change it in the future.

Any part of your code where that you can't change to another implementation, because someone else might be depending on the concrete implementation, is de-facto Public API.

The question is then always whether we want a particular choice to be API or implementation.

There are places where things are currently breaking, and I think they definitely should not be.

(Most related to LUB, because the depth of a class should not be part of its API. The API of a class is which super-interfaces it implements and which member signatures it has, plus class modifiers. How the superclass tree looks should be an implementation detail. The class is the level of abstraction, its internal structure is an implementation detail. That's also why you can't do super.super.foo, you don't, and mustn't, know the hierarchical structure of your superclass.)

So I'd rather not intitutionalize the current behavior (not in that sense of the word 😉 ).

Most of the current behavior is fine, because Dart is from a family of object oriented languages which have had good and clear abstraction lines. We inherited most, and even (IMO) improved some, like getters.

Every time someone asks "could we not read a final field of a constant object as a constant operation? We know it's a field!", I will say that no, you don't know that it's a field. Getter/field symmetry enforces that all you are allowed to assume is that it's a getter. The moment we make a field act differently from a getter, we have broken that symmetry, made being a field part of the public API, and thereby encouraged authors to write final Foo _foo; Foo get foo => _foo; so that they can later, maybe, add functionality to the foo getter without it being breaking. That is, exactly what made Java style end up as "private field, public get method", just so you could potentially add something to the getter later. I do not want "being a field" to be part of the public API. There is a large number of things you can write, that I don't want to be part of the public API, because it would encourage people to write more code just to opt out. Or it will accidentally lock you in to something that was only intended as an implementation choice, because you didn't know to write the "more code" in time.

And for default values, I do want using a default value at all to be an implementation choice. It's one choice for how to deal with an absent argument, but it's not the only one. It's possible to have arguments where several different concrete values could be valid default values, with the same semantic behavior. And where a subclass can expand the parameter type and allow a new default value. The caller doesn't know what the default value is, nor that there is a default value, only what omitting an argument means. Using a default value to implement that is an implementation choice.

a default value in a function declaration may definitely be depended on by client code,

No. They only depend on the behavior of calling the function. That the function uses a default value to get that behavior is an implementation choice. There are definitely APIs where it's hard to preserve behavior while changing the default value, when values of the parameter type are valid arguments with all different behaviors.

A hidden subclass with a nullable parameter using null as default value could get around it, only leaking a little in the runtime type. And if the parameter type is Object?, or any type that is subclassable to the author, an implementation may rely on a sentinel value to detect an omitted value. It doesn't have to reveal that sentinel value. In fact, it really shouldn't. Any subclass will have to define its own sentinel.

It does matter for clients what the default value is because it always, in principle, matters which actual arguments are passed to any given function call.

In principle, principle and practice are the same. 😁 There are many, many functions where a change to a default value would necessarily change the function's behavior, and that's what matters. But there are some where you could change the default value, and still end up with the same behavior, either because there are mutiple values that lead to the same behavior, or because it's a sentinel that the user cannot pass at all.

I'm simply arguing that a default value in a function declaration can be taken as harder evidence for the semantics of not passing that actual argument than a description in a comment

Absolutely. It's documentation. There is no harm in showing it to users. It's just not a promise that the function implementation will always use a default value.

If I change the implementation tomorrow, in some magical way, but I have preserved the extrinsic behavior of the function, then clients must not break. I'll have to write some more docs if I can no longer rely on the default value as documentation,

The underlying principle is that machine checked properties are always better than comments, all other things equal.

When the machine doesn't have false positives.

I'd like to have a lint that reports inconsistencies in default values for instance methods

A lint with a warning that you can ignore, if you know what you are doing, is fine. Laudable even. It's just a warning. It cannot break anyone's code. (If someone's build system makes warnings into errors, they broke their own build. That's a valid choice, but it's nobody else's problem.)

lrhn avatar Oct 13 '25 12:10 lrhn