From 04dfb219166f13252d03186a5bf87d62eff0c744 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 23:09:23 +0100 Subject: [PATCH] compile one-argument pipelines and predicates without spreading --- src/Internal/functions.php | 17 +++++++++++++++++ src/Predicate/AllOfPredicate.php | 13 +++++++++++++ src/Predicate/AnyOfPredicate.php | 13 +++++++++++++ src/Predicate/ClosurePredicate.php | 5 ++++- src/Predicate/NegatedPredicate.php | 5 ++++- src/Utils/FunctionPipeline.php | 15 +++++++++------ tests/CompileTest.php | 21 +++++++++++++++++++++ 7 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/Internal/functions.php b/src/Internal/functions.php index 7380c17..04e2f8f 100644 --- a/src/Internal/functions.php +++ b/src/Internal/functions.php @@ -111,3 +111,20 @@ function target(callable $callable): callable return $callable; } } + +/** + * Whether calling a closure with one argument is the same as calling it with more: a userland, non-variadic closure + * with one required parameter, as PHP drops the arguments past it. A closure wrapping it can then take one argument + * as well, which is cheaper to call than a variadic one. + * + * @internal + */ +function isUnary(\Closure $closure): bool +{ + $reflection = new \ReflectionFunction($closure); + + return !$reflection->isInternal() + && !$reflection->isVariadic() + && $reflection->getNumberOfParameters() === 1 + && $reflection->getNumberOfRequiredParameters() === 1; +} diff --git a/src/Predicate/AllOfPredicate.php b/src/Predicate/AllOfPredicate.php index e554b3c..0d33605 100644 --- a/src/Predicate/AllOfPredicate.php +++ b/src/Predicate/AllOfPredicate.php @@ -14,6 +14,7 @@ use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; use function Kadet\Functional\Internal\callback; +use function Kadet\Functional\Internal\isUnary; class AllOfPredicate extends AbstractPredicate implements Compilable { @@ -53,6 +54,18 @@ class AllOfPredicate extends AbstractPredicate implements Compilable { $predicates = array_map(static fn (Predicate $predicate) => callback($predicate), $this->predicates); + if ($predicates && count(array_filter($predicates, isUnary(...))) === count($predicates)) { + return static function ($value) use ($predicates): bool { + foreach ($predicates as $predicate) { + if (!$predicate($value)) { + return false; + } + } + + return true; + }; + } + return static function (mixed ...$args) use ($predicates): bool { foreach ($predicates as $predicate) { if (!$predicate(...$args)) { diff --git a/src/Predicate/AnyOfPredicate.php b/src/Predicate/AnyOfPredicate.php index a625286..b3949b1 100644 --- a/src/Predicate/AnyOfPredicate.php +++ b/src/Predicate/AnyOfPredicate.php @@ -14,6 +14,7 @@ use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; use function Kadet\Functional\Internal\callback; +use function Kadet\Functional\Internal\isUnary; class AnyOfPredicate extends AbstractPredicate implements Compilable { @@ -54,6 +55,18 @@ class AnyOfPredicate extends AbstractPredicate implements Compilable { $predicates = array_map(static fn (Predicate $predicate) => callback($predicate), $this->predicates); + if ($predicates && count(array_filter($predicates, isUnary(...))) === count($predicates)) { + return static function ($value) use ($predicates): bool { + foreach ($predicates as $predicate) { + if ($predicate($value)) { + return true; + } + } + + return false; + }; + } + return static function (mixed ...$args) use ($predicates): bool { foreach ($predicates as $predicate) { if ($predicate(...$args)) { diff --git a/src/Predicate/ClosurePredicate.php b/src/Predicate/ClosurePredicate.php index bca0134..23567d3 100644 --- a/src/Predicate/ClosurePredicate.php +++ b/src/Predicate/ClosurePredicate.php @@ -14,6 +14,7 @@ use Kadet\Functional\Compilable; use Kadet\Functional\Decorator; use function Kadet\Functional\compile; +use function Kadet\Functional\Internal\isUnary; class ClosurePredicate extends AbstractPredicate implements Decorator, Compilable { @@ -54,6 +55,8 @@ class ClosurePredicate extends AbstractPredicate implements Decorator, Compilabl return $compiled; } - return static fn (mixed ...$args): bool => $compiled(...$args); + return isUnary($compiled) + ? static fn ($value): bool => $compiled($value) + : static fn (mixed ...$args): bool => $compiled(...$args); } } \ No newline at end of file diff --git a/src/Predicate/NegatedPredicate.php b/src/Predicate/NegatedPredicate.php index 3e89103..08b4044 100644 --- a/src/Predicate/NegatedPredicate.php +++ b/src/Predicate/NegatedPredicate.php @@ -15,6 +15,7 @@ use Kadet\Functional\Decorator; use Kadet\Functional\Predicate; use function Kadet\Functional\compile; +use function Kadet\Functional\Internal\isUnary; class NegatedPredicate extends AbstractPredicate implements Decorator, Compilable { @@ -49,6 +50,8 @@ class NegatedPredicate extends AbstractPredicate implements Decorator, Compilabl { $negated = compile($this->negated); - return static fn (mixed ...$args): bool => !$negated(...$args); + return isUnary($negated) + ? static fn ($value): bool => !$negated($value) + : static fn (mixed ...$args): bool => !$negated(...$args); } } \ No newline at end of file diff --git a/src/Utils/FunctionPipeline.php b/src/Utils/FunctionPipeline.php index d62f6e0..22bd56c 100644 --- a/src/Utils/FunctionPipeline.php +++ b/src/Utils/FunctionPipeline.php @@ -12,6 +12,7 @@ namespace Kadet\Functional\Utils; use Kadet\Functional\Compilable; use function Kadet\Functional\compile; +use function Kadet\Functional\Internal\isUnary; class FunctionPipeline implements Compilable { @@ -82,13 +83,15 @@ class FunctionPipeline implements Compilable return $first; } - if (count($functions) === 1) { - $next = $functions[0]; + // A first function taking one argument ignores any other, so the compiled pipeline can take one as well. + if (isUnary($first)) { + return static function ($value) use ($first, $functions) { + $result = $first($value); + foreach ($functions as $function) { + $result = $result instanceof UnpackedArgs ? $function(...$result->getArrayCopy()) : $function($result); + } - return static function (...$args) use ($first, $next) { - $result = $first(...$args); - - return $result instanceof UnpackedArgs ? $next(...$result->getArrayCopy()) : $next($result); + return $result; }; } diff --git a/tests/CompileTest.php b/tests/CompileTest.php index 781ce0e..cbf8bd1 100644 --- a/tests/CompileTest.php +++ b/tests/CompileTest.php @@ -292,4 +292,25 @@ class CompileTest extends \PHPUnit\Framework\TestCase $this->assertFalse($compiled(2)); $this->assertSame(0, $inner->calls); } + + public function testUnaryCompiledCallablesMatchWithExtraArguments() + { + $callables = [ + f\pipe(fn ($x) => $x, strtoupper(...)), + f\predicate(fn ($x) => $x === 'a'), + f\not(fn ($x) => $x === 'a'), + f\all(fn ($x) => $x === 'a', p\same('a')), + f\any(fn ($x) => $x === 'b', p\same('a')), + ]; + + foreach ($callables as $callable) { + $this->assertSame($callable('a', 'extra'), f\compile($callable)('a', 'extra')); + } + } + + public function testCompiledEmptyCombinatorsTakeNoArguments() + { + $this->assertSame(f\all()(), f\compile(f\all())()); + $this->assertSame(f\any()(), f\compile(f\any())()); + } }