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
  la...@garfieldtech.com

-- 
PHP Internals - PHP Runtime Development Mailing List
To unsubscribe, visit: https://www.php.net/unsub.php

Reply via email to