add native fast paths for eager transforms

Arrays and php-ds collections are transformed directly instead of through the lazy generators. merge() into an array now rejects keys that can't be array keys with a clear error, like toArray().
This commit is contained in:
Kacper Donat 2026-10-27 23:08:28 +01:00
parent 4fe9da0167
commit f3f05fd00c
4 changed files with 235 additions and 44 deletions

View File

@ -14,10 +14,13 @@ parameters:
message: '#^Function Kadet\\Functional\\Collections\\\w+\(\) should return Closure<TIn of iterable>\(TIn\): \(TIn is array \?#'
path: src/Collections/transforms.php
-
# toArray() keeps PHP's own coercion of bool, float and null keys, as an array literal would.
# toArray() and merge() into an array keep PHP's own coercion of bool, float and null keys, as an array
# literal would.
identifier: offsetAccess.invalidOffset
message: '#^Possibly invalid array key type bool\|float\|int\|string\|null\.$#'
path: src/Collections/collectors.php
message: '#^Possibly invalid array key type \(?bool#'
paths:
- src/Collections/collectors.php
- src/Collections/transforms.php
includes:
# Errors in the code that predates PHPStan in this project (untyped pre-1.0 functions and classes). New code

View File

@ -70,10 +70,7 @@ function keyedLike(iterable $input, iterable $entries): iterable
*/
function listLike(iterable $input, iterable $values): iterable
{
$list = [];
foreach ($values as $value) {
$list[] = $value;
}
$list = is_array($values) ? array_values($values) : iterator_to_array($values, false);
if (is_array($input)) {
return $list;
@ -86,6 +83,35 @@ function listLike(iterable $input, iterable $values): iterable
return seqOf($list);
}
/**
* What an eager transform iterates: the values of `Ds\Seq` and `Ds\Set` as an array (keyed by position, as
* iterating them gives), as iterating an array is faster; anything else as it is.
*
* @internal
*
* @param iterable<mixed, mixed> $input
*
* @return iterable<mixed, mixed>
*/
function iterated(iterable $input): iterable
{
return $input instanceof \Ds\Seq || $input instanceof \Ds\Set ? $input->toArray() : $input;
}
/**
* The values of an iterable as a list.
*
* @internal
*
* @param iterable<mixed, mixed> $iterable
*
* @return list<mixed>
*/
function valuesOf(iterable $iterable): array
{
return is_array($iterable) ? array_values($iterable) : iterator_to_array(iterated($iterable), false);
}
/**
* Wraps a list in a `Ds\Seq`, or returns it as it is when php-ds is unavailable.
*

View File

@ -23,10 +23,13 @@ namespace Kadet\Functional\Collections;
use Kadet\Functional\Collections\Lazy as lazy;
use function Kadet\Functional\Internal\callback;
use function Kadet\Functional\Collections\Internal\entries;
use function Kadet\Functional\Collections\Internal\iterated;
use function Kadet\Functional\Collections\Internal\keyedLike;
use function Kadet\Functional\Collections\Internal\seen;
use function Kadet\Functional\Collections\Internal\listLike;
use function Kadet\Functional\Collections\Internal\seqOf;
use function Kadet\Functional\Collections\Internal\unpairs;
use function Kadet\Functional\Collections\Internal\valuesOf;
use function Kadet\Functional\compile;
/**
@ -50,9 +53,29 @@ use function Kadet\Functional\compile;
*/
function map(callable $fn): \Closure
{
$lazy = lazy\map($fn);
$fn = callback($fn);
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return static function (iterable $input) use ($fn): iterable {
if (is_array($input)) {
$result = [];
foreach ($input as $key => $value) {
$result[$key] = $fn($value, $key);
}
return $result;
}
if ($input instanceof \Ds\Map) {
return $input->map(static fn ($key, $value) => $fn($value, $key));
}
$result = [];
foreach (iterated($input) as $key => $value) {
$result[] = $fn($value, $key);
}
return listLike($input, $result);
};
}
/**
@ -75,9 +98,29 @@ function map(callable $fn): \Closure
*/
function filter(?callable $fn = null): \Closure
{
$lazy = lazy\filter($fn);
$fn = $fn === null ? static fn ($value) => $value : callback($fn);
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return static function (iterable $input) use ($fn): iterable {
if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => $fn($value, $key));
}
$values = iterated($input);
if (is_array($values)) {
$values = array_filter($values, $fn, ARRAY_FILTER_USE_BOTH);
return is_array($input) ? $values : listLike($input, $values);
}
$result = [];
foreach ($values as $key => $value) {
if ($fn($value, $key)) {
$result[] = $value;
}
}
return listLike($input, $result);
};
}
/**
@ -98,9 +141,32 @@ function filter(?callable $fn = null): \Closure
*/
function reject(callable $fn): \Closure
{
$lazy = lazy\reject($fn);
$fn = callback($fn);
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return static function (iterable $input) use ($fn): iterable {
if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => !$fn($value, $key));
}
$result = [];
if (is_array($input)) {
foreach ($input as $key => $value) {
if (!$fn($value, $key)) {
$result[$key] = $value;
}
}
return $result;
}
foreach (iterated($input) as $key => $value) {
if (!$fn($value, $key)) {
$result[] = $value;
}
}
return listLike($input, $result);
};
}
/**
@ -122,9 +188,27 @@ function reject(callable $fn): \Closure
*/
function flatMap(callable $fn): \Closure
{
$lazy = lazy\flatMap($fn);
$fn = callback($fn);
return static fn (iterable $input): iterable => listLike($input, $lazy($input));
return static function (iterable $input) use ($fn): iterable {
$result = [];
foreach (iterated($input) as $key => $value) {
$items = $fn($value, $key);
if (!is_iterable($items)) {
throw new \UnexpectedValueException(sprintf(
'flatMap(): the callback must return an iterable, got %s.',
get_debug_type($items),
));
}
foreach ($items as $item) {
$result[] = $item;
}
}
return listLike($input, $result);
};
}
/**
@ -134,9 +218,7 @@ function flatMap(callable $fn): \Closure
*/
function values(): \Closure
{
$lazy = lazy\values();
return static fn (iterable $input): iterable => listLike($input, $lazy($input));
return static fn (iterable $input): iterable => listLike($input, valuesOf($input));
}
/**
@ -146,12 +228,17 @@ function values(): \Closure
*/
function keys(): \Closure
{
$lazy = lazy\keys();
return static function (iterable $input): iterable {
if (is_array($input)) {
return array_keys($input);
}
return static function (iterable $input) use ($lazy): iterable {
$keys = iterator_to_array($lazy($input), false);
$keys = [];
foreach (iterated($input) as $key => $_) {
$keys[] = $key;
}
return is_array($input) ? $keys : seqOf($keys);
return seqOf($keys);
};
}
@ -184,9 +271,34 @@ function unique(): \Closure
*/
function uniqueBy(callable $selector): \Closure
{
$lazy = lazy\uniqueBy($selector);
$selector = callback($selector);
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return static function (iterable $input) use ($selector): iterable {
$seen = seen();
if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => !$seen($selector($value, $key)));
}
$result = [];
if (is_array($input)) {
foreach ($input as $key => $value) {
if (!$seen($selector($value, $key))) {
$result[$key] = $value;
}
}
return $result;
}
foreach (iterated($input) as $key => $value) {
if (!$seen($selector($value, $key))) {
$result[] = $value;
}
}
return listLike($input, $result);
};
}
/**
@ -261,7 +373,17 @@ function slice(int $offset, ?int $length = null): \Closure
{
$lazy = lazy\slice($offset, $length);
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return static function (iterable $input) use ($offset, $length, $lazy): iterable {
if (is_array($input)) {
return array_slice($input, $offset, $length, true);
}
if ($input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set) {
return $length === null ? $input->slice($offset) : $input->slice($offset, $length);
}
return keyedLike($input, $lazy($input));
};
}
/**
@ -271,9 +393,11 @@ function slice(int $offset, ?int $length = null): \Closure
*/
function take(int $n): \Closure
{
$lazy = lazy\take($n);
if ($n < 0) {
throw new \InvalidArgumentException(sprintf('take(): count must be >= 0, got %d', $n));
}
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return slice(0, $n);
}
/**
@ -283,9 +407,11 @@ function take(int $n): \Closure
*/
function skip(int $n): \Closure
{
$lazy = lazy\skip($n);
if ($n < 0) {
throw new \InvalidArgumentException(sprintf('skip(): count must be >= 0, got %d', $n));
}
return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return slice($n);
}
/**
@ -299,9 +425,9 @@ function skip(int $n): \Closure
*/
function prepend(mixed ...$values): \Closure
{
$lazy = lazy\prepend(...$values);
$values = array_values($values);
return static fn (iterable $input): iterable => listLike($input, $lazy($input));
return static fn (iterable $input): iterable => listLike($input, array_merge($values, valuesOf($input)));
}
/**
@ -329,9 +455,7 @@ function append(mixed ...$values): \Closure
*/
function concat(iterable $other): \Closure
{
$lazy = lazy\concat($other);
return static fn (iterable $input): iterable => listLike($input, $lazy($input));
return static fn (iterable $input): iterable => listLike($input, array_merge(valuesOf($input), valuesOf($other)));
}
/**
@ -348,10 +472,31 @@ function concat(iterable $other): \Closure
*/
function merge(iterable $other): \Closure
{
return static fn (iterable $input): iterable => keyedLike($input, (static function () use ($input, $other) {
yield from $input;
yield from $other;
})());
return static function (iterable $input) use ($other): iterable {
if (is_array($input)) {
foreach ($other as $key => $value) {
if (!is_scalar($key) && $key !== null) {
throw new \InvalidArgumentException(sprintf(
'merge(): key of type %s cannot be an array key',
get_debug_type($key),
));
}
$input[$key] = $value;
}
return $input;
}
if ($input instanceof \Ds\Map) {
$result = $input->copy();
$result->putAll($other);
return $result;
}
return listLike($input, array_merge(valuesOf($input), valuesOf($other)));
};
}
/**
@ -375,18 +520,24 @@ function merge(iterable $other): \Closure
*/
function each(callable $fn): \Closure
{
$lazy = lazy\each($fn);
$fn = callback($fn);
return static function (iterable $input) use ($lazy): iterable {
$reusable = is_array($input) || $input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set;
if ($reusable) {
foreach ($lazy($input) as $_) {
return static function (iterable $input) use ($fn): iterable {
if (is_array($input) || $input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set) {
foreach (iterated($input) as $key => $value) {
$fn($value, $key);
}
return $input;
}
return listLike($input, $lazy($input));
$values = [];
foreach ($input as $key => $value) {
$fn($value, $key);
$values[] = $value;
}
return listLike($input, $values);
};
}

View File

@ -364,6 +364,17 @@ class TransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSeq([1, 2, 3], c\merge([3])(self::gen([1, 2])));
}
public function testMergeObjectKeyIntoArrayThrows()
{
$map = new Map();
$map->put(new \stdClass(), 1);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('merge(): key of type stdClass cannot be an array key');
c\merge($map)(['a' => 1]);
}
// each / tap
public function testEachDoesNotReplaceValues()