compile predicates and pipelines

This commit is contained in:
Kacper Donat 2026-10-27 23:02:34 +01:00
parent eac27c9e63
commit 0c91360368
10 changed files with 258 additions and 17 deletions

View File

@ -10,9 +10,12 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Predicate;
class AllOfPredicate extends AbstractPredicate
use function Kadet\Functional\compile;
class AllOfPredicate extends AbstractPredicate implements Compilable
{
/**
* Array of predicates to check if all are satisfied.
@ -41,4 +44,19 @@ class AllOfPredicate extends AbstractPredicate
return true;
}
public function compile(): \Closure
{
$predicates = array_map(compile(...), $this->predicates);
return static function (mixed ...$args) use ($predicates): bool {
foreach ($predicates as $predicate) {
if (!$predicate(...$args)) {
return false;
}
}
return true;
};
}
}

View File

@ -10,9 +10,12 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Predicate;
class AnyOfPredicate extends AbstractPredicate
use function Kadet\Functional\compile;
class AnyOfPredicate extends AbstractPredicate implements Compilable
{
/**
* Array of predicates to check if all are satisfied.
@ -42,4 +45,19 @@ class AnyOfPredicate extends AbstractPredicate
return false;
}
public function compile(): \Closure
{
$predicates = array_map(compile(...), $this->predicates);
return static function (mixed ...$args) use ($predicates): bool {
foreach ($predicates as $predicate) {
if ($predicate(...$args)) {
return true;
}
}
return false;
};
}
}

View File

@ -10,9 +10,12 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
class ClosurePredicate extends AbstractPredicate implements Decorator
use function Kadet\Functional\compile;
class ClosurePredicate extends AbstractPredicate implements Decorator, Compilable
{
/**
* @var \Closure
@ -38,4 +41,19 @@ class ClosurePredicate extends AbstractPredicate implements Decorator
{
return $this->closure;
}
/**
* A closure already declared to return bool is returned as it is (compiled, if it wraps a Compilable).
*/
public function compile(): \Closure
{
$compiled = compile($this->closure);
$type = (new \ReflectionFunction($compiled))->getReturnType();
if ($type instanceof \ReflectionNamedType && $type->getName() === 'bool' && !$type->allowsNull()) {
return $compiled;
}
return static fn (mixed ...$args): bool => $compiled(...$args);
}
}

View File

@ -11,9 +11,10 @@ namespace Kadet\Functional\Predicate;
use function Kadet\Functional\{ all, any };
use Kadet\Functional\Compilable;
use Kadet\Functional\Predicate;
class ConstantPredicate extends AbstractPredicate
class ConstantPredicate extends AbstractPredicate implements Compilable
{
/** @var boolean */
private $value;
@ -58,6 +59,13 @@ class ConstantPredicate extends AbstractPredicate
return $this->value;
}
public function compile(): \Closure
{
$value = $this->value;
return static fn (mixed ...$args): bool => $value;
}
public static function always()
{
static $always;

View File

@ -10,10 +10,13 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
use Kadet\Functional\Predicate;
class NegatedPredicate extends AbstractPredicate implements Decorator
use function Kadet\Functional\compile;
class NegatedPredicate extends AbstractPredicate implements Decorator, Compilable
{
/**
* Predicate to be negated.
@ -41,4 +44,11 @@ class NegatedPredicate extends AbstractPredicate implements Decorator
{
return $this->negated;
}
public function compile(): \Closure
{
$negated = compile($this->negated);
return static fn (mixed ...$args): bool => !$negated(...$args);
}
}

View File

@ -19,7 +19,7 @@ use function Kadet\Functional\predicate;
*/
function equals($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $expected == $value;
});
}
@ -31,7 +31,7 @@ function equals($expected): Predicate
*/
function same($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value === $expected;
});
}
@ -43,7 +43,7 @@ function same($expected): Predicate
*/
function gt($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value > $expected;
});
}
@ -55,7 +55,7 @@ function gt($expected): Predicate
*/
function ge($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value >= $expected;
});
}
@ -67,7 +67,7 @@ function ge($expected): Predicate
*/
function lt($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value < $expected;
});
}
@ -79,7 +79,7 @@ function lt($expected): Predicate
*/
function le($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value <= $expected;
});
}

View File

