From 5c60d8b7d3a32386249c7c815260d46d1b76a186 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 1 Dec 2024 12:58:44 +0100 Subject: [PATCH 1/6] fix Either and Maybe ::memoize() not loading everything --- CHANGELOG.md | 6 ++++++ proofs/either.php | 27 +++++++++++++++++++++++++++ proofs/maybe.php | 20 ++++++++++++++++++++ src/Either/Defer.php | 2 +- src/Maybe/Defer.php | 2 +- 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 proofs/either.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 468634f..3b34c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- `Innmind\Immutable\Maybe::memoize()` and `Innmind\Immutable\Either::memoize()` was only unwrapping the first layer of the monad. It now recursively unwraps until all the deferred monads are memoized. + ## 5.10.0 - 2024-11-09 ### Added diff --git a/proofs/either.php b/proofs/either.php new file mode 100644 index 0000000..4ab566a --- /dev/null +++ b/proofs/either.php @@ -0,0 +1,27 @@ +filter(static fn($value) => !\is_null($value))), + static function($assert, $value) { + $loaded = false; + $either = Either::defer(static fn() => Either::right($value)) + ->flatMap(static function() use ($value, &$loaded) { + return Either::defer(static function() use ($value, &$loaded) { + $loaded = true; + + return Either::right($value); + }); + }); + + $assert->false($loaded); + $either->memoize(); + $assert->true($loaded); + }, + ); +}; diff --git a/proofs/maybe.php b/proofs/maybe.php index bdff303..1b165aa 100644 --- a/proofs/maybe.php +++ b/proofs/maybe.php @@ -43,4 +43,24 @@ static function($assert, $value1, $value2) { ); }, ); + + yield proof( + 'Maybe::memoize() any composition', + given(Set\Type::any()->filter(static fn($value) => !\is_null($value))), + static function($assert, $value) { + $loaded = false; + $maybe = Maybe::defer(static fn() => Maybe::just($value)) + ->flatMap(static function() use ($value, &$loaded) { + return Maybe::defer(static function() use ($value, &$loaded) { + $loaded = true; + + return Maybe::just($value); + }); + }); + + $assert->false($loaded); + $maybe->memoize(); + $assert->true($loaded); + }, + ); }; diff --git a/src/Either/Defer.php b/src/Either/Defer.php index e962868..fe37f91 100644 --- a/src/Either/Defer.php +++ b/src/Either/Defer.php @@ -108,7 +108,7 @@ private function unwrap(): Either * @psalm-suppress InaccessibleProperty * @psalm-suppress ImpureFunctionCall */ - return $this->value ??= ($this->deferred)(); + return $this->value ??= ($this->deferred)()->memoize(); } /** diff --git a/src/Maybe/Defer.php b/src/Maybe/Defer.php index 09d46a4..251e537 100644 --- a/src/Maybe/Defer.php +++ b/src/Maybe/Defer.php @@ -107,7 +107,7 @@ private function unwrap(): Maybe * @psalm-suppress InaccessibleProperty * @psalm-suppress ImpureFunctionCall */ - return $this->value ??= ($this->deferred)(); + return $this->value ??= ($this->deferred)()->memoize(); } /** From a8915c31b004e2fcf1e91249985f187b374595dd Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 1 Dec 2024 15:06:38 +0100 Subject: [PATCH 2/6] add Sequence::sink() --- CHANGELOG.md | 4 + docs/structures/sequence.md | 74 +++++++++ proofs/sequence.php | 243 +++++++++++++++++++++++++++++ src/Sequence.php | 12 ++ src/Sequence/Defer.php | 25 +++ src/Sequence/Implementation.php | 12 ++ src/Sequence/Lazy.php | 32 ++++ src/Sequence/Primitive.php | 25 +++ src/Sequence/Sink.php | 123 +++++++++++++++ src/Sequence/Sink/Continuation.php | 70 +++++++++ 10 files changed, 620 insertions(+) create mode 100644 src/Sequence/Sink.php create mode 100644 src/Sequence/Sink/Continuation.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b34c7f..66e297c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `Innmind\Immutable\Sequence::sink()` + ### Fixed - `Innmind\Immutable\Maybe::memoize()` and `Innmind\Immutable\Either::memoize()` was only unwrapping the first layer of the monad. It now recursively unwraps until all the deferred monads are memoized. diff --git a/docs/structures/sequence.md b/docs/structures/sequence.md index cfb799e..9a12f87 100644 --- a/docs/structures/sequence.md +++ b/docs/structures/sequence.md @@ -479,6 +479,80 @@ $sum = $sequence->reduce(0, fn($sum, $int) => $sum + $int); $sum; // 10 ``` +### `->sink()` :material-memory-arrow-down: + +This is similar to [`->reduce`](#-reduce) except you decide on each iteration it you want to continue reducing or not. + +This is useful for long sequences (mainly lazy ones) where you need to reduce until you find some value in the `Sequence` or the reduced value matches some condition. This avoids iterating over values you know for sure you won't need. + +=== "By hand" + ```php + use Innmind\Immutable\Sequence\Sink\Continuation; + + $sequence = Sequence::of(1, 2, 3, 4, 5); + $sum = $sequence + ->sink(0) + ->until(static fn( + int $sum, + int $i, + Continuation $continuation, + ) => match (true) { + $sum > 5 => $continuation->stop($sum), + default => $continuation->continue($sum + $i), + }); + ``` + + Here `#!php $sum` is `#!php 6` and the `Sequence` stopped iterating on the 4th value. + +=== "Maybe" + ```php + $sequence = Sequence::of(1, 2, 3, 4, 5); + $sum = $sequence + ->sink(0) + ->maybe(static fn(int $sum, int $i) => match (true) { + $sum > 5 => Maybe::nothing(), + default => Maybe::just($sum + $i), + }) + ->match( + static fn(int $sum) => $sum, + static fn() => null, + ); + ``` + + Instead of manually specifying if we want to continue or not, it's inferred by the content of the `Maybe`. + + Here the `#!php $sum` is `#!php null` because on the 4th iteration we return a `#!php Maybe::nothing()`. + + !!! warning "" + Bear in mind that the carried value is lost when an iteration returns `#!php Maybe::nothing()`. + + If you need to still have access to the carried value you should use `#!php ->sink()->either()` and place the carried value on the left side. + + ??? abstract + In essence this allows the transformation of `Sequence>` to `Maybe>`. + +=== "Either" + ```php + $sequence = Sequence::of(1, 2, 3, 4, 5); + $sum = $sequence + ->sink(0) + ->either(static fn(int $sum, int $i) => match (true) { + $sum > 5 => Either::left($sum), + default => Either::right($sum + $i), + }) + ->match( + static fn(int $sum) => $sum, + static fn(int $sum) => $sum, + ); + ``` + + Instead of manually specifying if we want to continue or not, it's inferred by the content of the `Either`. + + Here the `#!php $sum` is `#!php 6` because on the 4th iteration we return an `#!php Either::left()` with the carried sum from the previous iteration. + + ??? abstract + In essence this allows the transformation of `Sequence>` to `Either>`. + ## Misc. ### `->equals()` :material-memory-arrow-down: diff --git a/proofs/sequence.php b/proofs/sequence.php index 3fdd02f..9d4bada 100644 --- a/proofs/sequence.php +++ b/proofs/sequence.php @@ -3,6 +3,8 @@ use Innmind\Immutable\{ Sequence, + Maybe, + Either, Str, Monoid\Concat, }; @@ -170,4 +172,245 @@ static function($assert, $calls) { } }, ); + + yield proof( + 'Sequence::sink()->until()', + given(Set\Sequence::of(Set\Type::any())), + static function($assert, $values) { + $all = Sequence::of(...$values) + ->sink([]) + ->until(static fn($all, $value, $continuation) => $continuation->continue( + [...$all, $value], + )); + + $assert->same($values, $all); + + $none = Sequence::of(...$values) + ->sink([]) + ->until(static fn($all, $value, $continuation) => $continuation->stop( + $all, + )); + + $assert->same([], $none); + }, + ); + + yield proof( + 'Sequence::sink()->until() when deferred', + given(Set\Sequence::of(Set\Type::any())), + static function($assert, $values) { + $all = Sequence::defer((static function() use ($values) { + yield from $values; + })()) + ->sink([]) + ->until(static fn($all, $value, $continuation) => $continuation->continue( + [...$all, $value], + )); + + $assert->same($values, $all); + + $none = Sequence::defer((static function() use ($values) { + yield from $values; + })()) + ->sink([]) + ->until(static fn($all, $value, $continuation) => $continuation->stop( + $all, + )); + + $assert->same([], $none); + }, + ); + + yield proof( + "Sequence::sink()->until() when deferred doesn't load values after stop", + given( + Set\Sequence::of(Set\Type::any()), + Set\Sequence::of(Set\Type::any()), + ), + static function($assert, $prefix, $suffix) { + $stop = new stdClass; + $loaded = false; + $all = Sequence::defer((static function() use ($prefix, $suffix, $stop, &$loaded) { + yield from $prefix; + yield $stop; + $loaded = true; + yield from $suffix; + })()) + ->sink([]) + ->until(static fn($all, $value, $continuation) => match ($value) { + $stop => $continuation->stop($all), + default => $continuation->continue( + [...$all, $value], + ), + }); + + $assert->same($prefix, $all); + $assert->false($loaded); + }, + ); + + yield proof( + 'Sequence::sink()->until() when lazy', + given(Set\Sequence::of(Set\Type::any())), + static function($assert, $values) { + $all = Sequence::lazy(static function() use ($values) { + yield from $values; + }) + ->sink([]) + ->until(static fn($all, $value, $continuation) => $continuation->continue( + [...$all, $value], + )); + + $assert->same($values, $all); + + $none = Sequence::lazy(static function() use ($values) { + yield from $values; + }) + ->sink([]) + ->until(static fn($all, $value, $continuation) => $continuation->stop( + $all, + )); + + $assert->same([], $none); + }, + ); + + yield proof( + "Sequence::sink()->until() when lazy doesn't load values after stop", + given( + Set\Sequence::of(Set\Type::any()), + Set\Sequence::of(Set\Type::any()), + ), + static function($assert, $prefix, $suffix) { + $stop = new stdClass; + $loaded = false; + $all = Sequence::lazy(static function() use ($prefix, $suffix, $stop, &$loaded) { + yield from $prefix; + yield $stop; + $loaded = true; + yield from $suffix; + }) + ->sink([]) + ->until(static fn($all, $value, $continuation) => match ($value) { + $stop => $continuation->stop($all), + default => $continuation->continue( + [...$all, $value], + ), + }); + + $assert->same($prefix, $all); + $assert->false($loaded); + }, + ); + + yield proof( + 'Sequence::sink()->until() when lazy cleans up on stop', + given( + Set\Sequence::of(Set\Type::any()), + Set\Sequence::of(Set\Type::any()), + ), + static function($assert, $prefix, $suffix) { + $stop = new stdClass; + $cleaned = false; + $all = Sequence::lazy(static function($register) use ($prefix, $suffix, $stop, &$cleaned) { + $register(static function() use (&$cleaned) { + $cleaned = true; + }); + yield from $prefix; + yield $stop; + yield from $suffix; + }) + ->sink([]) + ->until(static fn($all, $value, $continuation) => match ($value) { + $stop => $continuation->stop($all), + default => $continuation->continue( + [...$all, $value], + ), + }); + + $assert->same($prefix, $all); + $assert->true($cleaned); + }, + ); + + yield proof( + 'Sequence::sink()->maybe()', + given( + Set\Sequence::of(Set\Type::any()), + Set\Sequence::of(Set\Type::any()), + ), + static function($assert, $prefix, $suffix) { + $all = Sequence::of(...$prefix, ...$suffix) + ->sink([]) + ->maybe(static fn($all, $value) => Maybe::just( + [...$all, $value], + )); + + $assert->same( + [...$prefix, ...$suffix], + $all->match( + static fn($all) => $all, + static fn() => null, + ), + ); + + $stop = new stdClass; + $all = Sequence::of(...$prefix, ...[$stop], ...$suffix) + ->sink([]) + ->maybe(static fn($all, $value) => match ($value) { + $stop => Maybe::nothing(), + default => Maybe::just( + [...$all, $value], + ), + }); + + $assert->null( + $all->match( + static fn($all) => $all, + static fn() => null, + ), + ); + }, + ); + + yield proof( + 'Sequence::sink()->either()', + given( + Set\Sequence::of(Set\Type::any()), + Set\Sequence::of(Set\Type::any()), + ), + static function($assert, $prefix, $suffix) { + $all = Sequence::of(...$prefix, ...$suffix) + ->sink([]) + ->either(static fn($all, $value) => Either::right( + [...$all, $value], + )); + + $assert->same( + [...$prefix, ...$suffix], + $all->match( + static fn($all) => $all, + static fn() => null, + ), + ); + + $stop = new stdClass; + $all = Sequence::of(...$prefix, ...[$stop], ...$suffix) + ->sink([]) + ->either(static fn($all, $value) => match ($value) { + $stop => Either::left($all), + default => Either::right( + [...$all, $value], + ), + }); + + $assert->same( + $prefix, + $all->match( + static fn() => null, + static fn($all) => $all, + ), + ); + }, + ); }; diff --git a/src/Sequence.php b/src/Sequence.php index 61f244d..d73d18d 100644 --- a/src/Sequence.php +++ b/src/Sequence.php @@ -572,6 +572,18 @@ public function reduce($carry, callable $reducer) return $this->implementation->reduce($carry, $reducer); } + /** + * @template C + * + * @param C $carry + * + * @return Sequence\Sink + */ + public function sink(mixed $carry): Sequence\Sink + { + return Sequence\Sink::of($this->implementation, $carry); + } + /** * Return a set of the same type but without any value * diff --git a/src/Sequence/Defer.php b/src/Sequence/Defer.php index ec4e93f..bb9dacd 100644 --- a/src/Sequence/Defer.php +++ b/src/Sequence/Defer.php @@ -636,6 +636,31 @@ public function reduce($carry, callable $reducer) return $carry; } + /** + * @template I + * + * @param I $carry + * @param callable(I, T, Sink\Continuation): Sink\Continuation $reducer + * + * @return I + */ + public function sink($carry, callable $reducer): mixed + { + $continuation = Sink\Continuation::of($carry); + + foreach ($this->values as $value) { + /** @psalm-suppress ImpureFunctionCall */ + $continuation = $reducer($carry, $value, $continuation); + $carry = $continuation->unwrap(); + + if (!$continuation->shouldContinue()) { + return $continuation->unwrap(); + } + } + + return $continuation->unwrap(); + } + /** * @return Implementation */ diff --git a/src/Sequence/Implementation.php b/src/Sequence/Implementation.php index 5448423..a2b6b2a 100644 --- a/src/Sequence/Implementation.php +++ b/src/Sequence/Implementation.php @@ -270,6 +270,18 @@ public function sort(callable $function): self; */ public function reduce($carry, callable $reducer); + /** + * Reduce the sequence to a single value but stops on the first failure + * + * @template I + * + * @param I $carry + * @param callable(I, T, Sink\Continuation): Sink\Continuation $reducer + * + * @return I + */ + public function sink($carry, callable $reducer): mixed; + /** * Return a set of the same type but without any value * diff --git a/src/Sequence/Lazy.php b/src/Sequence/Lazy.php index 645a5d8..fcf8ac3 100644 --- a/src/Sequence/Lazy.php +++ b/src/Sequence/Lazy.php @@ -612,6 +612,38 @@ public function reduce($carry, callable $reducer) return $carry; } + /** + * @template I + * + * @param I $carry + * @param callable(I, T, Sink\Continuation): Sink\Continuation $reducer + * + * @return I + */ + public function sink($carry, callable $reducer): mixed + { + $continuation = Sink\Continuation::of($carry); + $register = RegisterCleanup::noop(); + /** @psalm-suppress ImpureFunctionCall */ + $generator = ($this->values)($register); + + /** @psalm-suppress ImpureMethodCall */ + foreach ($generator as $value) { + /** @psalm-suppress ImpureFunctionCall */ + $continuation = $reducer($carry, $value, $continuation); + $carry = $continuation->unwrap(); + + if (!$continuation->shouldContinue()) { + /** @psalm-suppress ImpureMethodCall */ + $register->cleanup(); + + break; + } + } + + return $continuation->unwrap(); + } + /** * @return Implementation */ diff --git a/src/Sequence/Primitive.php b/src/Sequence/Primitive.php index a0536a7..644fa5e 100644 --- a/src/Sequence/Primitive.php +++ b/src/Sequence/Primitive.php @@ -428,6 +428,31 @@ public function reduce($carry, callable $reducer) return \array_reduce($this->values, $reducer, $carry); } + /** + * @template I + * + * @param I $carry + * @param callable(I, T, Sink\Continuation): Sink\Continuation $reducer + * + * @return I + */ + public function sink($carry, callable $reducer): mixed + { + $continuation = Sink\Continuation::of($carry); + + foreach ($this->values as $value) { + /** @psalm-suppress ImpureFunctionCall */ + $continuation = $reducer($carry, $value, $continuation); + $carry = $continuation->unwrap(); + + if (!$continuation->shouldContinue()) { + break; + } + } + + return $continuation->unwrap(); + } + /** * @return self */ diff --git a/src/Sequence/Sink.php b/src/Sequence/Sink.php new file mode 100644 index 0000000..7866e8f --- /dev/null +++ b/src/Sequence/Sink.php @@ -0,0 +1,123 @@ + $implementation + * @param C $carry + */ + private function __construct( + private Implementation $implementation, + private mixed $carry, + ) { + } + + /** + * @internal + * @psalm-pure + * @template A + * @template B + * + * @param Implementation $implementation + * @param B $carry + * + * @return self + */ + public static function of(Implementation $implementation, mixed $carry): self + { + return new self($implementation, $carry); + } + + /** + * @param callable(C, T, Sink\Continuation): Sink\Continuation $reducer + * + * @return C + */ + public function until(callable $reducer): mixed + { + return $this->implementation->sink( + $this->carry, + $reducer, + ); + } + + /** + * This will consume all the values from the Sequence as long as a value is + * contained in the returned Maybe. + * + * @param callable(C, T): Maybe $reducer + * + * @return Maybe + */ + public function maybe(callable $reducer): Maybe + { + return $this->implementation->sink( + Maybe::just($this->carry), + static function($carry, $value, $continuation) use ($reducer) { + /** + * @var Maybe $carry + * @var T $value + */ + + /** @psalm-suppress MixedArgument */ + $maybe = $carry + ->flatMap(static fn($carry) => $reducer($carry, $value)) + ->memoize(); + + return $maybe->match( + static fn() => $continuation->continue($maybe), + static fn() => $continuation->stop($maybe), + ); + }, + ); + } + + /** + * This will consume all the values from the Sequence as long as a right + * value is contained in the returned Either. + * + * @template E + * + * @param callable(C, T): Either $reducer + * + * @return Either + */ + public function either(callable $reducer): Either + { + /** @var Either */ + $carry = Either::right($this->carry); + + return $this->implementation->sink( + $carry, + static function($carry, $value, $continuation) use ($reducer) { + /** + * @var Either $carry + * @var T $value + */ + + /** @psalm-suppress MixedArgument */ + $either = $carry + ->flatMap(static fn($carry) => $reducer($carry, $value)) + ->memoize(); + + return $either->match( + static fn() => $continuation->continue($either), + static fn() => $continuation->stop($either), + ); + }, + ); + } +} diff --git a/src/Sequence/Sink/Continuation.php b/src/Sequence/Sink/Continuation.php new file mode 100644 index 0000000..6d04b73 --- /dev/null +++ b/src/Sequence/Sink/Continuation.php @@ -0,0 +1,70 @@ + + */ + public static function of(mixed $carry): self + { + return new self($carry, true); + } + + /** + * @param T $carry + * + * @return self + */ + public function continue(mixed $carry): self + { + return new self($carry, true); + } + + /** + * @param T $carry + * + * @return self + */ + public function stop(mixed $carry): self + { + return new self($carry, false); + } + + /** + * @internal + */ + public function shouldContinue(): bool + { + return $this->continue; + } + + /** + * @internal + * + * @return T + */ + public function unwrap(): mixed + { + return $this->carry; + } +} From f262e78c5195c8b911988345072e757c3bb33360 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 1 Dec 2024 17:13:58 +0100 Subject: [PATCH 3/6] add Attempt monad --- CHANGELOG.md | 1 + docs/structures/attempt.md | 186 ++++++++++++++++++ docs/structures/index.md | 1 + mkdocs.yml | 1 + proofs/attempt.php | 343 +++++++++++++++++++++++++++++++++ src/Attempt.php | 161 ++++++++++++++++ src/Attempt/Defer.php | 123 ++++++++++++ src/Attempt/Error.php | 72 +++++++ src/Attempt/Implementation.php | 70 +++++++ src/Attempt/Result.php | 68 +++++++ 10 files changed, 1026 insertions(+) create mode 100644 docs/structures/attempt.md create mode 100644 proofs/attempt.php create mode 100644 src/Attempt.php create mode 100644 src/Attempt/Defer.php create mode 100644 src/Attempt/Error.php create mode 100644 src/Attempt/Implementation.php create mode 100644 src/Attempt/Result.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e297c..6f364df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `Innmind\Immutable\Sequence::sink()` +- `Innmind\Immutable\Attempt` ### Fixed diff --git a/docs/structures/attempt.md b/docs/structures/attempt.md new file mode 100644 index 0000000..365d576 --- /dev/null +++ b/docs/structures/attempt.md @@ -0,0 +1,186 @@ +# `Attempt` + +This structures is similar to [`Either`](either.md) but where the left value is necessarily an instance of `\Throwable`. + +Its main use is as a return type of any function that would normally throw an exception. Instead of throwing and let the exception bubble up the call stack, it's caught in the structure and forces you to deal with this exception at some point. + +Unlike an `Either` the error type can't be more precise than `\Throwable`. + +`Attempt` is intended to be used as a return type where the call may fail but you can't know in advance all the possible failing scenarii. This is the case for interfaces where the kind of error will depend on the implementation details. + +If you already know all the possible failing scenarii you should use an `Either` instead. + +??? note + In other languages this monad is called `Try`. But this is a reserved keyword in PHP, hence the name `Attempt`. + +## `::error()` + +This builds an `Attempt` that failed with the given exception: + +```php +$attempt = Attempt::error(new \Exception); +``` + +!!! note "" + You will rarely use this method directly. + +## `::result()` + +This builds an `Attempt` that succeeded with the given value: + +```php +$attempt = Attempt::result($anyValue); +``` + +!!! note "" + You will rarely use this method directly. + +## `::of()` + +This builds an `Attempt` that will immediately call the callable and catch any exception: + +```php +$attempt = Attempt::of(static function() { + if (/* some condition */) { + throw new \Exception; + } + + return $anyValue; +}); +``` + +This is the equivalent of: + +```php +$doStuff = static function() { + if (/* some condition */) { + return Attempt::error(new \Exception); + } + + return Attempt::result($anyValue); +}; +$attempt = $doStuff(); +``` + +!!! success "" + This is very useful to wrap any third party code to a monadic style. + +## `::defer()` + +This builds an `Attempt` where the callable passed will be called only when [`->memoize()`](#-memoize) or [`->match()`](#-match) is called. + +```php +$attempt = Attempt::defer(static fn() => Attempt::of(doStuff(...))); +// doStuff has not been called yet +$attempt->memoize(); +// doStuff has been called +``` + +The main use case is for IO operations. + +## `->map()` + +This will apply the map transformation on the result if no previous error occured. + +=== "Result" + ```php + $attempt = Attempt::of(static fn() => 1/2) + ->map(static fn(int $i) => $i*2); + ``` + + Here `#!php $attempt` contains `1`; + +=== "Error" + ```php + $attempt = Attempt::of(static fn() => 1/0) + ->map(static fn(int $i) => $i*2); + ``` + + Here `#!php $attempt` contains a `DivisionByZeroError` and the callable passed to `map` has not been called. + +## `->flatMap()` + +This is similar to `#!php ->map()` except the callable passed to it must return an `Attempt` indicating that it may fail. + +```php +$attempt = Attempt::result(2 - $reduction) + ->flatMap(static fn(int $divisor) => Attempt::of( + static fn() => 42 / $divisor, + )); +``` + +If `#!php $reduction` is `#!php 2` then `#!php $attempt` will contain a `DivisionByZeroError` otherwise for any other value it will contain a fraction of `#!php 42`. + +## `->match()` + +This extracts the result value but also forces you to deal with any potential error. + +```php +$result = Attempt::of(static fn() => 2 / $reduction)->match( + static fn($fraction) => $fraction, + static fn(\Throwable $e) => $e, +); +``` + +If `#!php $reduction` is `#!php 0` then `#!php $result` will be an instance of `DivisionByZeroError`, otherwise it will be a fraction of `#!php 2`. + +## `->recover()` + +This will allow you to recover in case of a previous error. + +```php +$attempt = Attempt::of(static fn() => 1/0) + ->recover(static fn(\Throwable $e) => Attempt::result(42)); +``` + +Here `#!php $attempt` is `#!php 42` because the first `Attempt` raised a `DivisionByZeroError`. + +## `->maybe()` + +This converts an `Attempt` to a `Maybe`. + +=== "Result" + ```php + Attempt::result($value)->maybe(); + // is the same as + Maybe::just($value); + ``` + +=== "Error" + ```php + Attempt::error(new \Exception)->maybe() + // is the same as + Maybe::nothing(); + ``` + +## `->either()` + +This converts an `Attempt` to a `Either`. + +=== "Result" + ```php + Attempt::result($value)->either(); + // is the same as + Either::right($value); + ``` + +=== "Error" + ```php + Attempt::error(new \Exception)->either() + // is the same as + Either::left(new \Exception); + ``` + +## `->memoize()` + +This method force to load the contained value into memory. This is only useful for a deferred `Attempt`, this will do nothing for other attempts as the value is already known. + +```php +Attempt::defer(static fn() => Attempt::result(\rand())) + ->map(static fn($i) => $i * 2) // value still not loaded here + ->memoize() // call the rand function and then apply the map and store it in memory + ->match( + static fn($i) => doStuff($i), + static fn() => null, + ); +``` diff --git a/docs/structures/index.md b/docs/structures/index.md index 6cbe744..848ed5a 100644 --- a/docs/structures/index.md +++ b/docs/structures/index.md @@ -9,6 +9,7 @@ This library provides the following structures: - [`RegExp`](regexp.md) - [`Maybe`](maybe.md) - [`Either`](either.md) +- [`Attempt`](attempt.md) - [`Validation`](validation.md) - [`Identity`](identity.md) - [`State`](state.md) diff --git a/mkdocs.yml b/mkdocs.yml index 10793b5..4353935 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - RegExp: structures/regexp.md - Maybe: structures/maybe.md - Either: structures/either.md + - Attempt: structures/attempt.md - Validation: structures/validation.md - Identity: structures/identity.md - State: structures/state.md diff --git a/proofs/attempt.php b/proofs/attempt.php new file mode 100644 index 0000000..8db39ff --- /dev/null +++ b/proofs/attempt.php @@ -0,0 +1,343 @@ + throw $e); + + $assert->same( + $e, + $attempt->match( + static fn() => null, + static fn($e) => $e, + ), + ); + }, + ); + + yield proof( + 'Attempt::map()', + given( + Set\Type::any(), + Set\Type::any(), + $exceptions, + ), + static function($assert, $start, $end, $e) { + $attempt = Attempt::result($start) + ->map(static function($value) use ($assert, $start, $end) { + $assert->same($start, $value); + + return $end; + }); + + $assert->same( + $end, + $attempt->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $attempt = Attempt::error($e) + ->map(static fn() => $end); + + $assert->same( + $e, + $attempt->match( + static fn() => null, + static fn($value) => $value, + ), + ); + }, + ); + + yield proof( + 'Attempt::flatMap()', + given( + Set\Type::any(), + Set\Type::any(), + $exceptions, + ), + static function($assert, $start, $end, $e) { + $attempt = Attempt::result($start) + ->flatMap(static function($value) use ($assert, $start, $end) { + $assert->same($start, $value); + + return Attempt::result($end); + }); + + $assert->same( + $end, + $attempt->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $attempt = Attempt::result($start) + ->flatMap(static function($value) use ($assert, $start, $e) { + $assert->same($start, $value); + + return Attempt::error($e); + }); + + $assert->same( + $e, + $attempt->match( + static fn() => null, + static fn($value) => $value, + ), + ); + + $attempt = Attempt::error($e) + ->flatMap(static fn() => Attempt::result($end)); + + $assert->same( + $e, + $attempt->match( + static fn() => null, + static fn($value) => $value, + ), + ); + }, + ); + + yield proof( + 'Attempt::recover()', + given( + $exceptions, + $exceptions, + Set\Type::any(), + ), + static function($assert, $start, $end, $value) { + $attempt = Attempt::error($start) + ->recover(static function($e) use ($assert, $start, $end) { + $assert->same($start, $e); + + return Attempt::error($end); + }); + + $assert->same( + $end, + $attempt->match( + static fn() => null, + static fn($value) => $value, + ), + ); + + $attempt = Attempt::error($start) + ->recover(static function($e) use ($assert, $start, $value) { + $assert->same($start, $e); + + return Attempt::result($value); + }); + + $assert->same( + $value, + $attempt->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $attempt = Attempt::result($value) + ->recover(static fn() => Attempt::error($end)); + + $assert->same( + $value, + $attempt->match( + static fn($value) => $value, + static fn() => null, + ), + ); + }, + ); + + yield proof( + 'Attempt::maybe()', + given( + Set\Type::any(), + $exceptions, + ), + static function($assert, $result, $e) { + $assert->same( + $result, + Attempt::result($result) + ->maybe() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->true( + Attempt::error($e) + ->maybe() + ->match( + static fn() => false, + static fn() => true, + ), + ); + }, + ); + + yield proof( + 'Attempt::either()', + given( + Set\Type::any(), + $exceptions, + ), + static function($assert, $result, $e) { + $assert->same( + $result, + Attempt::result($result) + ->either() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + $e, + Attempt::error($e) + ->either() + ->match( + static fn() => null, + static fn($value) => $value, + ), + ); + }, + ); + + yield proof( + 'Attempt::memoize()', + given( + Set\Type::any(), + $exceptions, + ), + static function($assert, $result, $e) { + $assert->same( + $result, + Attempt::result($result) + ->memoize() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + $e, + Attempt::error($e) + ->memoize() + ->match( + static fn() => null, + static fn($value) => $value, + ), + ); + + $called = 0; + $attempt = Attempt::defer(static function() use ($result, &$called) { + ++$called; + + return Attempt::result($result); + }); + + $assert->same(0, $called); + $assert->same( + $result, + $attempt + ->memoize() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $attempt->memoize(); + $assert->same(1, $called); + + $called = 0; + $attempt = Attempt::defer(static function() use ($e, &$called) { + ++$called; + + return Attempt::error($e); + }); + + $assert->same(0, $called); + $assert->same( + $e, + $attempt + ->memoize() + ->match( + static fn() => null, + static fn($value) => $value, + ), + ); + $attempt->memoize(); + $assert->same(1, $called); + }, + ); + + yield proof( + 'Attempt::defer()', + given( + Set\Type::any(), + Set\Type::any(), + $exceptions, + $exceptions, + ), + static function($assert, $result1, $result2, $e1, $e2) { + $loaded = false; + $attempt = Attempt::defer(static function() use ($result1, &$loaded) { + $loaded = true; + + return Attempt::result($result1); + }) + ->map(static fn() => $result2) + ->flatMap(static fn() => Attempt::error($e1)) + ->recover(static fn() => Attempt::error($e2)); + + $assert->false($loaded); + $attempt->maybe(); + $assert->false($loaded); + $attempt->either(); + $assert->false($loaded); + + $attempt->memoize(); + $assert->true($loaded); + + $assert->same( + $e2, + $attempt->match( + static fn() => null, + static fn($value) => $value, + ), + ); + $assert->false( + $attempt->maybe()->match( + static fn() => true, + static fn() => false, + ), + ); + $assert->same( + $e2, + $attempt->either()->match( + static fn() => null, + static fn($value) => $value, + ), + ); + }, + ); +}; diff --git a/src/Attempt.php b/src/Attempt.php new file mode 100644 index 0000000..76aa812 --- /dev/null +++ b/src/Attempt.php @@ -0,0 +1,161 @@ + */ + private Implementation $implementation; + + /** + * @param Implementation $implementation + */ + private function __construct(Implementation $implementation) + { + $this->implementation = $implementation; + } + + /** + * @template U + * @psalm-pure + * + * @return self + */ + public static function error(\Throwable $error): self + { + return new self(new Error($error)); + } + + /** + * @template U + * @psalm-pure + * + * @param U $value + * + * @return self + */ + public static function result(mixed $value): self + { + return new self(new Result($value)); + } + + /** + * This method is to be used for IO operations + * + * @template U + * @psalm-pure + * + * @param callable(): self $deferred + * + * @return self + */ + public static function defer(callable $deferred): self + { + return new self(new Defer($deferred)); + } + + /** + * @template U + * @psalm-pure + * + * @param callable(): U $try + * + * @return self + */ + public static function of(callable $try): self + { + try { + /** @psalm-suppress ImpureFunctionCall */ + return self::result($try()); + } catch (\Throwable $e) { + return self::error($e); + } + } + + /** + * @template U + * + * @param callable(T): U $map + * + * @return self + */ + public function map(callable $map): self + { + return new self($this->implementation->map($map)); + } + + /** + * @template U + * + * @param callable(T): self $map + * + * @return self + */ + public function flatMap(callable $map): self + { + return $this->implementation->flatMap($map); + } + + /** + * @template U + * + * @param callable(T): U $result + * @param callable(\Throwable): U $error + * + * @return U + */ + public function match(callable $result, callable $error) + { + return $this->implementation->match($result, $error); + } + + /** + * @template U + * + * @param callable(\Throwable): self $recover + * + * @return self + */ + public function recover(callable $recover): self + { + return $this->implementation->recover($recover); + } + + /** + * @return Maybe + */ + public function maybe(): Maybe + { + return $this->implementation->maybe(); + } + + /** + * @return Either<\Throwable, T> + */ + public function either(): Either + { + return $this->implementation->either(); + } + + /** + * Force loading the value in memory (only useful for a deferred Attempt) + * + * @return self + */ + public function memoize(): self + { + return $this->implementation->memoize(); + } +} diff --git a/src/Attempt/Defer.php b/src/Attempt/Defer.php new file mode 100644 index 0000000..d883ab3 --- /dev/null +++ b/src/Attempt/Defer.php @@ -0,0 +1,123 @@ + + * @psalm-immutable + * @internal + */ +final class Defer implements Implementation +{ + /** @var callable(): Attempt */ + private $deferred; + /** @var ?Attempt */ + private ?Attempt $value = null; + + /** + * @param callable(): Attempt $deferred + */ + public function __construct(callable $deferred) + { + $this->deferred = $deferred; + } + + public function map(callable $map): self + { + $captured = $this->capture(); + + return new self(static fn() => self::detonate($captured)->map($map)); + } + + public function flatMap(callable $map): Attempt + { + $captured = $this->capture(); + + return Attempt::defer(static fn() => self::detonate($captured)->flatMap($map)); + } + + public function match(callable $result, callable $error) + { + return $this->unwrap()->match($result, $error); + } + + public function recover(callable $recover): Attempt + { + $captured = $this->capture(); + + return Attempt::defer(static fn() => self::detonate($captured)->recover($recover)); + } + + public function maybe(): Maybe + { + $captured = $this->capture(); + + return Maybe::defer(static fn() => self::detonate($captured)->maybe()); + } + + public function either(): Either + { + $captured = $this->capture(); + + return Either::defer(static fn() => self::detonate($captured)->either()); + } + + /** + * @return Attempt + */ + public function memoize(): Attempt + { + return $this->unwrap(); + } + + /** + * @return Attempt + */ + private function unwrap(): Attempt + { + /** + * @psalm-suppress InaccessibleProperty + * @psalm-suppress ImpureFunctionCall + */ + return $this->value ??= ($this->deferred)()->memoize(); + } + + /** + * @return array{\WeakReference>, callable(): Attempt} + */ + private function capture(): array + { + /** @psalm-suppress ImpureMethodCall */ + return [ + \WeakReference::create($this), + $this->deferred, + ]; + } + + /** + * @template A + * + * @param array{\WeakReference>, callable(): Attempt} $captured + * + * @return Attempt + */ + private static function detonate(array $captured): Attempt + { + [$ref, $deferred] = $captured; + $self = $ref->get(); + + if (\is_null($self)) { + return $deferred(); + } + + return $self->unwrap(); + } +} diff --git a/src/Attempt/Error.php b/src/Attempt/Error.php new file mode 100644 index 0000000..d933174 --- /dev/null +++ b/src/Attempt/Error.php @@ -0,0 +1,72 @@ + + * @psalm-immutable + * @internal + */ +final class Error implements Implementation +{ + public function __construct( + private \Throwable $value, + ) { + } + + /** + * @template T + * + * @param callable(R1): T $map + * + * @return self + */ + public function map(callable $map): self + { + /** @var self */ + return $this; + } + + public function flatMap(callable $map): Attempt + { + return Attempt::error($this->value); + } + + public function match(callable $result, callable $error) + { + /** @psalm-suppress ImpureFunctionCall */ + return $error($this->value); + } + + public function recover(callable $recover): Attempt + { + /** @psalm-suppress ImpureFunctionCall */ + return $recover($this->value); + } + + public function maybe(): Maybe + { + return Maybe::nothing(); + } + + public function either(): Either + { + return Either::left($this->value); + } + + /** + * @return Attempt + */ + public function memoize(): Attempt + { + return Attempt::error($this->value); + } +} diff --git a/src/Attempt/Implementation.php b/src/Attempt/Implementation.php new file mode 100644 index 0000000..0afc68d --- /dev/null +++ b/src/Attempt/Implementation.php @@ -0,0 +1,70 @@ + + */ + public function map(callable $map): self; + + /** + * @template U + * + * @param callable(T): Attempt $map + * + * @return Attempt + */ + public function flatMap(callable $map): Attempt; + + /** + * @template U + * + * @param callable(T): U $result + * @param callable(\Throwable): U $error + * + * @return U + */ + public function match(callable $result, callable $error); + + /** + * @template U + * + * @param callable(\Throwable): Attempt $recover + * + * @return Attempt + */ + public function recover(callable $recover): Attempt; + + /** + * @return Maybe + */ + public function maybe(): Maybe; + + /** + * @return Either<\Throwable, T> + */ + public function either(): Either; + + /** + * @return Attempt + */ + public function memoize(): Attempt; +} diff --git a/src/Attempt/Result.php b/src/Attempt/Result.php new file mode 100644 index 0000000..a561f27 --- /dev/null +++ b/src/Attempt/Result.php @@ -0,0 +1,68 @@ + + * @psalm-immutable + * @internal + */ +final class Result implements Implementation +{ + /** + * @param R1 $value + */ + public function __construct( + private mixed $value, + ) { + } + + public function map(callable $map): self + { + /** @psalm-suppress ImpureFunctionCall */ + return new self($map($this->value)); + } + + public function flatMap(callable $map): Attempt + { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this->value); + } + + public function match(callable $result, callable $error) + { + /** @psalm-suppress ImpureFunctionCall */ + return $result($this->value); + } + + public function recover(callable $recover): Attempt + { + return Attempt::result($this->value); + } + + public function maybe(): Maybe + { + return Maybe::just($this->value); + } + + public function either(): Either + { + return Either::right($this->value); + } + + /** + * @return Attempt + */ + public function memoize(): Attempt + { + return Attempt::result($this->value); + } +} From c12914376ce88ab7c2a78d333800fec2fadb8e3a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 1 Dec 2024 17:26:53 +0100 Subject: [PATCH 4/6] add Attempt::unwrap() --- docs/structures/attempt.md | 11 +++++++++++ proofs/attempt.php | 19 +++++++++++++++++++ src/Attempt.php | 15 +++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/docs/structures/attempt.md b/docs/structures/attempt.md index 365d576..32180ed 100644 --- a/docs/structures/attempt.md +++ b/docs/structures/attempt.md @@ -184,3 +184,14 @@ Attempt::defer(static fn() => Attempt::result(\rand())) static fn() => null, ); ``` + +## `->unwrap()` + +This will return the result or throw any previous error. + +```php +$result = Attempt::of(static fn() => 1 / $divisor) + ->unwrap(); +``` + +Here `#!php $result` is necessarily a fraction of `#!php 1` but this code may raise the `DivisionByZeroError` exception. diff --git a/proofs/attempt.php b/proofs/attempt.php index 8db39ff..6185e27 100644 --- a/proofs/attempt.php +++ b/proofs/attempt.php @@ -340,4 +340,23 @@ static function($assert, $result1, $result2, $e1, $e2) { ); }, ); + + yield proof( + 'Attempt::unwrap()', + given( + Set\Type::any(), + $exceptions, + ), + static function($assert, $result, $e) { + $assert->same( + $result, + Attempt::result($result)->unwrap(), + ); + + $assert->throws( + static fn() => Attempt::error($e)->unwrap(), + $e::class, + ); + }, + ); }; diff --git a/src/Attempt.php b/src/Attempt.php index 76aa812..018648d 100644 --- a/src/Attempt.php +++ b/src/Attempt.php @@ -121,6 +121,21 @@ public function match(callable $result, callable $error) return $this->implementation->match($result, $error); } + /** + * Be aware that this call is not safe as it may throw an exception. + * + * @throws \Throwable + * + * @return T + */ + public function unwrap(): mixed + { + return $this->match( + static fn($value) => $value, + static fn($e) => throw $e, + ); + } + /** * @template U * From af3d4a5d4474776dede1a3ea3ddbb4469461ebf1 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 1 Dec 2024 17:29:28 +0100 Subject: [PATCH 5/6] fix psalm errors --- src/Attempt.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Attempt.php b/src/Attempt.php index 018648d..0d25da8 100644 --- a/src/Attempt.php +++ b/src/Attempt.php @@ -130,8 +130,9 @@ public function match(callable $result, callable $error) */ public function unwrap(): mixed { + /** @var T */ return $this->match( - static fn($value) => $value, + static fn(mixed $value): mixed => $value, static fn($e) => throw $e, ); } From ea05f5e796fbc94be21ef1761c70705ca132a634 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 1 Dec 2024 17:41:36 +0100 Subject: [PATCH 6/6] specify next release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f364df..4a30b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## 5.11.0 - 2024-12-01 ### Added