adapt callbacks by target arity

This commit is contained in:
Kacper Donat 2026-10-27 23:04:09 +01:00
parent 0c91360368
commit d4f1130077
13 changed files with 216 additions and 75 deletions

View File

@ -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",

View File

@ -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;
}
}

View File

@ -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;
/** /**

View File

@ -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;

View File

@ -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;
/** /**

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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 ]));

View File

@ -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()

View File

@ -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]));
}
} }