From f3f05fd00c586cda9578f9ddba3710a870399ec2 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 23:08:28 +0100 Subject: [PATCH] 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(). --- phpstan.neon | 9 +- src/Collections/Internal/containers.php | 34 +++- src/Collections/transforms.php | 225 ++++++++++++++++++++---- tests/Collections/TransformsTest.php | 11 ++ 4 files changed, 235 insertions(+), 44 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 6b3afe7..8fc2362 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -14,10 +14,13 @@ parameters: message: '#^Function Kadet\\Functional\\Collections\\\w+\(\) should return Closure\(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 diff --git a/src/Collections/Internal/containers.php b/src/Collections/Internal/containers.php index 8c94210..bf1d746 100644 --- a/src/Collections/Internal/containers.php +++ b/src/Collections/Internal/containers.php @@ -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 $input + * + * @return iterable + */ +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 $iterable + * + * @return list + */ +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. * diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php index 8f099f2..d374036 100644 --- a/src/Collections/transforms.php +++ b/src/Collections/transforms.php @@ -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); }; } diff --git a/tests/Collections/TransformsTest.php b/tests/Collections/TransformsTest.php index 6b954c2..2b77568 100644 --- a/tests/Collections/TransformsTest.php +++ b/tests/Collections/TransformsTest.php @@ -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()