# 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()` and `fix()` return decorator 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 function itself: ```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()`; any other callable is converted to a closure. Implement `Compilable` on your own decorators to get the same benefit. You rarely need to call `compile()` yourself: the collection functions compile every callback once per step. ## 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. Sorting, grouping, collectors and queries need the whole input, so they exist only in `c\`. They take any iterable, generators included, and are how a lazy chain ends. ### 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(...)`. This also holds when they are wrapped, e.g. `f\partial(str_pad(...), _, 3, '0')`. - **Closures declaring exactly one parameter**, like `fn ($value) => ...`. ```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 ``` 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()`. ### 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". ### Queries `first`, `last`, `find`, `contains`, `any`, `some`, `every`, `count`, `isEmpty`, `min`, `max`, `reduce`, `sum` and `join` turn a collection into a single value. They stop reading as soon as the answer is known, which matters for generators. ```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 ``` `reduce()` callbacks take the carry first: `fn ($carry, $value, $key)`. ### 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 has `@template` generics, so PHPStan (level 8 in this repository) follows element types through a chain, `|>` included: ```php /** @param Ds\Seq $departures */ function delayedLines(Ds\Seq $departures): ?string { return $departures |> c\filter(fn (Departure $departure): bool => $departure->delay > 0) // Ds\Seq |> c\map(fn (Departure $departure): string => $departure->line) // Ds\Seq |> 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). The limits of what can be inferred: - **Element types come from the callback's parameter and return types.** `fn ($departure) => ...` without types gives `mixed`. - **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` shows as `Ds\Seq&iterable`, 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, on src/ and the type tests in tests/types/ ``` - `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. 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.