rfc:scope-functions

PHP RFC: Scope Functions

Introduction

This RFC proposes a variant of closures for the common case of a callback that runs inside its defining function.

Current PHP closures that are supposed to affect outside variables require explicit capturing:

function () use (&$x, &$y, &$z) {
    // ...
}

The list is verbose, potentially unfamiliar to readers due to references, and silently wrong when forgotten as adding a new variable to the body without updating use() just shadows the variable.

For use cases involving the array_* functions, transaction wrappers, async callbacks, sorting, and others, having closure automatically its parents’ scope makes these functions shorter and easier to read, write, and maintain.

$autoScoped = 1;
$closure = fn () {
    var_dump($autoScoped);
    $autoScoped = 2;
}
 
// int(1)
$closure();
 
// int(2)
var_dump($autoScoped);

Proposal

Introduce a new type of closure that shares variables with the enclosing scope.

Syntax

fn(parameter_list)[: return_type] { body }

Disambiguated from arrow functions by a { block vs the => expression. The fn keyword is reused to avoid introducing a new keyword. fn(...) { ... }.

Semantics

A scope function is a Closure whose variables are scoped to the parent function. All reads, writes, and newly introduced variables share the parent's scope:

function example() {
    $x = 1;
    (fn() {
        $x++;
        $new = 'hi';
    })();
    // int(2), string(2) "hi"
    var_dump($x, $new);
}

Beyond shared variables, a scope function behaves like any other closure:

  • Return: return returns from the scope function only, not from the parent.
  • Exceptions: Exceptions propagate normally to the caller of the scope function, then up the stack.
  • Call stack: Calls to a scope function appear as their own frame in debug_backtrace() and stack traces.
  • $this: Inside of methods, $this inside the closure is the same as outside of it.
  • extract(), compact(), $$var: All scope modifing functions and constructs to work and modify the parent's scope.

Examples

Async callbacks

// Current approach:
// - `use` for each callback
// - Results come back as an indexed array that the caller has to unpack.
function findSharedLikes($baseUrl, $userIdOne, $userIdTwo, $token, $client) {
    $promises[] = \Amp\async(function () use ($baseUrl, $client, $userIdOne, $token) {
        $req = $client->request("$baseUrl/api/user/$userIdOne/likes", ["Authorization" => "Bearer $token"]);
        return $req->getBody()->buffer();
    });
    $promises[] = \Amp\async(function () use ($baseUrl, $client, $userIdTwo, $token) {
        $req = $client->request("$baseUrl/api/user/$userIdTwo/likes", ["Authorization" => "Bearer $token"]);
        return $req->getBody()->buffer();
    });
    $results = \Amp\await($promises);
    return array_intersect($results[0], $results[1]);
}
 
// With scope functions:
// - results are written into nicely named variables in the parent scope
// - return is easy to read
function findSharedLikes($baseUrl, $userIdOne, $userIdTwo, $token, $client) {
    $promises[] = \Amp\async(fn() {
        $req = $client->request("$baseUrl/api/user/$userIdOne/likes", ["Authorization" => "Bearer $token"]);
        $likesOne = $req->getBody()->buffer();
    });
    $promises[] = \Amp\async(fn() {
        $req = $client->request("$baseUrl/api/user/$userIdTwo/likes", ["Authorization" => "Bearer $token"]);
        $likesTwo = $req->getBody()->buffer();
    });
    \Amp\await($promises);
 
    return array_intersect($likesOne, $likesTwo);
}

Transaction wrapper

A simple callback-based DB transaction wrapper that can be used with exceptions or return values.

class DatabaseConnection
{
    const TRANSACTION_ABORT = 1;
 
    public function transaction(callable $callback): void {
        try {
            if ($callback() === self::TRANSACTION_ABORT) {
                $this->rollback();
                return;
            }
        } catch (\Throwable $e) {
            $this->rollback();
            throw $e;
        }
        $this->commit();
    }
}
 
$connection->transaction(fn () {
    // No need to capture $connection here or use $affectedRows by reference
    $affectedRows = $connection->query("UPDATE ...");
    if ($affectedRows === 0) {
        return DatabaseConnection::TRANSACTION_ABORT;
    }
    // ...
});
// $affectedRows is available after the transaction
log("Ran transaction, update affected $affectedRows rows");

Sorting with an external comparator

function sortByPriority(array $items, PriorityTable $priorities, Metrics $metrics): array {
    // Track sort performance
    $comparisons = 0;
 
    // Today: $priorities could be used "normally" and $comparisons must be used by reference
    usort($items, function ($a, $b) use ($priorities, &$comparisons) {
        $comparisons++;
        return $priorities->of($a) <=> $priorities->of($b);
    });
 
    // With scope functions: Variables are simply in scope
    usort($items, fn($a, $b) {
        $comparisons++;
        return $priorities->of($a) <=> $priorities->of($b);
    });
 
    $metrics->recordComparisons($comparisons);
    return $items;
}

Accumulation

Aggregation of numbers into a total and count to avoid floating points errors in averages.

function aggregate(array $numbers): array {
    $count = '0';
    $sum = '0';
 
    // Currently: Both accumulators must be listed in use() with &
    array_walk(function ($number) use (&$count, &$sum) {
        $count = bcadd($count, '1');
        $sum = bcadd($sum, $number);
    }, $numbers);
 
    // With scope functions: $count and $sum are just used.
    array_walk(fn($number) {
        $count = bcadd($count, '1')
        $sum = bcadd($sum, $number)
    )}, $numbers);
 
    return [$count, $sum];
}

