dart-lang / language

Design of the Dart language
Other
2.65k stars 202 forks source link

Resolve to non-`null` version of extension when available and criteria are met #3666

Open FMorschel opened 6 months ago

FMorschel commented 6 months ago

Say we have this extension:

extension ObjectsExtensions<T extends Object> on T {
  T? when(bool Function(T self) predicate, {T? Function(T self)? orElse}) {
    if (predicate(this)) return this;
    return orElse?.call(this);
  }
}

I'd like to propose that Dart should resolve to this one if orElse is given and its result is non-null:

extension ObjectsExtensions2<T extends Object> on T {
  T when(bool Function(T self) predicate, {required T Function(T self) orElse}) {
    if (predicate(this)) return this;
    return orElse.call(this);
  }
}

That would mean less ! added to code where you know it's not needed. Or, at least I would like to know why this would not be viable right now. Today it always resolves to the first one no matter what you do.

I'm sorry if this should not be asked here (if it was already answered or similar).

lrhn commented 6 months ago

If by "resolve to", you mean that if both extensions are in scope, it would choose the second if possible. That doesn't work today because which extension to use is decided without looking at the member signature, it only depends on the on T type. Which means that if you have both, with identical on clauses, they'll always conflict.

It's not impossible to try to resolve such a conflict by looking at the member signature. If only one of them match what you're trying to do, then it would be a simple win. If more than one still match, which they would here, we'd have to do some more tie-breaking, which is both complicated and potentially flaky (small changes to types might flip you to using a different implementation).

Also, it implicitly introduces method overloading, two members with the same name on the same type, which Dart doesn't otherwise have. We don't want extension methods to be preferable to just writing a method on the class, that gives the wrong incentives.

So possible, not likely.

But you do say that it always resolves to the first one, which isn't true if this was about resolving between different extension methods, because today they are just a conflict.

So if you are asking for type inference to treat the former declaration as the latter in ... some cases (when orElse is provided, and presumably as a function which returns T, not T? which would be valid, then I'm not sure I can find a compiler-applicable typing rule which makes that typing possible.

The return type would change if orElse is provided (and returning T), because then it's guaranteed that the body will return a T, but that requires a level of code understanding that we cannot assume compilers to have, and a way to give more than one static type to the same function. We need a machine-understandable rule in order to change the type.

There are similar cases in platform libraries where we'd really like a parameter to be required if a type is non-nullable, like Future.value([T? value]), which will throw if T is non-nullable and the argument is omitted.

My personal favorite proposal for that is to make arguments optional if and only if the parameter is nullable, then Future.value(T value) could omit the argument for Future<int?>.value(), but not for Future<int>.value(1). That's a mighty big language change, and probably not going to happen. 😞

The other option is actual overloading, where you define both the functions,

extension ObjectsExtensions<T extends Object> on T {
  T? when(bool Function(T self) predicate, {T? Function(T self)? orElse}) {
    if (predicate(this)) return this;
    return orElse?.call(this);
  }

  T when(bool Function(T self) predicate, {required T Function(T self) orElse}) {
    if (predicate(this)) return this;
    return orElse(this);
  }
}

and then the compiler figures out which one to call depending on "stuff" (context type, actual arguments). Also a big language change, not particularly likely either.

My guess is "not possible without making too big changes to the language".

eernstg commented 6 months ago

Here is one possible design. It isn't particularly pretty, but it might be acceptable in practice:

class _When<T> {
  final T self;
  final bool Function(T) predicate;

  _When(this.self, this.predicate);

  // Non-nullable processing: Must pass `orElse` argument, returns non-null.
  T orElse(T Function(T self) orElse) => predicate(self) ? self : orElse(self);

  // Nullable processing: Can omit `ifElse` argument, may return null.
  T? ifElse([T? Function(T self)? ifElse]) =>
      predicate(self) ? self : ifElse?.call(self);

  // Use null when the predicate is false ("there is no else").
  T? call() => predicate(self) ? self : null;
}

extension When<T extends Object> on T {
  _When<T> when(bool Function(T self) predicate) => _When(this, predicate);

  // Special case when "there is no else", avoiding the curried invocation.
  T? whenn(bool Function(T self) predicate) => predicate(this) ? this : null;
}

void doPrint(Object? o) => print(o);

extension on Object? {
  void get print => doPrint(this);
}

bool trueP(Object o) => true;
bool falseP(Object o) => false;

void main() {
  1.when(trueP)().print; // '1'.
  1.when(falseP).ifElse().print; // 'null'.
  1.when(falseP).ifElse((i) => i + 1).print; // '2'.
  3.when(trueP).orElse((i) => i + 1).print; // '3'.
  3.when(falseP).orElse((i) => i + 1).print; // '4'.

  5.whenn(trueP).print; // '4'.
  5.whenn(falseP).print; // 'null'.
}

So we're changing the provision of a named argument to an invocation of a method on an intermediate object (that is, we're doing f(e1).m(e2) rather than f(e1, m: e2), saves one character ;-).

We use orElse to handle the non-nullable case, just like in the original examples. The argument is required and the result is non-nullable.

We use ifElse for the nullable case. In this case we can omit the argument or pass null, and the returned result is always nullable.

