functional-php/README.md
2026-10-28 10:05:33 +01:00

483 lines
23 KiB
Markdown

# kadet/functional
Small functional building blocks for PHP 8.2+: predicates, property and method accessors, partial application and
currying, and a set of collection functions made for the pipe operator. They work on arrays, generators and
[php-ds](https://github.com/php-ds/ext-ds) collections.
```php
use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Transforms as t;
$delayed = $departures
|> lazy\filter(p\property('delay', p\gt(60)))
|> c\sortBy(t\property('delay'), descending: true)
|> c\keyBy(t\property('id'))
|> c\map(fn (Departure $departure) => "{$departure->line} to {$departure->destination}");
```
- [Installation](#installation)
- [Predicates](#predicates)
- [Transforms](#transforms)
- [Partial application, currying and composition](#partial-application-currying-and-composition)
- [Compiling decorators](#compiling-decorators)
- [Collections](#collections)
- [Static analysis](#static-analysis)
- [Contributing](#contributing)
## Installation
```bash
composer require kadet/functional
```
The library has no required dependencies. For the collection functions, php-ds is recommended: either the
extension (`pie install php-ds/ext-ds:^2.0`) or the polyfill (`composer require php-ds/php-ds`). Without it, everything
that works on arrays and generators still does; see [Without php-ds](#without-php-ds).
The examples below use this class, and `$departures` is a list of them:
```php
final class Departure
{
public function __construct(
public readonly string $id,
public readonly string $line,
public readonly string $destination,
public readonly int $delay = 0, // in seconds
) {
}
}
$departures = [
new Departure('d1', '8', 'Stogi'),
new Departure('d2', '8', 'Stogi', delay: 120),
new Departure('d3', '12', 'Oliwa', delay: 30),
new Departure('d4', '12', 'Oliwa', delay: 300),
new Departure('d5', '6', 'Łostowice'),
];
```
## Predicates
A predicate is a callable object returning a bool. The functions in `Kadet\Functional\Predicates` build the common
ones, and predicates combine with `and()`, `or()` and `not()`.
```php
use Kadet\Functional as f;
use Kadet\Functional\Predicates as p;
$late = p\property('delay', p\gt(60));
$toOliwa = p\property('destination', p\same('Oliwa'));
$late($departures[3]); // true
$late->and($toOliwa)($departures[1]); // false: late, but going to Stogi
$late->or($toOliwa)->not()($departures[0]); // true
f\not($late)($departures[0]); // true
```
Comparisons: `equals` (`==`), `same` (`===`), `gt`, `ge`, `lt`, `le`. Accessors: `property` (uses a `getX()`
getter when there is one), `field`, `method`. Plus `instance($class)`. `f\all(...)` and `f\any(...)` combine many
predicates at once, and `f\always()` / `f\never()` are constants.
## Transforms
`Kadet\Functional\Transforms` builds accessor functions, handy as selectors:
```php
use Kadet\Functional\Transforms as t;
t\property('line')($departures[0]); // '8', via getLine() when the object has one
t\field('line')($departures[0]); // '8', always the field itself
t\method('format', 'H:i')(new DateTimeImmutable('12:30')); // '12:30'
```
## Partial application, currying and composition
```php
use Kadet\Functional as f;
use const Kadet\Functional\_;
// Fix some arguments; `_` leaves a hole to fill later.
$replaceA = f\partial(str_replace(...), 'a', _, 'banana');
$replaceA('o'); // 'bonono'
// A curried function waits until it has all required arguments.
$between = f\curry(fn (int $min, int $max, int $value) => $value >= $min && $value <= $max);
$isHour = $between(0, 23);
$isHour(12); // true
$between(0)(59)(60); // false
// Left-to-right composition.
$slug = f\pipe(trim(...), strtolower(...), f\partial(str_replace(...), ' ', '-', _));
$slug(' Main Station '); // 'main-station'
// Anonymous recursion: the function receives itself as the first argument.
$factorial = f\fix(fn ($self, int $n) => $n < 2 ? 1 : $n * $self($n - 1));
$factorial(5); // 120
// Remember results by arguments.
$slow = f\memoize(fn (string $stop) => strtoupper($stop)); // imagine an expensive lookup
$slow('stogi'); // computed
$slow('stogi'); // remembered
```
## Compiling decorators
`partial()`, `curry()`, `fix()`, `pipe()` and the predicates return objects. Calling one goes through its
`__invoke()`, which inspects the arguments every time. That is fine for a single call, but in a loop over thousands
of elements it adds up. `f\compile()` flattens such a callable into a plain `\Closure` once, so the loop pays only
for the functions it calls:
```php
use Kadet\Functional as f;
use const Kadet\Functional\_;
$pad = f\compile(f\partial(str_pad(...), _, 3, '0', STR_PAD_LEFT));
$pad('7'); // '007'
```
Objects implementing `Kadet\Functional\Compilable` are compiled with their own `compile()`, also when given as a
closure of their `__invoke()`; any other callable is converted to a closure. Compiling is recursive: a predicate
like `p\property('delay', p\gt(60))` becomes a closure calling the accessor and the comparison directly. Implement
`Compilable` on your own decorators to get the same benefit. `memoize()` is not compiled, its cache is the point.
A compiled callable behaves like the object, with two differences. When the call ends in an internal function,
arguments it can't take are dropped instead of throwing. And a compiled `curry()` calls its function with the
arguments it gets, without currying further.
You rarely need to call `compile()` yourself: the collection functions compile every callback once per step,
predicates and pipes included.
## Collections
The collection functions take their configuration and return a function that takes the data, so a pipeline is a
chain of `|>`:
```php
use Kadet\Functional\Collections as c; // eager: returns collections
use Kadet\Functional\Collections\Lazy as lazy; // lazy: returns generators
$lines = $departures
|> c\filter(fn (Departure $departure) => $departure->delay > 0)
|> c\map(t\property('line'))
|> c\unique()
|> c\values();
// ['8', '12']
```
The namespace says which flavour you get. `c\` is eager and gives a finished collection after every step. `lazy\`
holds the streaming transforms, which give a generator and do no work until something reads it. Collectors and
queries end a chain, turning it into a collection or a single value, so there is nothing to delay and they exist
only in `c\`. They take any iterable, generators included, and are how a lazy chain ends. Sorting needs the whole
input before it can give the first element, so it is eager only as well.
### Pipes, and calling without them
Every function returns a closure, and there is no "data first" form. One shape for everything means every function
composes the same way, and it avoids an ambiguity: a callable array like `[$repository, 'find']` is also an
iterable, so `c\first($something)` could not tell data from a predicate.
The pipe operator needs PHP 8.5. On earlier versions, or for a single step, call the closure directly or compose
steps with `f\pipe()`:
```php
c\first()($departures); // the first departure
$firstLate = f\pipe(c\filter(p\property('delay', p\gt(60))), c\first());
$firstLate($departures); // d2
```
Where a php-ds method reads better for a one-off (`$seq->first()`), use it.
### Callbacks get the value, then the key
Every callback, predicate and selector is called as `$fn($value, $key)`, for every kind of input, `Ds\Map`
included:
```php
$codes = new Ds\Map(['STG' => 'Stogi', 'OLW' => 'Oliwa']);
$codes |> c\map(fn (string $name, string $code) => "$name ($code)");
// Ds\Map ['STG' => 'Stogi (STG)', 'OLW' => 'Oliwa (OLW)']
```
Value first, because most callbacks only need the value. It is also the order of `array_filter()` with
`ARRAY_FILTER_USE_BOTH`. `Ds\Map::map()` itself passes `($key, $value)`: when you switch between the two, the
library's order is the one that stays the same across arrays, generators and all php-ds types.
### Builtins get only the value
Passing the key along is a trap for functions with optional parameters. `round()` takes a precision as its second
argument, so a naive `($value, $key)` call would round the first element to 0 places, the second to 1 and the
third to 2. To prevent that, two kinds of callables are called with the value only:
- **PHP's internal functions**, written as first-class callables: `trim(...)`, `round(...)`, `strtoupper(...)`.
- **Any callable declaring exactly one parameter**, like `fn ($value) => ...` or a one-parameter method.
The library's own wrappers are seen through: what counts is the function a callable ultimately calls. For
`partial()`, `curry()`, `memoize()` and `f\predicate()` that is the wrapped function, for `f\not()` the
negated predicate, and for `f\pipe()` its first function, the one that gets the arguments. So
`f\partial(str_pad(...), _, 3, '0')`, `f\memoize(fn ($stop) => ...)` (cached by the value alone) and
`f\pipe(trim(...), strtolower(...))` all get only the value. `f\all()` and `f\any()` decide for each of their
predicates: in `f\all(is_numeric(...), fn ($value, $key) => ...)` the first gets the value, the second the key too.
```php
[1.25, 2.5, 3.14159] |> c\map(round(...)); // [1.0, 3.0, 3.0], not [1.0, 2.5, 3.14]
[' a ', ' b'] |> c\map(trim(...)); // ['a', 'b']; the key never becomes trim's character list
['n' => ' Main St '] |> c\map(f\pipe(trim(...), strtolower(...))); // ['n' => 'main st']
```
Any other callable gets the key, **even into an optional parameter**. A method like
`format(Departure $departure, string $style = 'short')` would receive the key as `$style`. Wrap it in a
one-parameter closure: `c\map(fn ($departure) => $formatter->format($departure))`.
Builtins are written as first-class callables (`trim(...)`) throughout. String callables (`'trim'`) work too, but
`trim(...)` fails right where it is written if the function doesn't exist, is understood by IDEs and static
analysis, and can't be mistaken for data.
### What you get back
Eager transforms return the same kind of container they were given, so a `Seq` stays a `Seq` and an array stays an
array. The details:
| Input | Keyed transforms¹ | List transforms² |
|---|---|---|
| array | array, keys preserved | list array |
| `Ds\Map` | `Ds\Map`, keys preserved | `Ds\Seq` |
| `Ds\Seq` | `Ds\Seq`, reindexed | `Ds\Seq` |
| `Ds\Set` | `Ds\Set` | `Ds\Set` (but `keys()` gives a `Ds\Seq`) |
| generator, any other iterable | `Ds\Seq` (a list array without php-ds) | `Ds\Seq` (a list array without php-ds) |
¹ `map`, `filter`, `reject`, `unique`, `uniqueBy`, `sort`, `sortBy`, `slice`, `take`, `skip`, `merge`, `each`,
`tap`. ² `flatMap`, `values`, `keys`, `prepend`, `append`, `concat`: these produce a list, and a `Ds\Map` can't hold
a list, so it becomes a `Ds\Seq`.
A few consequences worth knowing:
- **A generator given to an eager transform comes back as a `Ds\Seq`** (or a list array without php-ds). The
callbacks still see the generator's keys, but the result is a list. A generator can be read only once, so
returning a half-read generator, or a generator at all, from an eager function would be a trap.
- **`each()`/`tap()` return their input** unchanged for arrays and php-ds collections, which makes them safe to put
in the middle of a chain for logging. A generator is consumed by them, so they return its values as a
`Ds\Seq`, not the spent generator.
- `filter()` on an array keeps the keys, like `array_filter()`. Add `c\values()` when you need a list.
- `map()` on a `Ds\Set` follows `Ds\Set::map()`, so results that are equal collapse into one.
- `merge()` merges arrays and `Ds\Map` by key (integer keys of arrays are kept, unlike `array_merge()`); for other
inputs it appends.
- `sort()`/`sortBy()` are stable and never modify their input, unlike `Ds\Seq::sort()`.
- `sort()` without a comparator and `sortBy()` sort natively, comparing like `<=>`. The one difference is enums
used directly as the sort key (or as the values for `sort()`), which `<=>` can't order: they are grouped by case,
in an unspecified order (PHP compares object addresses, not declaration order), and placed after the other values
(before them in descending order). Enums nested inside arrays compare as `<=>` does.
### Finding things: `first`, `find` and `required`
`first()` and `last()` return `null` when the collection is empty or nothing matches. A missing value is usually
an expected outcome (no departure from this stop right now), so it should not need a `try`. When the value must be
there, say so with `required: true`, and get an exception that names the function and what went wrong:
```php
$departures |> c\first(p\property('line', p\same('12'))); // d3
$departures |> c\first(p\property('line', p\same('2'))); // null
$departures |> c\first(p\property('line', p\same('2')), required: true);
// EmptyCollectionException: first(): no element matched
[] |> c\last(required: true);
// EmptyCollectionException: last(): collection is empty
```
`required` also makes a found `null` distinguishable: `[null] |> c\first(required: true)` returns `null` without
throwing, because something was found.
`contains($value)` and `any($predicate)` are separate functions on purpose. A string like `'date'` is both a
value and a valid callable, so one function taking either could not know which you meant. `contains()` compares
strictly (`===`; a `Ds\Key` with `equals()`), `any()` calls the predicate.
### Aliases
Some functions have a second name, so the code can say what it means. Each is a one-line delegation and behaves
exactly like its target:
| Alias | Same as | Use it when |
|---|---|---|
| `find($predicate)` | `first($predicate)`, with the predicate required | you are searching, not taking the head |
| `some($predicate)` | `any($predicate)` | it reads better in a condition: `if ($stops \|> c\some(...))` |
| `tap($fn)` | `each($fn)` | the call is for a side effect in the middle of a chain, like logging |
### Transforms
`map`, `filter` (keeps truthy values without a callback), `reject`, `flatMap`, `values`, `keys`, `unique`,
`uniqueBy`, `sort` (comparator or `<=>`), `sortBy` (selector, optionally `descending: true`), `slice`, `take`,
`skip`, `prepend`, `append`, `concat`, `merge`, `each`, `tap`.
```php
$delays = $departures
|> c\reject(fn (Departure $departure) => $departure->delay === 0)
|> c\sortBy(t\property('delay'))
|> c\map(fn (Departure $departure) => sprintf('%s: +%d min', $departure->id, $departure->delay / 60))
|> c\values();
// ['d3: +0 min', 'd2: +2 min', 'd4: +5 min']
```
### Collectors
`keyBy`, `groupBy` (a `Ds\Map` of `Ds\Seq`, in encounter order), `toSeq`, `toMap`, `toSet` and `toArray`. All but
`toArray` produce php-ds collections.
```php
$byLine = $departures |> c\groupBy(t\property('line'));
// Ds\Map ['8' => Seq [d1, d2], '12' => Seq [d3, d4], '6' => Seq [d5]]
$byId = $departures |> c\keyBy(t\property('id'));
$byId->get('d4')->destination; // 'Oliwa'
```
`toArray()` throws an `InvalidArgumentException` when a key can't be an array key, like an object key of a
`Ds\Map`, instead of PHP's "Illegal offset type". So does `merge()` when it merges such keys into an array.
### Queries
`first`, `last`, `find`, `contains`, `any`, `some`, `every`, `count`, `isEmpty`, `min`, `max`, `reduce`, `sum` and
`join` turn a collection into a single value. `first`, `find`, `any`, `some`, `every`, `contains` and `isEmpty`
stop reading as soon as the answer is known, which matters for generators. The others read everything, with two
shortcuts: `last` reads arrays and php-ds collections from the end, and `count` asks countable containers for their
size.
```php
$departures |> c\every(fn (Departure $departure) => $departure->delay < 600); // true
$departures |> c\sum(t\property('delay')); // 450
$departures |> c\max(t\property('delay')); // 300
$departures |> c\map(t\property('line')) |> c\unique() |> c\join(', '); // '8, 12, 6'
$departures |> c\reduce(fn (int $carry, Departure $departure) => $carry + 1, 0); // 5
```
**`min()` and `max()` with a selector return the selected value, not the element.** Above, `c\max()` gives `300`,
the largest delay, not the departure that has it. To get the element, sort and take the first:
```php
$departures |> c\sortBy(t\property('delay'), descending: true) |> c\first(); // d4
```
On a tie, `min()` and `max()` return the first of the equal values: `[1, 1.0] |> c\max()` is `1`, and of two
objects equal under `<=>` you get the one that came first.
`reduce()` callbacks take the carry first: `fn ($carry, $value, $key)`. Like the value-only rule above, an internal
function or a callable declaring exactly two parameters gets only `($carry, $value)`, so
`[5 => 1, 9 => 2] |> c\reduce(max(...), 0)` is `2`, not the key `9`.
### Errors
Exceptions name the function and the problem. Two kinds are thrown:
- **Invalid arguments** raise PHP's own SPL exceptions: `InvalidArgumentException` (`c\take(-1)`, a key that can't be
an array key in `toArray()` or `merge()`) and `UnexpectedValueException` (a `flatMap()` callback that returns no
iterable).
- **Library conditions** raise subclasses of `Kadet\Functional\Exception\FunctionalException`:
`EmptyCollectionException` (a `required` query found nothing) and `DsUnavailableException` (a collector needs
php-ds, see below).
### Lazy pipelines
`lazy\map`, `filter`, `reject`, `flatMap`, `values`, `keys`, `unique`, `uniqueBy`, `slice`, `take`, `skip`,
`prepend`, `append`, `concat`, `each` and `tap` return a generator. Each element travels through the whole chain
before the next one is read, and nothing happens until the chain is read by a collector or a query:
```php
function readDepartures(string $path): Generator
{
foreach (new SplFileObject($path) as $line) {
if ($line !== '') {
yield json_decode($line, true);
}
}
}
$firstTram = readDepartures('departures.jsonl')
|> lazy\filter(fn (array $row) => $row['vehicle'] === 'tram')
|> lazy\map(fn (array $row) => new Departure($row['id'], $row['line'], $row['destination']))
|> c\first(); // reads the file only up to the first tram
```
Choose lazy when:
- **the input is large or streamed** (an import, a file, a database cursor): no step builds an intermediate
collection, so memory stays flat;
- **you stop early**: `first`, `find`, `any` or `take` on a big input read only as far as needed;
- **the input is infinite**, like a generator counting up.
Stay eager when the result is stored, returned from a function or read more than once. A generator can be read
only once, and a second `foreach` over it silently sees nothing (or throws). So don't let a lazy value leave the
function that builds it: end the chain with a collector (`c\toSeq()`, `c\keyBy()`, ...) or a query. For small
collections, eager is also easier to debug, since every step has a value you can look at.
### Without php-ds
php-ds is optional. Without it:
- every transform, lazy or eager, works on arrays and generators; eager transforms given a generator return a list
array instead of a `Ds\Seq`;
- `toArray()` and all queries work;
- `keyBy`, `groupBy`, `toSeq`, `toMap` and `toSet` throw `Kadet\Functional\Exception\DsUnavailableException`
("keyBy() needs php-ds: install ext-ds or require php-ds/php-ds"). They throw when the collector is built, not
when data arrives, so `$fn = c\keyBy(...)` fails at once, at the line that needs php-ds, even if no data ever
reaches it.
With php-ds you get `Ds\Map` keys of any type (objects included), collections that are objects (passed by handle,
not copied), and results whose type says whether they are a list (`Seq`), a mapping (`Map`) or a set (`Set`).
## Static analysis
Every function in `Collections` has `@template` generics, so PHPStan (level 8 in this repository) follows element
types through a chain, `|>` included:
```php
/** @param Ds\Seq<Departure> $departures */
function delayedLines(Ds\Seq $departures): ?string
{
return $departures
|> c\filter(fn (Departure $departure): bool => $departure->delay > 0) // Ds\Seq<Departure>
|> c\map(fn (Departure $departure): string => $departure->line) // Ds\Seq<string>
|> c\first(); // string|null
}
```
Eager transforms with a callback keep the container in the type as well (a `Ds\Map` in, a `Ds\Map` out; a
generator in, `Ds\Seq|list` out). A callable PHPStan knows nothing about never causes an error, it only makes
the result `mixed`. The limits of what can be inferred:
- **Element types come from the callback's parameter and return types.** `fn ($departure) => ...` without types
gives `mixed`.
- **Accessors and predicates are not generic.** `t\property()`, `t\field()`, `t\method()` and `t\getter()` are typed
`\Closure(object): mixed`, and the `p\` builders return `Predicate`. Chains using them analyse cleanly, but the
elements become `object` (`c\sortBy(t\property('delay'))`) or `mixed` (`c\map(t\property('line'))`,
`c\max(t\property('delay'))`). Where the type matters, write the closure:
`c\map(fn (Departure $departure): string => $departure->line)`.
- **`sum()` doesn't check that the values are numbers** when called without a selector.
- **Keys are inferred only when the callback declares a `$key` parameter.** Otherwise the keys of the result are
`mixed` (`int|string` for arrays).
- **Transforms without a callback** (`values`, `keys`, `unique`, `sort()`, `slice`, `take`, `skip`, `prepend`,
`append`, `concat`, `merge`) keep the element types but are typed as returning `iterable`: the container type is
lost. Follow them with a collector such as `c\toSeq()` if you need it.
- With the php-ds **polyfill**, PHPStan does not treat `Ds\Seq` as generic: `Ds\Seq<T>` shows as
`Ds\Seq&iterable<T>`, and the values of a `Ds\Map` read as `mixed`, so queries over a map (`c\first()`, `c\last()`)
give `mixed`.
## Contributing
```bash
composer test # both PHPUnit suites
composer phpstan # PHPStan level 8: src/ as PHP 8.2 (phpstan.neon), tests/types/ as PHP 8.5 (phpstan.types.neon)
```
- `composer test` runs the main suite and the no-php-ds suite (`phpunit.no-ds.xml`), which hides the polyfill to
check the paths without php-ds. **The no-ds suite is skipped when ext-ds is loaded**, because an extension can't
be hidden: it prints a notice and passes. Run it on a PHP without ext-ds to cover those paths.
- `tests/PipeSyntaxTest.php` is the only test that uses `|>`. It is loaded only on PHP 8.5 and newer (see
`phpunit.xml`), so the rest of the suite still runs on PHP 8.2. Keep `|>` out of other tests.
- `tests/types/` holds PHPStan type assertions (`assertType()`). They are analysed, never executed, with their own
config, because they use `|>`. Add one when you change a signature.
- `phpstan-baseline.neon` lists errors in code that predates PHPStan in this project. New code must not add to it.