# 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 $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). 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` 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: 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.