@ -54,7 +54,7 @@ function field(string $field, $predicate)
*/
function instance(string $class)
{
return new ClosurePredicate(function ($value) use ($class) {
return new ClosurePredicate(function ($value) use ($class): bool {
return $value instanceof $class;
});
}

View File

@ -9,8 +9,11 @@
namespace Kadet\Functional\Utils;
use Kadet\Functional\Compilable;
class FunctionPipeline
use function Kadet\Functional\compile;
class FunctionPipeline implements Compilable
{
/**
* @var callable[]
@ -45,4 +48,47 @@ class FunctionPipeline
: $callable($arg);
}, new UnpackedArgs($args));
}
/**
* The first function gets all arguments, each next one the result of the previous one (spread when it is
* UnpackedArgs), like invoking the pipeline.
*/
public function compile(): \Closure
{
if (empty($this->functions)) {
return static fn (...$args) => new UnpackedArgs($args);
}
// A curried function decides on its own whether to call or keep currying, so it is called as is.
$functions = array_map(
static fn (callable $function) => $function instanceof CurriedFunction
? static fn (...$args) => $function(...$args)
: compile($function),
$this->functions,
);
$first = array_shift($functions);
if (empty($functions)) {
return $first;
}
if (count($functions) === 1) {
$next = $functions[0];
return static function (...$args) use ($first, $next) {
$result = $first(...$args);
return $result instanceof UnpackedArgs ? $next(...$result->getArrayCopy()) : $next($result);
};
}
return static function (...$args) use ($first, $functions) {
$result = $first(...$args);
foreach ($functions as $function) {
$result = $result instanceof UnpackedArgs ? $function(...$result->getArrayCopy()) : $function($result);
}
return $result;
};
}
}

View File

@ -68,10 +68,18 @@ function memoize(callable $callable)
}
/**
* Flattens a callable into a plain closure. Compilable decorators are compiled, so calling the result does not go
* through them; any other callable is converted with closure().
* Flattens a callable into a plain closure. Compilable objects (decorators, pipelines, predicates) are compiled, so
* calling the result does not go through them, also when given as a closure of their `__invoke` (e.g. `$pipe(...)`);
* any other callable is converted with closure().
*/
function compile(callable $callable): \Closure
{
if ($callable instanceof \Closure) {
$reflection = new \ReflectionFunction($callable);
$bound = $reflection->getClosureThis();
return $bound instanceof Compilable && $reflection->getName() === '__invoke' ? $bound->compile() : $callable;
}
return $callable instanceof Compilable ? $callable->compile() : closure($callable);
}

View File

@ -10,7 +10,11 @@
use Kadet\Functional as f;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
use Kadet\Functional\Predicate\ClosurePredicate;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Utils\FunctionPipeline;
use Kadet\Functional\Utils\PartiallyAppliedFunction;
use Kadet\Functional\Utils\UnpackedArgs;
use const Kadet\Functional\_;
class CompileTest extends \PHPUnit\Framework\TestCase
@ -177,4 +181,115 @@ class CompileTest extends \PHPUnit\Framework\TestCase
$this->assertSame(6, $compiled(3));
$this->assertSame(24, $compiled(4));
}
public function testPipelineAndPredicatesAreCompilable()
{
$this->assertInstanceOf(Compilable::class, f\pipe('strtoupper'));
$this->assertInstanceOf(Compilable::class, f\predicate(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\not(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\all(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\any(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\always());
}
public function testCompiledPipelineDoesNotInvokePipeline()
{
$spy = new class(trim(...), strtoupper(...)) extends FunctionPipeline {
public int $calls = 0;
public function __invoke(...$args)
{
$this->calls++;
return parent::__invoke(...$args);
}
};
$compiled = $spy->compile();
$this->assertSame('A', $compiled(' a '));
$this->assertSame(0, $spy->calls);
}
public function testCompiledPipelineMatchesPipeline()
{
$pipelines = [
f\pipe(),
f\pipe(fn (...$args) => $args),
f\pipe(trim(...), strtoupper(...)),
f\pipe(fn ($a, $b) => $a + $b, fn ($x) => $x * 2, fn ($x) => $x - 1),
f\pipe(fn ($a, $b) => new UnpackedArgs([$b, $a]), fn ($a, $b) => "$a$b"),
];
foreach ($pipelines as $pipeline) {
$this->assertEquals($pipeline(1, 2), f\compile($pipeline)(1, 2));
}
}
public function testCompiledPipelineKeepsCurrying()
{
$pipeline = f\pipe(strtoupper(...), f\curry(fn ($a, $b) => "$a$b"));
$this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b'));
}
public function testCompileUnwrapsClosureOfCompilable()
{
$spy = new class(strtoupper(...)) extends FunctionPipeline {
public int $calls = 0;
public function __invoke(...$args)
{
$this->calls++;
return parent::__invoke(...$args);
}
};
$this->assertSame('A', f\compile(\Closure::fromCallable($spy))('a'));
$this->assertSame('A', f\compile($spy(...))('a'));
$this->assertSame(0, $spy->calls);
}
public function testCompiledPredicatesMatchPredicates()
{
$values = [0, 1, 2, 3, '1', 'x'];
$cases = [
[f\predicate(fn ($x) => $x), $values],
[f\predicate(is_numeric(...)), $values],
[p\gt(1), $values],
[p\gt(1)->not(), $values],
[f\not(fn ($x) => $x > 1), $values],
[f\all(p\gt(0), p\lt(3)), $values],
[f\any(p\lt(1), p\gt(2)), $values],
[f\always(), $values],
[f\never(), $values],
[p\property('x', p\gt(1)), [(object) ['x' => 1], (object) ['x' => 2]]],
];
foreach ($cases as [$predicate, $inputs]) {
$compiled = f\compile($predicate);
foreach ($inputs as $value) {
$this->assertSame($predicate($value), $compiled($value));
}
}
}
public function testCompiledPredicateDoesNotInvokePredicate()
{
$inner = new class(fn ($x) => $x > 1) extends ClosurePredicate {
public int $calls = 0;
public function __invoke(...$args): bool
{
$this->calls++;
return parent::__invoke(...$args);
}
};
$compiled = f\compile(f\all($inner->not(), p\lt(5)));
$this->assertTrue($compiled(1));
$this->assertFalse($compiled(2));
$this->assertSame(0, $inner->calls);
}
}