rfc:callable-types

This is an old revision of the document!


PHP RFC: Callable Types

Introduction

This RFC proposes an evolution of the callable type, scoped to argument lists. This should allow more detailed declarations of callable “typehints” - including their arity, argument types and return type.

Here is one basic comparison between a simple callable and a detailed callable prototype declaration:

// Before
/**
 * @param callable(int, int):int $reducer
 */
function reduce(int $a, int $b, callable $reducer): int {
  return $reducer($a, $b);
}
 
// call with bad callback
reduce(1, 2, function($a, $b, $c) {
  return $a + $b + $c;
});
// >>>
// Warning: Missing argument 3 for {closure}(), called in ...
// Notice: Undefined variable: c in ...
// TypeError: Return value of reduce() must be of the type integer, string returned in ...
// After
function reduce(int $a, int $b, callable(int, int):int $reducer): int {
  return $reducer($a, $b);
}
 
// call with bad callback
reduce(1, 2, function($a, $b, $c) {
  return $a + $b + $c;
});
// >>>
// TypeError: Argument 3 passed to reduce() must be callable(int, int):int, callable($a, $b, $c) given...

This concept is also present in other languages, commonly referred as function prototypes or function interfaces.

NOTE: This RFC is not related to “generics”.

Proposal

Why?

Callable types might be particularly useful to write more robust callback based code (functional or not). This includes:

Better Error Messages

// before
// after

Safety Before Side Effects

While callable types can offer more debug friendlier messages, there are other factor that could favor earlier failures approach. In some cases, a callback might create or precede a side effect.

Specifying a more constrained callable type allows a given routine to fail before an operation that creates side effect:

// example of a rejected callable that shows early failure

This certainly can be achieved without callable types: perhaps using reflection or manual type checking of return values but certainly callable types can make this kind of situation less tedious and therefore more productive.

Self Documented Code

It's common to see callback based libraries doing their best to declare the callable signatures on docblocks, hoping that the consumers will be able to figure out what to pass around:

// before

With callable types, the codebase simply becomes much closer to “self documented”, so this is not contrived to runtime checks that will assist the user land.

// after

Empower Anonymous Functions

Currently the only possible way to formally specify the type information of a callable is using classes:

// before
 
interface FooCallback {
    function __invoke(int $left, int $right): int;
}
 
function crunch_data(array $data, FooCallback $callback): array {
    $result = [];
    foreach($data as $left => $right) $result[] = $callback($left, $right);
 
    return $result;
}
 
$crunched = crunch_data(
    [1 => 2, 3 => 4],
    new class implements FooCallback {
        function __invoke(int $left, int $right): int { return $left * $right; }
    }
);

Unfortunately, this solution completely excludes anonymous functions usage. But with a more specific signature, callable types could work as an interface over __invoke:

// after
 
function crunch_data(array $data, callable(int $left, int $right):int $callback): array {
    $result = [];
    foreach($data as $left => $right) $result[] = $callback($left, $right);
 
    return $result;
}
 
$crunched = crunch_data([1 => 2, 3 => 4], function(int $left, int $right): int {
    return $left * $right;
});

Why Not?

One might say that function prototypes “does not fit the PHP loosely typed model”. This might be true to part of the community, at some extent, but it's possible to affirm that PHP already supports function prototypes - but their potential is currently 'confined' inside interfaces and abstract classes definitions:

interface FooInterface {
    function foo(A $a, B $b): C; // this is a function prototype, part of an interface
}
 
abstract class Foo {
    function bar(A $a, B $b): C; // this is a function prototype too
}

Arity

=> Add case by case + examples with less required args, more required args, optional args...

Variance and Signature Validation

Variance is supported and adheres to LSP. This means that whenever function of type F is expected, any function that takes equal or more general input than F and gives equal or narrower output than F, can be considered of type F. Classes in argument/return type of a callable typehint are a subject to variance, primitives are not.

Examples:

class A {}
class B extends A {}
 
function foo(callable(A) $cb) { }
function bar(callable(B) $cb) { }
 
