On Thu, Aug 14, 2025, at 21:30, Larry Garfield wrote:
> Hi folks.  We have discovered a subtle issue with the pipes implementation 
> that needs to be addressed, although we're not entirely sure how.  
> Specifically, Derick found it while trying to ensure Xdebug plays nice with 
> pipes.
> 
> The problem has to do with the operator precedence of short closures vs 
> pipes.  For example:
> 
> $result = $arr
>   |> fn($x) => array_map(strtoupper(...), $x)
>   |> fn($x) => array_filter($x, fn($v) => $v != 'O');
>   
> It's logical to assume that would be parsed as 
> 
> $result = $arr
>   |> (fn($x) => array_map(strtoupper(...), $x))
>   |> (fn($x) => array_filter($x, fn($v) => $v != 'O'));
> 
> Which then compiles into, essentially:
> 
> $temp = (fn($x) => array_map(strtoupper(...), $x))($arr);
> $temp = (fn($x) => array_filter($x, fn($v) => $v != 'O'))($temp);
> $result = $temp;
> 
> Or 
> 
> $result = (fn($x) => array_filter($x, fn($v) => $v != 'O'))((fn($x) => 
> array_map(strtoupper(...), $x))($arr));
> 
> (depending on how you want to visualize it.)
> 
> That was the intent of the RFC.
> 
> However, because short closures are "greedy" and assume anything before the 
> next semi-colon is part of the closure body, it's actually getting parsed 
> like this:
> 
> $result = $arr
>     |> fn($x) => (
>         array_map(strtoupper(...), $x)
>             |> fn($x) => (
>                 array_filter($x, fn($v) => $v != 'O')
>             )
>     )
> ;
> 
> Which would compile into something like:
> 
> $result = (fn($x) => (fn($x) => array_filter($x, fn($v) => $v != 
> 'O'))(array_map(strtoupper(...), $x)))($arr);
> 
> Which is not the intent.
> 
> Humorously, if all the functions and closures involved are pure, this parsing 
> difference *usually* doesn't matter.  The result is still computed as 
> intended.  That's why it wasn't caught during earlier reviews or by automated 
> tests.  However, there are cases where it matters.  For example:
> 
> 42
>     |> fn($x) => $x < 42
>     |> fn($x) => var_dump($x);
>     
> One would expect that to evaluate to false in the first segment, then 
> var_dump() false.  But it actually var_dump()s 42, because it gets 
> interpreted as (42 |> fn($x) => var_dump($x)) first.
> 
> The incorrect wrapping also makes debugging vastly harder, even if the 
> computed result is the same, as the expression is broken up "wrong" into 
> multiple nested closures, stack traces are different than one would expect, 
> etc.
> 
> The challenge is conflicting requirements.  Closures have extremely low 
> precedence right now, specifically so that they will grab everything that 
> comes after them as a single expression.  However, we also want pipes to 
> allow a step to be a closure; that would typically mean making pipe bind even 
> lower than closures, but that's not viable because it would result in 
> 
> $result = 'x' |> fn ($x) => strtoupper($x) 
> 
> being interpreted as
> 
> ($result = 'x') |> (fn ($x) => strtoupper($x))
> 
> Which would be rather pointless.
> 
> So far, the best suggestion that's been put forward (though we've not tried 
> implementing it yet) is to disallow a pipe inside a short-closure body, 
> unless the body is surrounded by ().  So this:
> 
> fn($x) => $x 
>   |> fn($x) => array_map(strtoupper(...), $x)
>   |> fn($x) => array_filter($x, fn($v) => $v != 'O');
>  
> Today, that would run somewhat by accident, as the outer-most closure would 
> claim everything after it.  With the new approach, it would be interpreted as 
> passing `fn($x) => $x` as the argument to the first pipe segment, which would 
> then be mapped over, which would fail.  You'd instead need to do this:
> 
> fn($x) => ($x 
>   |> (fn($x) => array_map(strtoupper(...), $x))
>   |> (fn($x) => array_filter($x, fn($v) => $v != 'O'))
> );
> 
> Which is not wonderful, but it's not too bad, either.  That's probably the 
> only case where pipes inside a short-closure body would be useful anyway.  
> And if PFA (https://wiki.php.net/rfc/partial_function_application_v2) and 
> similar closure improvements pass, it will greatly reduce the need for mixing 
> short closures and pipes together in either precedence, so it won't come up 
> very often.
> 
> There are a few other operators that bind lower than pipe (see 
> https://github.com/php/php-src/blob/fd8dfe1bfda62a3bd9dd1ff7c0577da75db02fcf/Zend/zend_language_parser.y#L56-L73),
>  which would therefore need wrapping parentheses.  For many of them we do 
> want them to be lower than pipe, so just moving pipe's priority down isn't 
> viable. However, most of those are unlikely to be used inside a pipe segment, 
> so are less likely to come up.  The most likely would be a bail-out exception:
> 
> $value = null;
> $value
>     |> fn ($x) => $x ?? throw new Exception('Value may not be null')
>     |> fn ($x) => var_dump($x);
> 
> Which would currently be interpreted something like:
> 
> $c = function ($x) {
>   $c = function ($x) {
>     return var_dump($x);
>   };
>   return $x ?? throw $c(new Exception('Value may not be null'));
> };
> $c(null);
> 
> This would not throw the exception as expected, unless parentheses are added. 
>  It would var_dump() an exception and then try to throw the return of 
> varl_dump(), which would fatal.
> 
> RM approval to address this during the 8.5 beta phase has been given, but we 
> still want to have some discussion to make sure we have a good solution.
> 
> So, the question:
> 
> 1. Does this seem like a good solution, or is there a problem we've not 
> spotted yet?
> 2. Does anyone have a better solution to suggest?
> 
> Thanks to Derick, Ilija, and Tim for tracking down this annoying edge case.
> 
> -- 
>   Larry Garfield
>   la...@garfieldtech.com
> 

Hi Larry,

What would happen to this:

$x = fn($y) => $y |> strtoupper(…) |> var_dump(…);

with this change? I would expect $x to be a chain — which is what it currently 
is. But if I understand correctly, it will become:

$x = (fn($y) => $y) |> strtoupper(...) |> var_dump(...);

which would be interesting... but this that you shared as the current "correct" 
way:

> fn($x) => ($x 
>   |> (fn($x) => array_map(strtoupper(...), $x))
>   |> (fn($x) => array_filter($x, fn($v) => $v != 'O'))
> );

This looks correct to me… but I’ve been using pipes since they were merged. So, 
I might be biased. I would have written the above like this though:

fn($x) => $x
  |> __(array_map(...))(strtoupper(...), __)
  |> __(array_filter(...))(__, fn($v) => $v != 'O');

or if I was feeling wordy:

$array_map = __(array_map(...);
$array_filter = __(array_filter(...);
fn($x) => $x => $array_map(strtoupper(...), __) |> $array_filter(__, fn($v) => 
$v != 'O');

This suggested change would completely break that if I'm understanding 
correctly. Then PFA would reallow this syntax? I dunno ... changing it makes it 
sound inconsistent to me.

FWIW, I like it how it is for writing reusable pipes chains.

PS. The code above is implemented like so:

function __(...$steps): Closure {
    $first = array_shift($steps) ?? throw new LogicException();
   
    return function(...$steps) use ($first) {
        return function($x) use ($first, $steps) {
            foreach($steps as &$val) {
                if ($val === __) {
                    $val = $x;
                }
            }
            return $first(...$steps);
        };
    };
}
define('__', __(...));

ish.

— Rob

Reply via email to