add compilable decorators

This commit is contained in:
Kacper Donat 2026-10-27 22:07:36 +01:00
parent 8e824ef93c
commit 1e94adc872
8 changed files with 338 additions and 3 deletions

View File

@ -23,7 +23,8 @@
},
"files": [
"./src/functions.php",
"./src/Predicates/index.php"
"./src/Predicates/index.php",
"./src/Collections/Internal/functions.php"
]
},
"suggest": {

View File

@ -0,0 +1,33 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional\Collections\Internal;
use function Kadet\Functional\compile;
/**
* Compiles a collection callback, adapted to its arity.
*
* The returned closure is called with ($value, $key). Internal functions (including first-class callables like
* `trim(...)`) and non-variadic closures declaring exactly one parameter receive only the value, so the key is
* never mistaken for an optional argument.
*
* @internal
*/
function callback(callable $callable): \Closure
{
$compiled = compile($callable);
$reflection = new \ReflectionFunction($compiled);
if ($reflection->isInternal() || (!$reflection->isVariadic() && $reflection->getNumberOfParameters() === 1)) {
return static fn ($value) => $compiled($value);
}
return $compiled;
}

21
src/Compilable.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional;
/**
* A callable object that can be flattened into a plain closure.
*/
interface Compilable
{
/**
* Returns a closure that behaves exactly like invoking this object, but does not go through it on each call.
*/
public function compile(): \Closure;
}

View File

@ -11,9 +11,13 @@ namespace Kadet\Functional\Utils;
use function Kadet\Functional\apply;
use Kadet\Functional\Compilable;
use function Kadet\Functional\compile;
use Kadet\Functional\Decorator;
use function Kadet\Functional\partial;
use function Kadet\Functional\reflect;
class FixedFunction implements Decorator
class FixedFunction implements Decorator, Compilable
{
/**
* @var callable
@ -39,4 +43,51 @@ class FixedFunction implements Decorator
{
return $this->fixed;
}
/**
* The compiled closure passes itself as the self-reference, so recursive calls stay compiled too. It applies
* arguments like apply(), with the fixed function reflected once here instead of on every call.
*/
public function compile(): \Closure
{
$fixed = $this->fixed;
try {
$reflection = reflect($fixed);
} catch (\ReflectionException $exception) {
$self = static function (...$args) use (&$self, $fixed) {
return $fixed($self, ...$args);
};
return $self;
}
$variadic = $reflection->isVariadic();
$required = $reflection->getNumberOfRequiredParameters();
$all = $reflection->getNumberOfParameters();
// A curried function decides on its own whether to call or keep currying, so it is kept as is.
$call = $fixed instanceof CurriedFunction ? $fixed : compile($fixed);
$self = static function (...$args) use (&$self, $fixed, $call, $variadic, $required, $all) {
array_unshift($args, $self);
switch (true) {
case $variadic:
return partial($fixed, ...$args);
case count($args) >= $all:
$applied = $call(...array_slice($args, 0, $all));
return is_callable($applied) ? apply($applied, ...array_slice($args, $all)) : $applied;
case count($args) >= $required:
return $call(...$args);
default:
return partial($fixed, ...$args);
}
};
return $self;
}
}

View File

@ -10,11 +10,13 @@
namespace Kadet\Functional\Utils;
use const Kadet\Functional\_ as PLACEHOLDER;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
use function Kadet\Functional\compile;
use function Kadet\Functional\not;
use function Kadet\Functional\Predicates\same;
class PartiallyAppliedFunction implements Decorator
class PartiallyAppliedFunction implements Decorator, Compilable
{
protected $callable;
protected $arguments = [];
@ -64,6 +66,28 @@ class PartiallyAppliedFunction implements Decorator
return $this->callable;
}
public function compile(): \Closure
{
$callable = compile($this->callable);
$arguments = $this->arguments;
$placeholders = array_keys($arguments, PLACEHOLDER, true);
if (empty($placeholders)) {
return static fn (...$args) => $callable(...$arguments, ...array_values($args));
}
return static function (...$args) use ($callable, $arguments, $placeholders) {
$args = array_values($args);
$count = min(count($placeholders), count($args));
for ($i = 0; $i < $count; $i++) {
$arguments[$placeholders[$i]] = $args[$i];
}
return $callable(...$arguments, ...array_slice($args, $count));
};
}
public static function fix(callable $callable, ...$args)
{
$applied = new self($callable, ...$args);

View File

@ -71,4 +71,13 @@ function fix(callable $callable, ...$args)
function memoize(callable $callable)
{
return new MemoizedFunction($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().
*/
function compile(callable $callable): \Closure
{
return $callable instanceof Compilable ? $callable->compile() : closure($callable);
}

View File

@ -0,0 +1,47 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Kadet\Functional as f;
use function Kadet\Functional\Collections\Internal\callback;
use const Kadet\Functional\_;
class CallbackTest extends \PHPUnit\Framework\TestCase
{
public function testInternalFunctionGetsValueOnly()
{
$this->assertSame('a', callback(trim(...))(' a ', 'xyz'));
}
public function testInternalStringCallableGetsValueOnly()
{
$this->assertSame('A', callback('strtoupper')('a', 'key'));
}
public function testSingleParamClosureGetsValueOnly()
{
$this->assertSame([1], callback(function ($v) { return func_get_args(); })(1, 'k'));
}
public function testTwoParamClosureGetsKey()
{
$this->assertSame('k=v', callback(fn ($v, $k) => "$k=$v")('v', 'k'));
}
public function testVariadicClosureGetsKey()
{
$this->assertSame(['v', 'k'], callback(fn (...$args) => $args)('v', 'k'));
}
public function testDecoratorIsCompiled()
{
$callback = callback(f\partial(fn ($prefix, $v, $k) => "$prefix$k=$v", '>'));
$this->assertSame('>k=v', $callback('v', 'k'));
}
}

149
tests/CompileTest.php Normal file
View File

@ -0,0 +1,149 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Kadet\Functional as f;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
use Kadet\Functional\Utils\PartiallyAppliedFunction;
use const Kadet\Functional\_;
class CompileTest extends \PHPUnit\Framework\TestCase
{
public function testCompileReturnsClosureForPartial()
{
$compiled = f\partial(str_replace(...), 'a', _, 'banana')->compile();
$this->assertInstanceOf(\Closure::class, $compiled);
$this->assertNotInstanceOf(Decorator::class, $compiled);
$this->assertSame('bxnxnx', $compiled('x'));
}
public function testCompiledPartialDoesNotInvokeDecorator()
{
$spy = new class(fn ($a, $b) => $a . $b, 'a') extends PartiallyAppliedFunction {
public int $calls = 0;
public function __invoke(...$args)
{
$this->calls++;
return parent::__invoke(...$args);
}
protected function prepareArguments($args)
{
$this->calls++;
return parent::prepareArguments($args);
}
};
$compiled = $spy->compile();
$this->assertSame('ab', $compiled('b'));
$this->assertSame(0, $spy->calls);
}
public function testCompileMatchesDecoratorForCurried()
{
$curried = f\curry(fn ($a, $b, $c) => "$a$b$c");
$applied = $curried('a');
$compiled = $applied->compile();
$this->assertSame('abc', $compiled('b', 'c'));
$this->assertSame($applied('b', 'c'), $compiled('b', 'c'));
}
public function testCompiledCurriedDoesNotCurryFurther()
{
$applied = f\curry(fn ($a, $b = 'B', $c = 'C') => "$a$b$c", 3, 'a');
$this->assertSame($applied->call('b'), $applied->compile()('b'));
}
public function testCompileNestedPartial()
{
$f3 = fn ($a, $b, $c) => [$a, $b, $c];
$this->assertSame($f3(1, 2, 3), f\compile(f\partial(f\partial($f3, 1), _, 3))(2));
}
public function testCompileAppendsExtraArguments()
{
$partial = f\partial(fn (...$args) => $args, 1, _, 3);
$this->assertSame($partial(2, 4, 5), f\compile($partial)(2, 4, 5));
$this->assertSame([1, 2, 3, 4, 5], f\compile($partial)(2, 4, 5));
}
public function testCompileKeepsUnfilledPlaceholders()
{
$partial = f\partial(fn (...$args) => $args, _, 2, _);
$this->assertSame($partial(1), f\compile($partial)(1));
$this->assertSame($partial(), f\compile($partial)());
}
public function testCompileFunctionDelegatesToMethod()
{
$partial = f\partial(str_replace(...), 'a', _, 'banana');
$this->assertSame($partial->compile()('o'), f\compile($partial)('o'));
}
public function testCompilePassesThroughClosures()
{
$closure = fn ($x) => $x;
$this->assertSame($closure, f\compile($closure));
}
public function testCompileConvertsCallableToClosure()
{
$compiled = f\compile('strtoupper');
$this->assertInstanceOf(\Closure::class, $compiled);
$this->assertSame('ABC', $compiled('abc'));
}
public function testDecoratorsAreCompilable()
{
$this->assertInstanceOf(Compilable::class, f\partial('strtoupper'));
$this->assertInstanceOf(Compilable::class, f\curry('strtoupper'));
$this->assertInstanceOf(Compilable::class, f\fix(fn ($f) => $f));
}
public function testCompiledFixedFunctionReferencesItself()
{
$compiled = f\fix(function ($f) use (&$compiled) {
$this->assertSame($f, $compiled);
return fn ($x) => $x;
})->compile();
$this->assertSame(1, $compiled(1));
}
public function testCompiledFixedFunctionRecurses()
{
$compiled = f\compile(f\fix(function ($f) {
return function ($x) use ($f) { return $x >= 2 ? $f($x - 1) * $x : 1; };
}));
$this->assertInstanceOf(\Closure::class, $compiled);
$this->assertSame(6, $compiled(3));
$this->assertSame(24, $compiled(4));
}
public function testCompiledFixedCurriedFunction()
{
$compiled = f\compile(f\fix(f\curry(function ($f, $x) { return $x >= 2 ? $f($x - 1) * $x : 1; })));
$this->assertSame(6, $compiled(3));
$this->assertSame(24, $compiled(4));
}
}