[Discussion] Callable types via Interfaces
| From: | Larry Garfield | Date: | Thu, 20 Apr 2023 17:24:57 +0000 |
| Subject: | [Discussion] Callable types via Interfaces | ||
| Groups: | php.internals | ||
| Request: | Send a blank email to internals+get-120083@lists.php.net to get a copy of this message | ||
Hi folks. This is a pre-discussion, in a sense, before a formal RFC. Nicolas Grekas and I have
been kicking around some ideas for how to address the desire for typed callables, and have several
overlapping concepts to consider. Before going down the rabbit hole on any of them we want to gauge
the general feeling about the approaches to see what is worth pursuing.
We have three "brain dump" RFCs on this topic, although these are all still in super-duper
early stages so don't sweat the details in them at this point. We just want to discuss the
basic concepts, which I have laid out below.
https://wiki.php.net/rfc/allow_casting_closures_into_single-method_interface_implementations
https://wiki.php.net/rfc/allow-closures-to-declare-interfaces-they-implement
https://wiki.php.net/rfc/structural-typing-for-closures
## The problem
function takeTwo(callable $c): int
{
return $c(1, 2);
}
Right now, we have no way to statically enforce that $c is a callable that takes 2 ints and returns
an int. We can document it, but that's it.
There is one loophole, in that an interface may require an __invoke() method:
interface TwoInts
{
public function __invoke(int $a, int $b): int;
}
And then a class may implement TwoInts, and takeTwo() can type against TwoInts. However, that works
only for classes, which are naturally considerably more verbose than a simple closure and represent
only a subset of the possible callable types.
The usual discussion has involved a way to specify a callable type's signature, like so:
function takeTwo(callable(int $a, int $b): int $c)
{
return $c(1, 2);
}
But that runs quickly into the problem of verbosity, reusability, and type aliases, and the
discussion usually dies there.
## The alternative
What we propose is to instead lean into the interface approach. Specifically, recall that all
closures in PHP are actually implemented as classes in the engine. That is:
$f = fn(int $x, int $y): int => $x + $y;
actually turns into (approximately) this in the engine:
$f = new class extends \Closure
{
public function __invoke(int $x, int $y): int
{
return $x + $y;
}
}
(It doesn't do syntax translation but that's effectively what the engine does.)
So all that's really missing is a way for arbitrary closures to denote that they implement an
interface, and then they can be used wherever an interface is required. That neatly sidesteps the
verbosity and reusability issues, and since interfaces are already well-understood there's no
need to wait for type aliases.
It would not support the old-style funky callables like a function string or [$obj,
'method'], but with the advent of first-class-callables those are no longer recommended
anyway so not supporting them is probably a good thing.
The same would also work for property types, which can easily type against an interface. That would
mostly sidestep the current limitation of typing a property as
callable, since you
could provide a more-specific type instead for a double-win.
## The options
There's three ways we've come up with that this design could be implemented. In concept
they're not mutually exclusive, so we could do one, two, or three of these. Figuring out which
approach would get the most support is the purpose of this thread.
### castTo
The first is to add a castTo() method to Closure. That would produce a new object that has the same
logic as the closure, but explicitly implements the interface.
That is, this:
$fn2 = $fn->castTo(TwoInts::class);
Is roughly logically equivalent to:
$fn2 = new class($fn) implements TwoInts {
public function __construct(private callable $fn) {}
public function __invoke(int $a, int $b): int
{
return $this->fn(func_get_args();
}
};
(Whether that's what the implementation actually does or if it's smarter about it is an
open question.)
In theory, this would also support any single-method interface, not just those using __invoke().
The other options below would not support that.
This does have a number of open edge cases, like what to do with a closure that is already bound to
an object.
### Function interfaces
The second option is to allow closures to declare up front what interfaces they implement. So:
$f = fn(int $x, int $y): int implements TwoInts => $x + $y;
This has the advantage of being more statically analyzable (both visually and for parsers). It may
also be more performant (in theory), as it could translate almost trivially to:
$f = new class extends \Closure implements TwoInts
{
public function __invoke(int $x, int $y): int
{
return $x + $y;
}
}
The downside is that it only works for user-defined closures that declare their support up-front,
statically. Something like strlen(...) or strtr(...) wouldn't work. It's also a bit
verbose, though using bindTo() directly on the closure is of similar length:
$f = (fn(int $x, int $y): int => $x + $y)->bindTo(TwoInts::class);
### Structural typing for closures
The third option would necessitate having similar logic in the engine to the first. In this case,
we take a "structural typing" approach to closures; that is, "if the types match at
runtime, it must be OK." This is probably closest to the earlier proposals for a
callable(int $x, int $y): int syntax (which would by necessity have to be structural),
but essentially uses interfaces as the type alias.
function takeTwo(TwoInts $c): int
{
return $c(1, 2);
}
$result = takeTwo(fn(int $x, int $y): int => $x + $y);
In this approach, no up-front work is needed. A callable/closure that conforms to an interface with
__invoke() "just works" when it's used. Essentially this would involve detecting
that the argument is a callable and the parameter is an interface with __invoke(), then trying to
castTo() that interface. If it works, pass the result. If not, fail in some way.
This approach would support any arbitrary closure, including strtr(...) style FCC closures. A
closure would not need to pre-declare its support ("nominal typing"), which makes it more
flexible. Callables "just work."
The downside here is complexity. Currently, class type conformance is determined ahead of time, and
a type check is just a lookup on a list on the class. This would necessitate loading the interface
(possibly autoloading it) within the function call action, attempting the cast operation, and
handling the potential fault. It also means it would only happen at the function boundary or
property assignment; a closure would never work with instanceof, class_implements, etc., because to
those it would still be "just" a \Closure.
A callable syntax literal (as previously discussed) would have most of the same challenges.
## The discussion
So those are the options. We feel that the interface-based approach is strong, and a good way
forward for getting typed callables without a bunch of dependent features needed first. These three
ways of getting there are all potentially viable (give or take implementation details and edge cases
in all cases, as always), all have their own pros and cons, and in concept we could very easily
adopt more than one, or combine them into a single RFC.
Before we dig into any of those edge cases, however, we want to throw the question out: Is this
general approach even acceptable? Are there implementation challenges to any of them we're not
seeing? Would you vote for any or all of these proposals or oppose on principle? Are you
interested in helping us implement any of them :-) ?
Please discuss, so we can decide how to proceed toward a real concrete proposal.
--
Larry Garfield
larry@garfieldtech.com