Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Functions downcast #362

Open
rrousselGit opened this issue May 20, 2019 · 4 comments
Open

Functions downcast #362

rrousselGit opened this issue May 20, 2019 · 4 comments

Comments

@rrousselGit
Copy link

rrousselGit commented May 20, 2019

Consider the following definitions:

void foo(int a, String b) {}

typedef MyCallback = void Function(int a, String b, double c);

Currently, we cannot assign the function foo to MyCallback and instead have to write such closure:

MyCallback cb = (int a, String b, _) => foo(a, b);

This proposal is to allow such assignment:

MyCallback cb = foo;

Reasoning

While the current behavior is technically correct, it makes adding parameters to a callback a breaking change.
And at the same time, it is common to not care about some parameters passed to a callback (hence why we have _).

There are many examples where such a feature may be useful.

For example, Iterable.map/Iterable.forEach may want to pass the index of the item to the callback as a second parameter:

[].map((value, index) => something);

Another example is the optional child argument on the Builder pattern of Flutter widgets.
Some widgets like AnimatedBuilder or ValueListenableBuilder takes an optional child for performance optimizations like so:

ValueListenableBuilder(
  valueListenable: listenable,
  // `child` passed to the function is the value pased to ValueListenableBuilder constructor
  builder: (context, value, child) => something,
  child: // cached widget
);

But some widgets such as StreamBuilder currently do not offer such child argument – which makes them inconsistent.
Ideally, these widgets should take an optional child, but again that is a relatively significant breaking change.

With this proposal, the changes described above would not be a breaking change any more.

@lrhn
Copy link
Member

lrhn commented May 20, 2019

Would this be an automatic wrapping (an int Function(int) f used where an int Function(int, String) is needed is wrapped as (int $1, String _) => f($1)) or a change to the subtype relation (f is int Function(int, String) is true)?

The former choice means that the function changes identity. The wrapper function is created implicitly on each assignment, and will only be equal, not identical, to another wrapping of the same function.

The latter choice may have wide-ranging side effects. We currently require a subclass method's function type to be a subtype of the superclass method's function type. If we extend the subtyping relation this way, then

class C {
  int foo(int x, String y) => 0;
}
class D extends C {
  int foo(int x) => 0;
}

would become valid. We probably do not want that, it's too error-prone.
(We would probably still not allow calling a function with more arguments than its static function type allows).

That all suggests that we are talking about implicit wrappers/adaptors.

The danger of allowing such an implicit conversion is that it might mask errors.
A function taking no arguments can pass almost anywhere without any static warning, even if it is completely wrong for the job. Automatic coercion between unrelated types is generally dangerous, exactly because it may hide errors that could otherwise be detected at compile-time.
This kind of coercion is paticularly fragile because it allows missing data to be added, which means that there are not two different sides that must match up. The coercion cannot fail.

@rrousselGit
Copy link
Author

rrousselGit commented May 20, 2019

Would this be an automatic wrapping [...] or a change to the subtype relation [...]?

I would say that void Function(int foo) should be a subtype of void Function().
No wrapper, just a downcast.

then

class C {
 int foo(int x, String y) => 0;
}
class D extends C {
  int foo(int x) => 0;
}

would become valid.

I don't see any reason for this example to be valid.
Method overrides are upcasts, not downcasts, as seen in the following example:

class C {
  int foo(int x, String y) => 0;
}
class D extends C {
  int foo(int x, String y, [double z]) => 0;
}

On the other hand, the following may become a valid scenario:

void Function() cb;
if (cb is void Function(int)) {
  cb(42);
}

Methods excluded, I think that the potential mistakes are very rare, and the gain probably outweigh the loss.
But I'm obviously biased, and I'm not too sure how we can mesure it.

@lrhn
Copy link
Member

lrhn commented May 20, 2019

If an int Function(int) can be used where an int Function(int, String) is expected, then the former is a subtype of the latter.
So, if we make int Function(int) a subtype of int Function(int, X) for any X (or sequence of types X, and I assume named parameters as well, then Null Function() is a subtype of all function types.
It really is as if all finite function types were infinite varargs where you couldn't access the other parameters: Null Function([Object _1, Object _2, ..... to infinity]).

That is why int Function(int, String, [double]) is a valid override of int Function(int, String) - any place you can use the latter, you can use the former as well (and not vice-versa, so it's a proper subtype).

So, if you now allow (int x)=>x where an int Function(int, String) callback is expected, int Function(int) is the subtype, and a class override is allowed if the subclass method's function type is a subtype of the superclass method's function type (plus some extra restrictions). So,

class C {
  int foo(int x, String y) => x;
}
class D extends C {
  itn foo(int x) => x;
}

should be valid if subtyping is the only constraint. After all:

int Function(int, String) foo = D().foo;
foo(42, "yep");

is valid, and so is:

(D() as C).foo(42, "yep");

It works somewhat like a covariant override in that D().foo(42, "yep") is invalid (we don't want to call functions with more parameters than we know they need. Well, I assume that).

Interestingly, this means that int Function(int) and int Function(int, [String]) are equivalent (mutual subtypes, either assignable to the other with no down-cast).
We already have int Function(int, [String]) <: int Function(int), but I assume you would want an int Function(int, [String]) callback to be satisfied by (int x) => x too, so we also have the other direction.

Which means that the type system breaks down and becomes unsound:

int Function(int) f1 = (int x, [String s]) =>  x + (s?.length ?? 0);  // valid by current type system.
int Function(int, int) f2 = f1;  // new subtyping rule.
f2(1, 1);  // run-time type error because an int is not a string.

So, using subtyping is not going to work.

The latter scenario is already valid, and the test is satisfiable:

void Function() cb = ([int x]) {};
if (cb is void Function(int)) {
  c(42);
}

This runs today.

@rrousselGit
Copy link
Author

rrousselGit commented May 20, 2019

So, if we make int Function(int) a subtype of int Function(int, X)

Sorry, I got confused.
In what I had in mind, that's the opposite. int Function(int, X) extends from int Function(int)

But yeah, that's now going to work as I expected. The assignment won't do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants