adapt callbacks by target arity
This commit is contained in:
parent
0c91360368
commit
d4f1130077
@ -25,7 +25,6 @@
|
||||
"./src/functions.php",
|
||||
"./src/Internal/functions.php",
|
||||
"./src/Predicates/index.php",
|
||||
"./src/Collections/Internal/functions.php",
|
||||
"./src/Collections/Internal/containers.php",
|
||||
"./src/Collections/Internal/queries.php",
|
||||
"./src/Collections/Lazy/transforms.php",
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
<?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 Kadet\Functional\Decorator;
|
||||
use function Kadet\Functional\closure;
|
||||
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(...)`, also when wrapped in decorators) 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);
|
||||
$target = new \ReflectionFunction(closure(target($callable)));
|
||||
|
||||
if ($target->isInternal() || (!$reflection->isVariadic() && $reflection->getNumberOfParameters() === 1)) {
|
||||
return static fn ($value) => $compiled($value);
|
||||
}
|
||||
|
||||
return $compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the function a callable ultimately calls, by unwrapping decorators, also when they were turned into closures.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function target(callable $callable): callable
|
||||
{
|
||||
while (true) {
|
||||
if ($callable instanceof Decorator) {
|
||||
$callable = $callable->getDecorated();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($callable instanceof \Closure) {
|
||||
$reflection = new \ReflectionFunction($callable);
|
||||
$bound = $reflection->getClosureThis();
|
||||
|
||||
if ($bound instanceof Decorator && $reflection->getName() === '__invoke') {
|
||||
$callable = $bound;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $callable;
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,8 @@
|
||||
|
||||
namespace Kadet\Functional\Collections\Internal;
|
||||
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
|
||||
use Kadet\Functional\Exception\EmptyCollectionException;
|
||||
|
||||
/**
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
namespace Kadet\Functional\Collections\Lazy;
|
||||
|
||||
use function Kadet\Functional\Collections\Internal\callback;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
use function Kadet\Functional\Collections\Internal\entries;
|
||||
use function Kadet\Functional\Collections\Internal\seen;
|
||||
use function Kadet\Functional\Collections\Internal\unpairs;
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
namespace Kadet\Functional\Collections;
|
||||
|
||||
use Kadet\Functional\Exception\DsUnavailableException;
|
||||
use function Kadet\Functional\Collections\Internal\callback;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
use function Kadet\Functional\Collections\Internal\dsAvailable;
|
||||
|
||||
/**
|
||||
|
||||
@ -21,7 +21,7 @@ namespace Kadet\Functional\Collections;
|
||||
|
||||
use Kadet\Functional\Exception\EmptyCollectionException;
|
||||
|
||||
use function Kadet\Functional\Collections\Internal\callback;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
use function Kadet\Functional\Collections\Internal\extremum;
|
||||
use function Kadet\Functional\Collections\Internal\firstOf;
|
||||
use function Kadet\Functional\Collections\Internal\reversedEntries;
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
namespace Kadet\Functional\Collections;
|
||||
|
||||
use Kadet\Functional\Collections\Lazy as lazy;
|
||||
use function Kadet\Functional\Collections\Internal\callback;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
use function Kadet\Functional\Collections\Internal\entries;
|
||||
use function Kadet\Functional\Collections\Internal\keyedLike;
|
||||
use function Kadet\Functional\Collections\Internal\listLike;
|
||||
|
||||
@ -9,7 +9,12 @@
|
||||
|
||||
namespace Kadet\Functional\Internal;
|
||||
|
||||
use Kadet\Functional\Decorator;
|
||||
use Kadet\Functional\Utils\FunctionPipeline;
|
||||
|
||||
use function Kadet\Functional\apply;
|
||||
use function Kadet\Functional\closure;
|
||||
use function Kadet\Functional\compile;
|
||||
use function Kadet\Functional\partial;
|
||||
|
||||
/**
|
||||
@ -37,3 +42,72 @@ function applyWithArity(callable $callable, callable $call, bool $variadic, int
|
||||
return partial($callable, ...$args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a callback that is called with `$arity` leading arguments (the value, or the carry and the value),
|
||||
* followed by the key.
|
||||
*
|
||||
* Whether the key is passed is decided by the callback's target, the function it ultimately calls (see target()):
|
||||
* an internal function, or a non-variadic one declaring exactly `$arity` parameters, gets only the leading
|
||||
* arguments. This keeps the key out of optional parameters (e.g. trim's character list) and out of decorators that
|
||||
* see all their arguments (e.g. memoize's cache key).
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param 1|2 $arity
|
||||
*/
|
||||
function callback(callable $callable, int $arity = 1): \Closure
|
||||
{
|
||||
$compiled = compile($callable);
|
||||
$target = new \ReflectionFunction(closure(target($callable)));
|
||||
|
||||
$leadingOnly = $target->isInternal()
|
||||
|| (!$target->isVariadic() && $target->getNumberOfParameters() === $arity);
|
||||
|
||||
if (!$leadingOnly) {
|
||||
return $compiled;
|
||||
}
|
||||
|
||||
// A userland closure taking no more than the leading arguments already ignores the rest.
|
||||
$reflection = new \ReflectionFunction($compiled);
|
||||
if (!$reflection->isInternal() && !$reflection->isVariadic() && $reflection->getNumberOfParameters() <= $arity) {
|
||||
return $compiled;
|
||||
}
|
||||
|
||||
return $arity === 1
|
||||
? static fn ($value) => $compiled($value)
|
||||
: static fn ($carry, $value) => $compiled($carry, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the function a callable ultimately calls: decorators are unwrapped, also when they were turned into
|
||||
* closures, and a pipeline gives its first function, which gets all of its arguments.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function target(callable $callable): callable
|
||||
{
|
||||
while (true) {
|
||||
if ($callable instanceof Decorator) {
|
||||
$callable = $callable->getDecorated();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($callable instanceof FunctionPipeline && $callable->first() !== null) {
|
||||
$callable = $callable->first();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($callable instanceof \Closure) {
|
||||
$reflection = new \ReflectionFunction($callable);
|
||||
$bound = $reflection->getClosureThis();
|
||||
|
||||
if (($bound instanceof Decorator || $bound instanceof FunctionPipeline) && $reflection->getName() === '__invoke') {
|
||||
$callable = $bound;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $callable;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ namespace Kadet\Functional\Predicate;
|
||||
use Kadet\Functional\Compilable;
|
||||
use Kadet\Functional\Predicate;
|
||||
|
||||
use function Kadet\Functional\compile;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
|
||||
class AllOfPredicate extends AbstractPredicate implements Compilable
|
||||
{
|
||||
@ -45,9 +45,13 @@ class AllOfPredicate extends AbstractPredicate implements Compilable
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each predicate gets only the arguments it takes: the first one alone when it is internal or declares one
|
||||
* parameter, all of them otherwise. So a key passed by a collection function doesn't reach `is_numeric(...)`.
|
||||
*/
|
||||
public function compile(): \Closure
|
||||
{
|
||||
$predicates = array_map(compile(...), $this->predicates);
|
||||
$predicates = array_map(static fn (Predicate $predicate) => callback($predicate), $this->predicates);
|
||||
|
||||
return static function (mixed ...$args) use ($predicates): bool {
|
||||
foreach ($predicates as $predicate) {
|
||||
|
||||
@ -13,7 +13,7 @@ namespace Kadet\Functional\Predicate;
|
||||
use Kadet\Functional\Compilable;
|
||||
use Kadet\Functional\Predicate;
|
||||
|
||||
use function Kadet\Functional\compile;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
|
||||
class AnyOfPredicate extends AbstractPredicate implements Compilable
|
||||
{
|
||||
@ -46,9 +46,13 @@ class AnyOfPredicate extends AbstractPredicate implements Compilable
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each predicate gets only the arguments it takes: the first one alone when it is internal or declares one
|
||||
* parameter, all of them otherwise. So a key passed by a collection function doesn't reach `is_numeric(...)`.
|
||||
*/
|
||||
public function compile(): \Closure
|
||||
{
|
||||
$predicates = array_map(compile(...), $this->predicates);
|
||||
$predicates = array_map(static fn (Predicate $predicate) => callback($predicate), $this->predicates);
|
||||
|
||||
return static function (mixed ...$args) use ($predicates): bool {
|
||||
foreach ($predicates as $predicate) {
|
||||
|
||||
@ -30,6 +30,16 @@ class FunctionPipeline implements Compilable
|
||||
$this->functions = $functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The function called first, with all arguments of the pipeline, or null when the pipeline is empty.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function first(): ?callable
|
||||
{
|
||||
return $this->functions[0] ?? null;
|
||||
}
|
||||
|
||||
public function append(callable $callable)
|
||||
{
|
||||
return new static(...array_merge($this->functions, [ $callable ]));
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
use Kadet\Functional as f;
|
||||
use function Kadet\Functional\Collections\Internal\callback;
|
||||
use function Kadet\Functional\Internal\callback;
|
||||
use const Kadet\Functional\_;
|
||||
|
||||
class CallbackTest extends \PHPUnit\Framework\TestCase
|
||||
@ -23,9 +23,37 @@ class CallbackTest extends \PHPUnit\Framework\TestCase
|
||||
$this->assertSame('A', callback('strtoupper')('a', 'key'));
|
||||
}
|
||||
|
||||
public function testSingleParamClosureGetsValueOnly()
|
||||
public function testSingleParamClosureIsNotWrapped()
|
||||
{
|
||||
$this->assertSame([1], callback(function ($v) { return func_get_args(); })(1, 'k'));
|
||||
// PHP drops the key on its own: a userland closure ignores arguments past its parameters.
|
||||
$closure = fn ($v) => $v;
|
||||
|
||||
$this->assertSame($closure, callback($closure));
|
||||
}
|
||||
|
||||
public function testDecoratedSingleParamClosureGetsValueOnly()
|
||||
{
|
||||
$this->assertSame([1], callback(f\memoize(function ($v) { return func_get_args(); }))(1, 'k'));
|
||||
}
|
||||
|
||||
public function testPipelineStartingWithInternalFunctionGetsValueOnly()
|
||||
{
|
||||
$this->assertSame('a', callback(f\pipe(trim(...)))(' a ', 'xyz'));
|
||||
}
|
||||
|
||||
public function testPipelineStartingWithTwoParamClosureGetsKey()
|
||||
{
|
||||
$this->assertSame('k=v', callback(f\pipe(fn ($v, $k) => "$k=$v"))('v', 'k'));
|
||||
}
|
||||
|
||||
public function testCarryAndValueOnlyForTwoParamTarget()
|
||||
{
|
||||
$this->assertSame([1, 2], callback(f\memoize(function ($c, $v) { return func_get_args(); }), 2)(1, 2, 'k'));
|
||||
}
|
||||
|
||||
public function testCarryValueAndKeyForThreeParamTarget()
|
||||
{
|
||||
$this->assertSame('1+2@k', callback(fn ($c, $v, $k) => "$c+$v@$k", 2)(1, 2, 'k'));
|
||||
}
|
||||
|
||||
public function testTwoParamClosureGetsKey()
|
||||
@ -10,6 +10,7 @@
|
||||
use Kadet\Functional as f;
|
||||
use Kadet\Functional\Collections as c;
|
||||
use Kadet\Functional\Predicates as p;
|
||||
use const Kadet\Functional\_;
|
||||
|
||||
/**
|
||||
* The library's own callables (predicates, pipes, decorators) used as collection callbacks.
|
||||
@ -37,4 +38,86 @@ class LibraryCallablesTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
$this->assertSame([1 => 2], c\reject(p\gt(1)->not())([1, 2]));
|
||||
}
|
||||
|
||||
// pipelines get only the value when their first function takes only the value
|
||||
|
||||
public function testMapWithPipelineOfInternalFunctions()
|
||||
{
|
||||
$slug = f\pipe(trim(...), strtolower(...), f\partial(str_replace(...), ' ', '-', _));
|
||||
|
||||
$this->assertSame(['n' => 'main-n'], c\map($slug)(['n' => 'Main n']));
|
||||
}
|
||||
|
||||
public function testMapWithPipelineOfInternalFunctionWithOptionalParameter()
|
||||
{
|
||||
$this->assertSame([1.0, 3.0, 3.0], c\map(f\pipe(round(...)))([1.25, 2.5, 3.14159]));
|
||||
}
|
||||
|
||||
public function testMapWithPipelineOfMemoizedOneParameterClosure()
|
||||
{
|
||||
$calls = 0;
|
||||
$pipeline = f\pipe(f\memoize(function ($x) use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return $x * 2;
|
||||
}), fn ($x) => $x + 1);
|
||||
|
||||
$this->assertSame(['a' => 3, 'b' => 3], c\map($pipeline)(['a' => 1, 'b' => 1]));
|
||||
$this->assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testMapWithPipelineStartingWithTwoParameterClosureGetsKey()
|
||||
{
|
||||
$this->assertSame(['a' => 'A=X'], c\map(f\pipe(fn ($v, $k) => "$k=$v", strtoupper(...)))(['a' => 'x']));
|
||||
}
|
||||
|
||||
public function testFilterWithPredicateOfPipeline()
|
||||
{
|
||||
$a = (object) ['x' => 1];
|
||||
$b = (object) ['x' => 2];
|
||||
|
||||
$this->assertSame([1 => $b], c\filter(p\property('x', p\gt(1)))([$a, $b]));
|
||||
}
|
||||
|
||||
// predicate combinators forward to each predicate only what it takes
|
||||
|
||||
public function testFilterWithAllOfInternalFunctionAndClosure()
|
||||
{
|
||||
$this->assertSame([0 => '1'], c\filter(f\all(is_numeric(...), fn ($x) => $x > 0))(['1', 'x', '-1']));
|
||||
}
|
||||
|
||||
public function testFilterWithAnyOfInternalFunctionAndClosure()
|
||||
{
|
||||
$this->assertSame([0 => '1', 2 => 'y'], c\filter(f\any(is_numeric(...), fn ($x) => $x === 'y'))(['1', 'x', 'y']));
|
||||
}
|
||||
|
||||
public function testFilterWithAllOfPassesKeyToPredicateTakingIt()
|
||||
{
|
||||
$this->assertSame(['b' => 2], c\filter(f\all(is_int(...), fn ($v, $k) => $k === 'b'))(['a' => 1, 'b' => 2]));
|
||||
}
|
||||
|
||||
public function testFilterWithNegatedAllOfInternalFunctions()
|
||||
{
|
||||
$this->assertSame([1 => 'x'], c\filter(f\not(f\all(is_numeric(...), is_string(...))))(['1', 'x']));
|
||||
}
|
||||
|
||||
// decorators
|
||||
|
||||
public function testMemoizedClosureIsCachedByValueOnly()
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = f\memoize(function ($x) use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return $x * 2;
|
||||
});
|
||||
|
||||
$this->assertSame([2, 2, 2, 2], c\map($memoized)([1, 1, 1, 1]));
|
||||
$this->assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testMemoizedInternalFunctionGetsValueOnly()
|
||||
{
|
||||
$this->assertSame([1.0, 3.0, 3.0], c\map(f\memoize(round(...)))([1.25, 2.5, 3.14159]));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user