adapt callbacks by target arity
This commit is contained in:
parent
0c91360368
commit
d4f1130077
@ -25,7 +25,6 @@
|
|||||||
"./src/functions.php",
|
"./src/functions.php",
|
||||||
"./src/Internal/functions.php",
|
"./src/Internal/functions.php",
|
||||||
"./src/Predicates/index.php",
|
"./src/Predicates/index.php",
|
||||||
"./src/Collections/Internal/functions.php",
|
|
||||||
"./src/Collections/Internal/containers.php",
|
"./src/Collections/Internal/containers.php",
|
||||||
"./src/Collections/Internal/queries.php",
|
"./src/Collections/Internal/queries.php",
|
||||||
"./src/Collections/Lazy/transforms.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;
|
namespace Kadet\Functional\Collections\Internal;
|
||||||
|
|
||||||
|
use function Kadet\Functional\Internal\callback;
|
||||||
|
|
||||||
use Kadet\Functional\Exception\EmptyCollectionException;
|
use Kadet\Functional\Exception\EmptyCollectionException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
namespace Kadet\Functional\Collections\Lazy;
|
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\entries;
|
||||||
use function Kadet\Functional\Collections\Internal\seen;
|
use function Kadet\Functional\Collections\Internal\seen;
|
||||||
use function Kadet\Functional\Collections\Internal\unpairs;
|
use function Kadet\Functional\Collections\Internal\unpairs;
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
namespace Kadet\Functional\Collections;
|
namespace Kadet\Functional\Collections;
|
||||||
|
|
||||||
use Kadet\Functional\Exception\DsUnavailableException;
|
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;
|
use function Kadet\Functional\Collections\Internal\dsAvailable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -21,7 +21,7 @@ namespace Kadet\Functional\Collections;
|
|||||||
|
|
||||||
use Kadet\Functional\Exception\EmptyCollectionException;
|
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\extremum;
|
||||||
use function Kadet\Functional\Collections\Internal\firstOf;
|
use function Kadet\Functional\Collections\Internal\firstOf;
|
||||||
use function Kadet\Functional\Collections\Internal\reversedEntries;
|
use function Kadet\Functional\Collections\Internal\reversedEntries;
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
namespace Kadet\Functional\Collections;
|
namespace Kadet\Functional\Collections;
|
||||||
|
|
||||||
use Kadet\Functional\Collections\Lazy as lazy;
|
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\entries;
|
||||||
use function Kadet\Functional\Collections\Internal\keyedLike;
|
use function Kadet\Functional\Collections\Internal\keyedLike;
|
||||||
use function Kadet\Functional\Collections\Internal\listLike;
|
use function Kadet\Functional\Collections\Internal\listLike;
|
||||||
|
|||||||
@ -9,7 +9,12 @@
|
|||||||
|
|
||||||
namespace Kadet\Functional\Internal;
|
namespace Kadet\Functional\Internal;
|
||||||
|
|
||||||
|
use Kadet\Functional\Decorator;
|
||||||
|
use Kadet\Functional\Utils\FunctionPipeline;
|
||||||
|
|
||||||
use function Kadet\Functional\apply;
|
use function Kadet\Functional\apply;
|
||||||
|
use function Kadet\Functional\closure;
|
||||||
|
use function Kadet\Functional\compile;
|
||||||
use function Kadet\Functional\partial;
|
use function Kadet\Functional\partial;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,3 +42,72 @@ function applyWithArity(callable $callable, callable $call, bool $variadic, int
|
|||||||
return partial($callable, ...$args);
|
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\Compilable;
|
||||||
use Kadet\Functional\Predicate;
|
use Kadet\Functional\Predicate;
|
||||||
|
|
||||||
use function Kadet\Functional\compile;
|
use function Kadet\Functional\Internal\callback;
|
||||||
|
|
||||||
class AllOfPredicate extends AbstractPredicate implements Compilable
|
class AllOfPredicate extends AbstractPredicate implements Compilable
|
||||||
{
|
{
|
||||||
@ -45,9 +45,13 @@ class AllOfPredicate extends AbstractPredicate implements Compilable
|
|||||||
return true;
|
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
|
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 {
|
return static function (mixed ...$args) use ($predicates): bool {
|
||||||
foreach ($predicates as $predicate) {
|
foreach ($predicates as $predicate) {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ namespace Kadet\Functional\Predicate;
|
|||||||
use Kadet\Functional\Compilable;
|
use Kadet\Functional\Compilable;
|
||||||
use Kadet\Functional\Predicate;
|
use Kadet\Functional\Predicate;
|
||||||
|
|
||||||
use function Kadet\Functional\compile;
|
use function Kadet\Functional\Internal\callback;
|
||||||
|
|
||||||
class AnyOfPredicate extends AbstractPredicate implements Compilable
|
class AnyOfPredicate extends AbstractPredicate implements Compilable
|
||||||
{
|
{
|
||||||
@ -46,9 +46,13 @@ class AnyOfPredicate extends AbstractPredicate implements Compilable
|
|||||||
return false;
|
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
|
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 {
|
return static function (mixed ...$args) use ($predicates): bool {
|
||||||
foreach ($predicates as $predicate) {
|
foreach ($predicates as $predicate) {
|
||||||
|
|||||||
@ -30,6 +30,16 @@ class FunctionPipeline implements Compilable
|
|||||||
$this->functions = $functions;
|
$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)
|
public function append(callable $callable)
|
||||||
{
|
{
|
||||||
return new static(...array_merge($this->functions, [ $callable ]));
|
return new static(...array_merge($this->functions, [ $callable ]));
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use Kadet\Functional as f;
|
use Kadet\Functional as f;
|
||||||
use function Kadet\Functional\Collections\Internal\callback;
|
use function Kadet\Functional\Internal\callback;
|
||||||
use const Kadet\Functional\_;
|
use const Kadet\Functional\_;
|
||||||
|
|
||||||
class CallbackTest extends \PHPUnit\Framework\TestCase
|
class CallbackTest extends \PHPUnit\Framework\TestCase
|
||||||
@ -23,9 +23,37 @@ class CallbackTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame('A', callback('strtoupper')('a', 'key'));
|
$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()
|
public function testTwoParamClosureGetsKey()
|
||||||
@ -10,6 +10,7 @@
|
|||||||
use Kadet\Functional as f;
|
use Kadet\Functional as f;
|
||||||
use Kadet\Functional\Collections as c;
|
use Kadet\Functional\Collections as c;
|
||||||
use Kadet\Functional\Predicates as p;
|
use Kadet\Functional\Predicates as p;
|
||||||
|
use const Kadet\Functional\_;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The library's own callables (predicates, pipes, decorators) used as collection callbacks.
|
* 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]));
|
$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