Filtering with a side channel for errors

function validateList(Validator $validator, array $items): array {
    $errors = [];
 
    // Today: $validator (read) and $errors (write) both have to be listed.
    $valid = array_filter($items, function ($item) use ($validator, &$errors) {
        if (!$reason = $validator->isValid($item)) {
            $errors[] = [$item, $reason];
            return false;
        }
        return true;
    });
 
    // With scope functions: nothing to list, intent reads top to bottom.
    $valid = array_filter($items, fn($item) {
        if (!$reason = $validator->isValid($item)) {
            $errors[] = [$item, $reason];
            return false;
        }
        return true;
    });
 
    return [$valid, $errors];
}

Restrictions

A scope function cannot leave the lifetime of its defining function.

The following forms are therefore not useful and thus forbidden:

  • static fn() {}: $this is already shared, a static scope function would be incoherent.
Fatal error: Scope functions cannot be static in %s on line %d
  • fn() use () {}: Every variable is already shared. use() is redundant.
Parse error: syntax error, unexpected token "use", expecting "{" in %s on line %d
  • Recursion: Recursive calls will raise an Error.
Fatal error: Uncaught Error: Cannot recursively call scope function in %s:%d
  • Cloning: clone $fn on a scope function raises an Error. Two clones would share the same parent scope, interfering with each other's state.
  • Rebinding: Closure::bind() and Closure::bindTo() raise an Error. The scope and $this come from the parent and cannot be rebound.
  • Outliving the parent: A scope function may only be called while its defining function is still on the stack. Storing one in a property, a global, a static variable, or returning it from the parent raises an Error when the parent returns.
Fatal error: Uncaught Error: Scope function closure must not outlive the declaring scope in %s:%d
  • Re-evaluation in the same frame: Re-evaluating the same scope function declaration in the same parent frame invalidates the previous instance rather than silently retargeting it to the new one.
Fatal error: Uncaught Error: Cannot call scope function: defining scope has exited in %s:%d
  This avoids silently retargeting an old closure handle to a new instance.
  • Generators: There are no restrictions on yield/yield from. Generators will be closed as the scoped function goes out of scope.

Alternatives Considered

Letting an older closure handle transparently resolve to the current instance when the same scope function declaration is re-evaluated:

This was rejected because it makes closure identity unstable: A stored closure would silently change meaning after later execution of the parent frame.

Reflection

ReflectionFunctionAbstract::isScopeFunction(): bool

Returns true for scope function closures. Available on ReflectionFunction.

Backward Incompatible Changes

None. fn(...) { ... } is currently a parse error.

Open Issues

None.

Future Scope

Scoped functions can be inlined with their callers. For example, for array_map this is a trivial operation that will improve performance.

Proposed PHP Version

PHP 8.6.

Voting

Accept the scope functions feature as described? Yes/No. Requires 2/3 majority.

References

rfc/scope-functions.txt · Last modified: by edorian