22 KiB
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 collections.
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
- Predicates
- Transforms
- Partial application, currying and composition
- Compiling decorators
- Collections
- Static analysis
- Contributing
Installation
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.
The examples below use this class, and $departures is a list of them:
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().
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:
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
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:
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 |>:
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():
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:
$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.
[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 aDs\Seq, not the spent generator.filter()on an array keeps the keys, likearray_filter(). Addc\values()when you need a list.map()on aDs\SetfollowsDs\Set::map(), so results that are equal collapse into one.merge()merges arrays andDs\Mapby key (integer keys of arrays are kept, unlikearray_merge()); for other inputs it appends.sort()/sortBy()are stable and never modify their input, unlikeDs\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:
$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.
$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.
$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.
$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:
$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 intoArray()ormerge()) andUnexpectedValueException(aflatMap()callback that returns no iterable). - Library conditions raise subclasses of
Kadet\Functional\Exception\FunctionalException:EmptyCollectionException(arequiredquery found nothing) andDsUnavailableException(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:
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,anyortakeon 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,toMapandtoSetthrowKadet\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:
/** @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 givesmixed. - Accessors and predicates are not generic.
t\property(),t\field(),t\method()andt\getter()are typed\Closure(object): mixed, and thep\builders returnPredicate. Chains using them analyse cleanly, but the elements becomeobject(c\sortBy(t\property('delay'))) ormixed(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
$keyparameter. Otherwise the keys of the result aremixed(int|stringfor arrays). - Transforms without a callback (
values,keys,unique,sort(),slice,take,skip,prepend,append,concat,merge) keep the element types but are typed as returningiterable: the container type is lost. Follow them with a collector such asc\toSeq()if you need it. - With the php-ds polyfill, PHPStan does not treat
Ds\Seqas generic:Ds\Seq<T>shows asDs\Seq&iterable<T>, and the values of aDs\Mapread asmixed, so queries over a map (c\first(),c\last()) givemixed.
Contributing
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 testruns 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.phpis the only test that uses|>. It is loaded only on PHP 8.5 and newer (seephpunit.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.neonlists errors in code that predates PHPStan in this project. New code must not add to it.