add compilable decorators
This commit is contained in:
parent
8e824ef93c
commit
1e94adc872
@ -23,7 +23,8 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"./src/functions.php",
|
"./src/functions.php",
|
||||||
"./src/Predicates/index.php"
|
"./src/Predicates/index.php",
|
||||||
|
"./src/Collections/Internal/functions.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"suggest": {
|
"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 function Kadet\Functional\apply;
|
||||||
|
use Kadet\Functional\Compilable;
|
||||||
|
use function Kadet\Functional\compile;
|
||||||
use Kadet\Functional\Decorator;
|
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
|
* @var callable
|
||||||
@ -39,4 +43,51 @@ class FixedFunction implements Decorator
|
|||||||
{
|
{
|
||||||
return $this->fixed;
|
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;
|
namespace Kadet\Functional\Utils;
|
||||||
|
|
||||||
use const Kadet\Functional\_ as PLACEHOLDER;
|
use const Kadet\Functional\_ as PLACEHOLDER;
|
||||||
|
use Kadet\Functional\Compilable;
|
||||||
use Kadet\Functional\Decorator;
|
use Kadet\Functional\Decorator;
|
||||||
|
use function Kadet\Functional\compile;
|
||||||
use function Kadet\Functional\not;
|
use function Kadet\Functional\not;
|
||||||
use function Kadet\Functional\Predicates\same;
|
use function Kadet\Functional\Predicates\same;
|
||||||
|
|
||||||
class PartiallyAppliedFunction implements Decorator
|
class PartiallyAppliedFunction implements Decorator, Compilable
|
||||||
{
|
{
|
||||||
protected $callable;
|
protected $callable;
|
||||||
protected $arguments = [];
|
protected $arguments = [];
|
||||||
@ -64,6 +66,28 @@ class PartiallyAppliedFunction implements Decorator
|
|||||||
return $this->callable;
|
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)
|
public static function fix(callable $callable, ...$args)
|
||||||
{
|
{
|
||||||
$applied = new self($callable, ...$args);
|
$applied = new self($callable, ...$args);
|
||||||
|
|||||||
@ -71,4 +71,13 @@ function fix(callable $callable, ...$args)
|
|||||||
function memoize(callable $callable)
|
function memoize(callable $callable)
|
||||||
{
|
{
|
||||||
return new MemoizedFunction($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