foo(function (A $a) {}); // there's no variance in this case, A can be substituted by A
foo(function (B $b) {}); // Uncaught TypeError: Argument 1 passed to foo() must be callable of compliant signature: callable(A), callable(B $b) given
bar(function (A $a) {}); // callable(A) > callable(B) - we can substitute callable(B) with callable(A) because the latter has a wider input than the latter

The same rules apply to return type of a callable:

function foo(callable: A $cb) { }
 
foo(function (): A { return new A; }); // A == A
foo(function (): B { return new B; }); // B < A this closure will return narrower type than what is expected by "foo", which means it can be a substitute for callable: A

A function that takes less arguments than what is expected is also considered contravariant:

function foo(callable($a, $b) $cb) { }
foo(function($a) { }); // callable($a) > callable($a, $b)

Optional arguments count just like any other arguments:

function foo(callable() $cb) { }
foo(function (A $a = null) { }); // TypeError
// even though technically callable($a = null) could be called without arguments (as foo() expects) it would lead to type error later on if used as callable().
// Because PHP doesn't prohibit you from passing extra arguments which function doesn't really expect nor take.
// That means that foo() could call $cb and pass anything as a first argument and if it would be something that is not an instance of A the call would fail.
// Hence "function (A $a = null) {}" has a prototype of callable(A $a) (it doesn't matter if the argument is optional or not)
// And callable(A $a) < callable(), so the call to foo() will fail here

When callable type is nested (when you have callable(callable(A))) variance has to be inversed with each nesting level. So if we have callable(A) > callable(B) then callable(callable(A)) < callable(callable(B)).

Syntax Choices

The syntax is similar to the one already used to declare prototypes on interfaces and abstract classes member, and should look meaningful to anyone who already knows how to declare a PHP interface. There are only two minor distinctions:

While declaring a callable type, it's possible to omit the argument names from argument lists when a given argument has type information. The argument names can be valuable, but there are cases they represent unnecessary verbosity. Hence why they can be omitted:

// the declarations below are synonyms:
 
function foo(callable(string $string_a, string $string_b):string $callback) {}
 
function foo(callable(string, string):string $callback) {}

The other distinction is that empty argument lists can be omitted:

// the declarations below are synonyms:
 
function foo(callable():string $callback) {}
 
function foo(callable:string $callback) {}

It's already common to see analogous syntax inside docblocks even though there are no regnant conventions, like:

/**
 * Foo function 
 *
 * @arg string                   $action
 * @arg callable(Logger $logger) $callback
 */
function foo($action, callable $callback) {
  // ...
}

Backward Incompatible Changes

The proposal has no BC breaks.

Proposed PHP Version(s)

The proposal targets PHP 7.1.

RFC Impact

Performance

The addition of more specific callable types definitions will not offer any penalty to the “simpler” callable already in use.

This is a synthetic benchmark between a function using a “simple callable” and a function using a “complex callable” on it's argument list:

// benchmark code

=> The loss will probably be negligible so we'll need to count CPU instructions to show the difference.

To Opcache

=> ...

Unaffected PHP Functionality

The current callable implementation should not have any of it's behaviors altered and will still be available. The RFC merely augments how callable can be declared.

Future Scope

Named Callabe Types

Named callable types were deliberately left out the current proposal. The rationale behind this decision is that most callable types are very transient, so the proposed inlined syntax will solve 90% of the use cases. This bit could be added through another RFC.

Another reason is that depending on how the Union Types RFC is designed it might include named types and, naturally, named callable types would be supported.

The following could also be an option for a dedicated named callable types syntax:

callable NodeComparator(Node $left, Node $right): bool;
 
function filter_nodes(array $nodes, NodeComparator $predictor) {
  // ...
}
 
function sort_nodes(array $nodes, NodeComparator $predictor) {
  // ...
}

Reflection API

A reflection API will be proposed in case the RFC is approved.

Proposed Voting Choices

=> ...

Patches and Tests

Links to any external patches and tests go here.

If there is no patch, make it clear who will create a patch, or whether a volunteer to help with implementation is needed.

Make it clear if the patch is intended to be the final patch, or is just a prototype.

Implementation

=> ...

References

rfc/callable-types.1447252143.txt.gz · Last modified: 2017/09/22 13:28 (external edit)