document callback adaptation, errors and min/max ties

This commit is contained in:
Kacper Donat 2026-10-27 23:12:05 +01:00
parent db3aab644e
commit c27e326d59
3 changed files with 61 additions and 15 deletions

View File

@ -125,10 +125,10 @@ $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:
`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;
@ -139,9 +139,17 @@ $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.
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
@ -161,9 +169,10 @@ $lines = $departures
```
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.
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
@ -206,12 +215,19 @@ argument, so a naive `($value, $key)` call would round the first element to 0 pl
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')`.
- **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
@ -316,7 +332,7 @@ $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".
`Ds\Map`, instead of PHP's "Illegal offset type". So does `merge()` when it merges such keys into an array.
### Queries
@ -341,7 +357,23 @@ the largest delay, not the departure that has it. To get the element, sort and t
$departures |> c\sortBy(t\property('delay'), descending: true) |> c\first(); // d4
```
`reduce()` callbacks take the carry first: `fn ($carry, $value, $key)`.
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

View File

@ -17,8 +17,11 @@ interface Compilable
/**
* Returns a closure that behaves like invoking this object, but does not go through it on each call.
*
* The one difference: when the call ends in a non-variadic internal function, arguments past its parameter count
* are dropped, where invoking this object would throw an ArgumentCountError.
* The differences:
* - when the call ends in a non-variadic internal function, arguments past its parameter count are dropped, where
* invoking this object would throw an ArgumentCountError (`all()` and `any()` likewise give each predicate only
* the arguments it takes);
* - a compiled CurriedFunction calls its function with the arguments it gets, it does not curry further.
*/
public function compile(): \Closure;
}

View File

@ -192,6 +192,17 @@ class QueriesTest extends \PHPUnit\Framework\TestCase
$this->assertNull(c\max()([]));
}
public function testMinMaxTiesKeepFirstExtremum()
{
$a = (object) ['x' => 1];
$b = (object) ['x' => 1];
$this->assertSame($a, c\min()([$a, $b]));
$this->assertSame($a, c\max()([$a, $b]));
$this->assertSame(1, c\max()([1, 1.0]));
$this->assertSame(1.0, c\min()([1.0, 1]));
}
public function testMinWithSelector()
{
$this->assertSame(1, c\min(fn ($v) => strlen($v))(['ccc', 'a', 'bb']));