Finally, there's an extra extension method whenn handling the case where we just wish to use null if the predicate returns false (so we can use e.whenn(pred) rather than e.when(pred)()).

FMorschel commented 6 months ago

Thanks for both of your answers!

I see what you mean Lasse, thank you for your time. Appreciate you explained everything in so much detail.

Erik, thanks a lot for the detailed solution! I'm not sure I could fully grasp all of what you meant to do with your full code. I'll give myself some time to think about it.

eernstg commented 6 months ago

I'm not sure I could fully grasp all of what you meant to do with your full code.

Here's the features I was aiming for:

If the language had supported some full-fledged version of static overloading then we might have been able to do as follows:

// Error in current Dart, but could be allowed by a hypothetical Dart-with-static-overloading.
extension<T> on T {
  T? when(bool Function(T self) predicate, {T? Function(T self)? orElse}) {
    if (predicate(this)) return this;
    return orElse?.call(this);
  }
  T when(bool Function(T self) predicate, {required T Function(T self) orElse}) {
    if (predicate(this)) return this;
    return orElse.call(this);
  }
}

This may or may not give rise to a compile-time error (for the declarations, if they aren't sufficiently different to enable a disambiguation, or for each call site if it is ambiguous), depending on the details of the hypothetical static overloading mechanism. It seems very likely that there will be ambiguity issues, or that a subtle tie-breaker rule would make it hard to understand when reading the code which one is chosen for each call site.

We could use different names for the named parameters to handle the "else" case, which would eliminate the ambiguity:

extension<T> on T {
  T? when(bool Function(T self) predicate, {T? Function(T self)? ifElse}) {...}
  T when(bool Function(T self) predicate, {required T Function(T self) orElse}) {...}
}

If orElse: e is passed then we're calling the second when, if no named argument is passed or ifElse: e is passed then we're calling the first one. As always, we still have a problem with tear-offs being ambiguous.

Anyway, we don't have static overloading, so let's stop speculating about how we could use it.

However, in current Dart we could simply choose different names for the two methods, and we're done!

extension<T> on T {
  T? whenOrNull(bool Function(T self) predicate, {T? Function(T self)? orElse}) {...}
  T when(bool Function(T self) predicate, {required T Function(T self) orElse}) {...}
}

This might be a perfectly usable response to the original request: "There is no problem, just stop wanting to call both of them when!" ;-)

My assumption was that this was a no-go, we should aim for a design that allows both approaches to use the same name.

Now the problem is that we want to have different return types for the two cases. We could use fancy mechanisms like intersections of function types to declare that one kind of actual argument list yields one return type, and another kind of actual argument list yields another return type, but that's basically just summoning all the ambiguity demons again (and Dart doesn't have intersections of function types anyway).

So we do need to have two different methods in order to be able to have two different return types.

We can bring two different methods in play by having two different invocations, and we can reuse the idea about naming something ifElse for the nullable case and orElse for the non-nullable case:

void main() {
  // Original request.
  1.when(myPredicate, orElse: someFunction);

  // Another approach that looks similar.
  1.when(myPredicate).orElse(someFunction);
}

We can do this by introducing a helper class like _When in my example here.

This is a general technique that allows us to introduce a branching mechanism in invocations: We could have several named arguments n1 .. nk, intending that the caller should pass exactly one of them, and then we'll run different code when nj is passed for each j in 1 .. k. But that doesn't work very well: We don't have a way to enforce that exactly one of those named parameters are passed (we might not pass any of them at all, or we might pass several).

So we pass some parameters to the method and let it return a helper object that has methods n1 .. nk. This means that we will call r.m(...).nj(e) rather than r.m(..., nj: e). As a result, we can have different return types based on the choice of nj.

In the concrete example we support ifElse and get return type T?, and we support orElse and get return type T.

The "method that emulates a named argument" returns the final result in this case, but it might also return a function that accepts some more arguments. So we can use this technique to introduce multiple branching points, if needed.

One issue remains: We'd like to have a very concise form when an invocation omits as much as possible. In the actual example ifElse accepts an optional positional parameter, so we can do 1.when(myPredicate).ifElse() in order to do when on the receiver with nothing specified for the "else" case (which means that we'll just return null). But that's not as nice as 1.when(myPredicate). However, 1.when(myPredicate) has type _When<int>, not int?, so that won't work.

So I added a shortcut: If you don't want to specify anything for the "else" case then you can use the call method of the helper object, which makes it 1.when(myPredicate)(), because invocation of an object that has a method named call can be abbreviated to a mere invocation (we could also do 1.when(myPredicate).call(), but that defeats the purpose, of course). So we can now use 1.when(myPredicate)() which is nearly as nice as 1.when(myPredicate).

I added one more shortcut for the same case: An extra method named whenn. The idea is the same: We want the situation where nothing is specified for the "else" case to be concise, and now we can do 1.whenn(myPredicate).

A nicer naming could be whenOrNull, but if conciseness is the top priority then whenn might be OK.

I hope this clarifies the line of thinking that made me consider turning ifElse and orElse into methods rather than named parameters.

FMorschel commented 6 months ago

Yes, it does! Thanks a lot for explaining!