From 9069086995d4521537f5090bc2708285428da928 Mon Sep 17 00:00:00 2001 From: Matt Pryor Date: Mon, 2 Feb 2015 14:29:13 +0000 Subject: [PATCH] Initial commit --- .gitignore | 11 ++++ LICENCE.txt | 21 +++++++ README.md | 155 ++++++++++++++++++++++++++++++++++++++++++++++ composer.json | 27 +++++++++ src/Option.php | 132 ++++++++++++++++++++++++++++++++++++++++ src/Result.php | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 508 insertions(+) create mode 100644 .gitignore create mode 100644 LICENCE.txt create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Option.php create mode 100644 src/Result.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c97ecb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Zed editor +.zedstate + +# Composer +composer.lock +composer.phar +vendor/ + +# Test files +index.php +main.php diff --git a/LICENCE.txt b/LICENCE.txt new file mode 100644 index 0000000..d1f8140 --- /dev/null +++ b/LICENCE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Matt Pryor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da14fe9 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# mkjpryor/option # + +Simple option and result classes for PHP, inspired by Scala's Option, Haskell's Maybe and Rust's Result. + +The Option type provides additional functionality over nullable types for operating on values that may or may not be present. + +The Result type also includes a reason for the failure (an exception), and allows errors to be propagated without worrying about specifically handling them. + + +## Installation ## + +`mkjpryor/option` can be installed via [Composer](https://getcomposer.org/): + +```bash +php composer.phar require mkjpryor/option dev-master +``` + + +## Usage ## + +### Creating an Option ### + +``` + { + // Some operation that might fail with an exception +}); +``` + +### Retrieving a value from an Option (or Result) ### + +The underlying value can be retrieved from an `Option` in an unsafe or safe manner (N.B. the same methods are available for retrieving a value from a `Result`): + +``` +get(); + +// Returns the Option's value if it is non-empty, 0 otherwise +$val = $opt->getOrDefault(0); + +// Returns the Option's value if it is non-empty, otherwise the result of evaluating the given function +// Useful if the default value is expensive to compute +$val = $opt->getOrElse(function() { return 0; }); + +// Return the Option's value if it is non-empty, null otherwise +$val = $opt->getOrNull(); +``` + +### Manipulating options and results ### + +`Option`s and `Result`s have several methods for manipulating them in an 'empty-safe' manner, e.g. `map`, `filter`. See the code for more details. + + +## Examples ## + +In the following example, we want to retrieve a user by id from the database and welcome them. If the user does not exist, we want to welcome them as a guest. + +``` +id = $id; + $this->username = $username; + } +} + +/** + * Fetches a user from a database by their id + * + * Note how we return a Result that may contain an Option + * This is because there are three possible outcomes that are semantically different: + * 1. We successfully find a user + * 2. The user doesn't exist in the database (this isn't an error - it is expected and must be handled) + * 3. There is an error querying the database + */ +function findUserById($id) { + // Assume DB::execute throws a DBError if there is an error while querying + $result = Result::try_(function() use($id) { + return DB::execute("SELECT * FROM users WHERE id = ?", $id); + }); + + // Use the error propagation to our advantage + return $result->map(function($data) { + if( count($data) > 0 ) { + return Option::just(new User($data[0]["id"], $data[0]["username"])); + } + else { + return Option::none(); + } + }); +} + +$id = 1234; // This would come from request params or something similar + +// Print an appropriate welcome message for the user +echo "Hello, " . findUserById($id) + // In our circumstances, a DB error is like not finding a user + ->orElse(function($e) { + return Result::success(Option::none()); + }) + // Get the username from the optional user + ->map(function($o) { + return $o->map(function($u) { return $u->username; }) + ->getOrDefault("Guest") + }) + // We can safely use get since we know it won't be an error + ->get(); +``` + +## License ## + +This code is licensed under the terms of the MIT licence. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3e319e7 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name" : "mkjpryor/option", + "type" : "library", + "description" : "Simple option and result types for PHP", + "homepage" : "https://github.com/mkjpryor/option", + "authors" : [ + { + "name" : "Matt Pryor", + "email" : "matt@pryor.org.uk", + "role" : "Developer" + } + ], + "license" : "MIT", + + "require" : { + }, + + "require-dev" : { + "phpunit/phpunit" : "4.*" + }, + + "autoload" : { + "psr-4" : { + "Mkjp\\Option\\" : "src" + } + } +} diff --git a/src/Option.php b/src/Option.php new file mode 100644 index 0000000..1529367 --- /dev/null +++ b/src/Option.php @@ -0,0 +1,132 @@ +value = $value; + } + + /** + * Returns the result of applying $f to the value of this option if it is non-empty + * + * $f should take the wrapped value and return another option + */ + public function andThen(callable $f) { + return !$this->isEmpty() ? $f($this->get()) : Option::none(); + } + + /** + * Returns this option if it is non-empty and $p returns true + * + * $p should take the wrapped value and return a boolean + */ + public function filter(callable $p) { + return !$this->isEmpty() && $p($this->get()) ? $this : Option::none(); + } + + /** + * Returns the option's value + * + * If the option is empty, a \LogicException is thrown + * + * @throws \LogicException + */ + public function get() { + if( $this->value === null ) + throw new \LogicException("Cannot get value from empty option"); + + return $this->value; + } + + /** + * Returns an iterator for this option + * + * If this option is non-empty, the iterator will produce the single element + * If this option is empty, it will be an empty iterator + */ + public function getIterator() { + if( !$this->isEmpty() ) yield $this->get(); + } + + /** + * Returns the option's value if it is non-empty, $default otherwise + */ + public function getOrDefault($default) { + return $this->isEmpty() ? $default : $this->get(); + } + + /** + * Returns the option's value if it is non-empty, otherwise the result of evaluating + * $f + * + * $f should take no arguments and return a value + */ + public function getOrElse(callable $f) { + return $this->isEmpty() ? $f() : $this->get(); + } + + /** + * Returns the option's value if it is non-empty, null otherwise + */ + public function getOrNull() { + return $this->isEmpty() ? null : $this->get(); + } + + /** + * Returns true if this option is empty, false otherwise + */ + public function isEmpty() { + return $this->value === null; + } + + /** + * Returns an option containing the result of applying $f to this option's value + * if it is non-empty + * + * $f should take the wrapped value and return a new value + */ + public function map(callable $f) { + return !$this->isEmpty() ? Option::just($f($this->get())) : Option::none(); + } + + /** + * Returns this option if it is non-empty, otherwise the given option is returned + */ + public function orElse(Option $other) { + return !$this->isEmpty() ? $this : $other; + } + + /** + * Get an option from the given, possibly null, value + */ + public static function from($x) { + return new Option($x); + } + + /** + * Get an option containing the given, non-null value + */ + public static function just($x) { + if( $x === null ) + throw new \LogicException("Cannot create just from null value"); + + return new Option($x); + } + + /** + * Get an empty option + */ + public static function none() { + return new Option(null); + } +} diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..4701d1b --- /dev/null +++ b/src/Result.php @@ -0,0 +1,162 @@ +value = $value; + $this->error = $error; + } + + /** + * Returns the result of applying $f to the value of this result if it is successful + * + * $f should take the wrapped value and return a result + */ + public function andThen(callable $f) { + if( $this->value !== null ) return $f($this->get()); + if( $this->error !== null ) return Result::error($this->error); + throw new \LogicException("Value and error cannot both be null"); + } + + /** + * Returns the value of the result if it is a success + * + * If it is an error, the exception it was created with is thrown + */ + public function get() { + if( $this->value !== null ) return $this->value; + if( $this->error !== null ) throw $this->error; + throw new \LogicException("Value and error cannot both be null"); + } + + /** + * Returns the error if the result is an error + * + * If the result is not an error, a \LogicException is thrown + */ + public function getError() { + if( $this->error === null ) + throw new \LogicException("Tried to retrieve error from a success"); + return $this->error; + } + + /** + * Returns an iterator for this result + * + * If this result is a success, the iterator will produce the single value + * If this result is an error, it will be an empty iterator + */ + public function getIterator() { + if( $this->value !== null ) yield $this->value; + } + + /** + * Returns the result's value if it is a success, $default otherwise + */ + public function getOrDefault($default) { + return $this->value !== null ? $this->value : $default; + } + + /** + * Returns the result's value if it is a success, otherwise the result of evaluating + * $f with the error + * + * $f should take an exception and return a value + */ + public function getOrElse(callable $f) { + if( $this->value !== null ) return $this->value; + if( $this->error !== null ) return $f($this->error); + throw new \LogicException("Value and error cannot both be null"); + } + + /** + * Returns the result's value if it is a success, null otherwise + */ + public function getOrNull() { + return $this->value; + } + + /** + * Returns true if this result represents an error, false otherwise + */ + public function isError() { + return $this->error !== null; + } + + /** + * Returns true if this result represents a success, false otherwise + */ + public function isSuccess() { + return $this->value !== null; + } + + /** + * Returns a result containing the result of applying $f to this result's value + * if it is a success + * + * $f should take a value and return a new value + */ + public function map(callable $f) { + if( $this->value !== null ) return Result::success($f($this->value)); + if( $this->error !== null ) return Result::error($this->error); + throw new \LogicException("Value and error cannot both be null"); + } + + /** + * Returns this result if it is a success, else the result of evaluating $f with + * the error + * + * $f should take an exception and return a new result + */ + public function orElse(callable $f) { + return $this->error === null ? $this : $f($this->error); + } + + /** + * Get an error result containing the given, non-null exception + */ + public static function error(\Exception $error) { + return new Result(null, $error); + } + + /** + * Get a successful result containing the given, non-null value + */ + public static function success($x) { + if( $x === null ) + throw new \LogicException("Cannot create a success with null value"); + + return new Result($x, null); + } + + /** + * Tries to execute the given function and returns a result representing the + * outcome, i.e. if the function returns normally, a successful result is + * returned containing the return value and if the function throws an exception, + * an error result is returned containing the error + * + * $f should take no arguments and return a value that will be wrapped in a + * success if the computation completes successfully + * + * The _ is required if we want to use the word try since it is keyword + */ + public static function try_(callable $f) { + try { + return Result::success($f()); + } + catch( \Exception $error ) { + return Result::error($error); + } + } +}