From 26f1a2062773d81a8974c47918ceea053cd794b5 Mon Sep 17 00:00:00 2001 From: An Phan Date: Sat, 21 Mar 2026 14:41:43 +0100 Subject: [PATCH] Add `frequencies`, inspired by Clojure's `frequencies` and Ruby's `Enumerable#tally` Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 1 + docs/functional-php.md | 18 +++++ src/Functional/Frequencies.php | 52 +++++++++++++ src/Functional/Functional.php | 5 ++ tests/Functional/FrequenciesTest.php | 108 +++++++++++++++++++++++++++ 5 files changed, 184 insertions(+) create mode 100644 src/Functional/Frequencies.php create mode 100644 tests/Functional/FrequenciesTest.php diff --git a/composer.json b/composer.json index ebfe9738..905624ab 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "src/Functional/FirstIndexOf.php", "src/Functional/FlatMap.php", "src/Functional/Flatten.php", + "src/Functional/Frequencies.php", "src/Functional/Flip.php", "src/Functional/FromEntries.php", "src/Functional/GreaterThan.php", diff --git a/docs/functional-php.md b/docs/functional-php.md index dfbdb62f..62134233 100644 --- a/docs/functional-php.md +++ b/docs/functional-php.md @@ -730,6 +730,24 @@ $getEven([1, 2, 3, 4]); // [2, 4] _Note, that you cannot use `curry` on a flipped function. `curry` uses reflection to get the number of function arguments, but this is not possible on the function returned from `flip`. Instead use `curry_n` on flipped functions._ +## frequencies() +Returns a new array mapping each distinct value to the number of times it appears in the collection. An optional callback can be provided to determine the key to count by. + +``array Functional\frequencies(array|Traversable $collection[, callable $callback])`` + +```php +use function Functional\frequencies; + +$data = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple']; +$freq = frequencies($data); // ['apple' => 3, 'banana' => 2, 'cherry' => 1] + +// With a callback to count by a derived key +$words = ['hi', 'hello', 'hey', 'goodbye', 'go']; +$byLength = frequencies($words, fn ($word) => strlen($word)); // [2 => 2, 5 => 2, 3 => 1, 7 => 1] +``` + +Inspired by Clojure's `frequencies` and Ruby's `Enumerable#tally`. + ## not Return a new function which takes the same arguments as the original function, but returns the logical negation of its result. diff --git a/src/Functional/Frequencies.php b/src/Functional/Frequencies.php new file mode 100644 index 00000000..9544d151 --- /dev/null +++ b/src/Functional/Frequencies.php @@ -0,0 +1,52 @@ + + * @copyright 2011-2021 Lars Strojny + * @license https://opensource.org/licenses/MIT MIT + * @link https://github.com/lstrojny/functional-php + */ + +namespace Functional; + +use Functional\Exceptions\InvalidArgumentException; +use Traversable; + +/** + * Returns a new array mapping each distinct value to the number of times it appears in the collection. + * An optional callback can be provided to determine the key to count by. + * + * @param Traversable|array $collection + * @param callable|null $callback + * @return array + * @no-named-arguments + */ +function frequencies($collection, ?callable $callback = null): array +{ + InvalidArgumentException::assertCollection($collection, __FUNCTION__, 1); + + $frequencies = []; + + foreach ($collection as $index => $element) { + if ($callback) { + $key = $callback($element, $index, $collection); + } else { + $key = $element; + } + + InvalidArgumentException::assertValidArrayKey($key, __FUNCTION__); + + if (\is_numeric($key)) { + $key = (int) $key; + } + + if (!isset($frequencies[$key])) { + $frequencies[$key] = 0; + } + + $frequencies[$key]++; + } + + return $frequencies; +} diff --git a/src/Functional/Functional.php b/src/Functional/Functional.php index 59936572..58784708 100644 --- a/src/Functional/Functional.php +++ b/src/Functional/Functional.php @@ -157,6 +157,11 @@ final class Functional */ const flip = '\Functional\flip'; + /** + * @see \Functional\frequencies + */ + const frequencies = '\Functional\frequencies'; + /** * @see \Functional\from_entries */ diff --git a/tests/Functional/FrequenciesTest.php b/tests/Functional/FrequenciesTest.php new file mode 100644 index 00000000..8759830d --- /dev/null +++ b/tests/Functional/FrequenciesTest.php @@ -0,0 +1,108 @@ + + * @copyright 2011-2021 Lars Strojny + * @license https://opensource.org/licenses/MIT MIT + * @link https://github.com/lstrojny/functional-php + */ + +namespace Functional\Tests; + +use ArrayIterator; +use Functional\Exceptions\InvalidArgumentException; + +use function Functional\frequencies; + +class FrequenciesTest extends AbstractTestCase +{ + protected function setUp(): void + { + parent::setUp(); + $this->list = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple']; + $this->listIterator = new ArrayIterator($this->list); + $this->hash = ['a' => 'one', 'b' => 'two', 'c' => 'one', 'd' => 'two', 'e' => 'one']; + $this->hashIterator = new ArrayIterator($this->hash); + } + + public function test(): void + { + $expected = ['apple' => 3, 'banana' => 2, 'cherry' => 1]; + self::assertSame($expected, frequencies($this->list)); + self::assertSame($expected, frequencies($this->listIterator)); + + $expectedHash = ['one' => 3, 'two' => 2]; + self::assertSame($expectedHash, frequencies($this->hash)); + self::assertSame($expectedHash, frequencies($this->hashIterator)); + } + + public function testWithCallback(): void + { + $fn = function ($v, $k, $collection) { + InvalidArgumentException::assertCollection($collection, __FUNCTION__, 3); + return \strlen($v); + }; + + $expected = [5 => 3, 6 => 3]; + self::assertSame($expected, frequencies($this->list, $fn)); + self::assertSame($expected, frequencies($this->listIterator, $fn)); + + $expectedHash = [3 => 5]; + self::assertSame($expectedHash, frequencies($this->hash, $fn)); + self::assertSame($expectedHash, frequencies($this->hashIterator, $fn)); + } + + public function testEmptyCollection(): void + { + self::assertSame([], frequencies([])); + self::assertSame([], frequencies(new ArrayIterator([]))); + } + + public function testNumericValues(): void + { + $list = [1, 2, 1, 3, 2, 1]; + $expected = [1 => 3, 2 => 2, 3 => 1]; + self::assertSame($expected, frequencies($list)); + self::assertSame($expected, frequencies(new ArrayIterator($list))); + } + + public function testExceptionIsThrownWhenCallbackReturnsInvalidKey(): void + { + $invalidTypes = [ + 'resource' => \stream_context_create(), + 'object' => new \stdClass(), + 'array' => [] + ]; + + foreach ($invalidTypes as $type => $value) { + $fn = function () use ($value) { + return $value; + }; + try { + frequencies(['v1'], $fn); + self::fail(\sprintf('Error expected for array key type "%s"', $type)); + } catch (\Exception $e) { + self::assertSame( + \sprintf( + 'Functional\frequencies(): callback returned invalid array key of type "%s". Expected NULL, string, integer, double or boolean', + $type + ), + $e->getMessage() + ); + } + } + } + + public function testPassNoCollection(): void + { + $this->expectArgumentError('Functional\frequencies() expects parameter 1 to be array or instance of Traversable'); + frequencies('invalidCollection'); + } + + public function testPassNonCallable(): void + { + $this->expectCallableArgumentError('Functional\frequencies', 2); + frequencies($this->list, 'undefinedFunction'); + } +}