Skip to content

Commit c9d87fb

Browse files
phananclaude
andauthored
Add frequencies function (#262)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b6e6726 commit c9d87fb

5 files changed

Lines changed: 184 additions & 0 deletions

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"src/Functional/FirstIndexOf.php",
5858
"src/Functional/FlatMap.php",
5959
"src/Functional/Flatten.php",
60+
"src/Functional/Frequencies.php",
6061
"src/Functional/Flip.php",
6162
"src/Functional/FromEntries.php",
6263
"src/Functional/GreaterThan.php",

docs/functional-php.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,24 @@ $getEven([1, 2, 3, 4]); // [2, 4]
730730

731731
_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._
732732

733+
## frequencies()
734+
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.
735+
736+
``array Functional\frequencies(array|Traversable $collection[, callable $callback])``
737+
738+
```php
739+
use function Functional\frequencies;
740+
741+
$data = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple'];
742+
$freq = frequencies($data); // ['apple' => 3, 'banana' => 2, 'cherry' => 1]
743+
744+
// With a callback to count by a derived key
745+
$words = ['hi', 'hello', 'hey', 'goodbye', 'go'];
746+
$byLength = frequencies($words, fn ($word) => strlen($word)); // [2 => 2, 5 => 2, 3 => 1, 7 => 1]
747+
```
748+
749+
Inspired by Clojure's `frequencies` and Ruby's `Enumerable#tally`.
750+
733751
## not
734752
Return a new function which takes the same arguments as the original function, but returns the logical negation of its result.
735753

src/Functional/Frequencies.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/**
4+
* @package Functional-php
5+
* @author Lars Strojny <lstrojny@php.net>
6+
* @copyright 2011-2021 Lars Strojny
7+
* @license https://opensource.org/licenses/MIT MIT
8+
* @link https://github.com/lstrojny/functional-php
9+
*/
10+
11+
namespace Functional;
12+
13+
use Functional\Exceptions\InvalidArgumentException;
14+
use Traversable;
15+
16+
/**
17+
* Returns a new array mapping each distinct value to the number of times it appears in the collection.
18+
* An optional callback can be provided to determine the key to count by.
19+
*
20+
* @param Traversable|array $collection
21+
* @param callable|null $callback
22+
* @return array
23+
* @no-named-arguments
24+
*/
25+
function frequencies($collection, ?callable $callback = null): array
26+
{
27+
InvalidArgumentException::assertCollection($collection, __FUNCTION__, 1);
28+
29+
$frequencies = [];
30+
31+
foreach ($collection as $index => $element) {
32+
if ($callback) {
33+
$key = $callback($element, $index, $collection);
34+
} else {
35+
$key = $element;
36+
}
37+
38+
InvalidArgumentException::assertValidArrayKey($key, __FUNCTION__);
39+
40+
if (\is_numeric($key)) {
41+
$key = (int) $key;
42+
}
43+
44+
if (!isset($frequencies[$key])) {
45+
$frequencies[$key] = 0;
46+
}
47+
48+
$frequencies[$key]++;
49+
}
50+
51+
return $frequencies;
52+
}

src/Functional/Functional.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ final class Functional
157157
*/
158158
const flip = '\Functional\flip';
159159

160+
/**
161+
* @see \Functional\frequencies
162+
*/
163+
const frequencies = '\Functional\frequencies';
164+
160165
/**
161166
* @see \Functional\from_entries
162167
*/
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
/**
4+
* @package Functional-php
5+
* @author Lars Strojny <lstrojny@php.net>
6+
* @copyright 2011-2021 Lars Strojny
7+
* @license https://opensource.org/licenses/MIT MIT
8+
* @link https://github.com/lstrojny/functional-php
9+
*/
10+
11+
namespace Functional\Tests;
12+
13+
use ArrayIterator;
14+
use Functional\Exceptions\InvalidArgumentException;
15+
16+
use function Functional\frequencies;
17+
18+
class FrequenciesTest extends AbstractTestCase
19+
{
20+
protected function setUp(): void
21+
{
22+
parent::setUp();
23+
$this->list = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple'];
24+
$this->listIterator = new ArrayIterator($this->list);
25+
$this->hash = ['a' => 'one', 'b' => 'two', 'c' => 'one', 'd' => 'two', 'e' => 'one'];
26+
$this->hashIterator = new ArrayIterator($this->hash);
27+
}
28+
29+
public function test(): void
30+
{
31+
$expected = ['apple' => 3, 'banana' => 2, 'cherry' => 1];
32+
self::assertSame($expected, frequencies($this->list));
33+
self::assertSame($expected, frequencies($this->listIterator));
34+
35+
$expectedHash = ['one' => 3, 'two' => 2];
36+
self::assertSame($expectedHash, frequencies($this->hash));
37+
self::assertSame($expectedHash, frequencies($this->hashIterator));
38+
}
39+
40+
public function testWithCallback(): void
41+
{
42+
$fn = function ($v, $k, $collection) {
43+
InvalidArgumentException::assertCollection($collection, __FUNCTION__, 3);
44+
return \strlen($v);
45+
};
46+
47+
$expected = [5 => 3, 6 => 3];
48+
self::assertSame($expected, frequencies($this->list, $fn));
49+
self::assertSame($expected, frequencies($this->listIterator, $fn));
50+
51+
$expectedHash = [3 => 5];
52+
self::assertSame($expectedHash, frequencies($this->hash, $fn));
53+
self::assertSame($expectedHash, frequencies($this->hashIterator, $fn));
54+
}
55+
56+
public function testEmptyCollection(): void
57+
{
58+
self::assertSame([], frequencies([]));
59+
self::assertSame([], frequencies(new ArrayIterator([])));
60+
}
61+
62+
public function testNumericValues(): void
63+
{
64+
$list = [1, 2, 1, 3, 2, 1];
65+
$expected = [1 => 3, 2 => 2, 3 => 1];
66+
self::assertSame($expected, frequencies($list));
67+
self::assertSame($expected, frequencies(new ArrayIterator($list)));
68+
}
69+
70+
public function testExceptionIsThrownWhenCallbackReturnsInvalidKey(): void
71+
{
72+
$invalidTypes = [
73+
'resource' => \stream_context_create(),
74+
'object' => new \stdClass(),
75+
'array' => []
76+
];
77+
78+
foreach ($invalidTypes as $type => $value) {
79+
$fn = function () use ($value) {
80+
return $value;
81+
};
82+
try {
83+
frequencies(['v1'], $fn);
84+
self::fail(\sprintf('Error expected for array key type "%s"', $type));
85+
} catch (\Exception $e) {
86+
self::assertSame(
87+
\sprintf(
88+
'Functional\frequencies(): callback returned invalid array key of type "%s". Expected NULL, string, integer, double or boolean',
89+
$type
90+
),
91+
$e->getMessage()
92+
);
93+
}
94+
}
95+
}
96+
97+
public function testPassNoCollection(): void
98+
{
99+
$this->expectArgumentError('Functional\frequencies() expects parameter 1 to be array or instance of Traversable');
100+
frequencies('invalidCollection');
101+
}
102+
103+
public function testPassNonCallable(): void
104+
{
105+
$this->expectCallableArgumentError('Functional\frequencies', 2);
106+
frequencies($this->list, 'undefinedFunction');
107+
}
108+
}

0 commit comments

Comments
 (0)