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 share 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 modifying functions and constructs to work and simply 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, updated $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 point errors in averages.

function aggregate(array $numbers): array {
    $count = '0';
    $sum = '0';
 
    // Currently: Both accumulators must be listed in use() with &
    array_walk($numbers, function ($number) use (&$count, &$sum) {
        $count = bcadd($count, '1');
        $sum = bcadd($sum, $number);
    });
 
    // With scope functions: $count and $sum are just used.
    array_walk($numbers, fn($number) {
        $count = bcadd($count, '1');
        $sum = bcadd($sum, $number);
    )});
 
    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

Closure operations

  • 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: Mutating the existing closure returns the same instance. Closure::bind() and Closure::bindTo() may rebind only the scope (class name). Passing anything but the current instance to $newThis raises an Error:
class Ancestor { function call() { return "parent"; } }
class A extends Ancestor {
    function call() { return "child"; }
 
    function do() {
        $fn = fn() { print $this->call(); };
        (Closure::bind($fn, $this, "Ancestor"))(); // Allowed, prints "parent"
        Closure::bind($fn, null); // Cannot rebind $this of a scope function
    }
}

Lifetime

A scope function cannot leave the lifetime of its defining function as its variables live in the parent.

A scope function may be invoked any number of times while that frame is alive. Once a functions exits, via return, an exception, or through a generator/fiber being closed the closure is invalidated.

Scope functions declared inside other scope functions share the outermost scope. All nested instances are invalidated together when the outermost ordinary function exits.

This check happens at the outermost scope exit, and at call time.

function issue() {
    $x = 1;
    return fn() {
        return $x;
    };
}
// Error: Scope function closure must not outlive the declaring scope
$f = issue();

Should the thrown error be caught and the stored scope Closure be accessed afterwards, the Closure will throw when called. The eager error is intended as a debugging aid. Every other operation on the Closure remains valid“.

  • Generators & Fibers: yield, yield from, and Fiber::suspend() inside a scope function work normally. If the parent exits while the coroutine is suspended inside an active scope function call, the coroutine is unwound.

Suspended callers

When a parent exits while a Generator or Fiber is suspended inside an active scope function call, the coroutine must be cleaned up before the scope function frame can be destroyed. Generators and Fibers handle this differently.

Generators are unwound as if they lost their last reference. The Generator is closed, and subsequent iteration behaves as for any other closed Generator.

Fibers are forcibly unwound up to the point where the scope function exits, then re-suspended. The Fiber object stays alive.

Fiber::suspend() is raises an Error if invoked from a finally block.

A subsequent Fiber::resume() forcibly throws an Error into the Fiber just past the scope function call.

Includes and eval

A scope function at the top level of a script, include, require, or eval will be cleaned up at the end of that script.

As included code shares the caller's symbol table, the closures variables would otherwise leak into the caller after the include returns, where any subsequent invocation would raise the “escape” error. An automatic cleanup, removing any variables holding scope functions, is done to avoid requiring users to do this unset() manually in every included file.

// include.php
<?php
$f = fn() {};
// entry.php
<?php
require 'include.php';
var_dump(isset($f)); // bool(false)

Re-evaluation

The same scope function declaration (same source code location) invalidates the previous instance. Should it still be called, an error is raised.

Any scope closure is only allocated a single slot, preventing recursion and double declaration. There are two choices: guaranteeing uniqueness (returning the active Closure again) or invalidating the previous Closure. We chose the second option to avoid the misconception that scope functions would capture the current variables. E.g. in foreach loops, it will always print the current active value and never the value at declaration time.

$closures = [];
for ($i = 0; $i < 3; $i++) {
    $closures[] = fn() { return $i; };
}
var_dump($closures[2]()); // int(3)
 
$closures[0](); // Error: Cannot call scope function: defining scope has exited

Alternatives Considered

Having older closure handles 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

Inlining

Given that a scope function shares the parent's scope, inlining a call into the caller is easy.

array_map, array_filter, usort, and others can be optimized at the compiler level to eliminate per-element call overhead when invoked with a scope function literal.

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