add compilable decorators
This commit is contained in:
parent
8e824ef93c
commit
1e94adc872
@ -23,7 +23,8 @@
|
||||
},
|
||||
"files": [
|
||||
"./src/functions.php",
|
||||
"./src/Predicates/index.php"
|
||||
"./src/Predicates/index.php",
|
||||
"./src/Collections/Internal/functions.php"
|
||||
]
|
||||
},
|
||||
"suggest": {
|
||||
|
||||
33
src/Collections/Internal/functions.php
Normal file
33
src/Collections/Internal/functions.php
Normal 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
21
src/Compilable.php
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -72,3 +72,12 @@ 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);
|
||||
}
|
||||
47
tests/Collections/CallbackTest.php
Normal file
47
tests/Collections/CallbackTest.php
Normal 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
149
tests/CompileTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user