diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25474b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [2.0.0] + +### Added + +- `compose()` function +- `curry()` function +- `flip()` function +- `id()` function +- `if_else()` function +- `k()` (Kestrel) function +- `on()` function + +### Removed + +- `Combinators` class has been removed in favor of function constructors + +## [1.0.0] + +- Initial release diff --git a/README.md b/README.md index 850b9f9..edc9e4a 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,59 @@ Combinators are simple functions that allow you to modify the behaviour of value These combinators aren't fully curried by default - mainly for optimisation reasons - but are designed so that most common use cases can be satisfied as is. Consequently, the type signatures use the comma (`,`) to represent multiple arguments. +### `curry :: a -> b -> c` + +Transform a callable that takes multiple parameters into a callable that takes one parameter and returns another function any more parameters are needed. + +```php +use function PhpFp\curry; + +$sum = curry(function ($a, $b, $c) { + return $a + $b + c; +}); + +// The following statements are all equivalent: +assert(6 === $add(1, 2, 3)); +assert(6 === $add(1)(2)(3)); +assert(6 === $add(1, 2)(3)); +assert(6 === $add(1)(2, 3)); +``` + +Note that currying ignores optional parameters. To curry optional parameters, set the arity: + +```php +use function PhpFp\curry; + +$concat = function ($a, $b, $join = '') { + return $a . $join . $b; +}; +$concat = curry($concat, 3); + +assert('a:b' === $concat('a', 'b', ':')); +``` + + ### `compose :: (b -> c), (a -> b) -> a -> c` Instead of writing `function ($x) { return f(g(x)); }`, `compose` allows us to express this as `compose('f', 'g')` (where the parameters could be closures, invokables, or anything that can be used as a "function"). Given two functions, `f` and `g`, a function will be returned that takes a value, `x`, and returns `f(g(x))`. This operation is _associative_, so `compose` calls can nest to create longer chains of functions. A simple, two-function example is shown here: ```php - c) -> (b, a) -> c` @@ -38,14 +68,12 @@ $pipe($f, $g)($x) === $g($f($x)); This function takes a two-argument function, and returns the same function with the arguments swapped round: ```php - Bool), (a -> b), (a -> b) -> a -> b` @@ -68,33 +94,31 @@ F::id('hello'); // hello This function allows for conditionals in composition chains. Unlike `converge`, which branches and merges, `ifElse` chooses which function to run based on the predicate, and the other function is ignored: ```php - b -> a` +### `k :: a -> b -> a` This function takes a value, and then returns a function that always returns that value, regardless of what it receives. This creates a "constant" function to wrap a value in places where invocation is expected: ```php - c), (a -> b) -> a, a -> c` @@ -102,7 +126,7 @@ $f(2); // '2 is even!' This function, also called the `Psi` combinator, allows you to call a function on transformations of values. This is really useful for things like sorting on particular properties of objects: we can call a compare function on two objects given a transformation. It's probably best illustrated with an example: ```php - 'something', 'test' => 4] ]; -array_column($sort($test, $f), 'title'); // ['goodbye', 'hello', 'something'] +assert(['goodbye', 'hello', 'something'] === $sort($test, $f), 'title'); ``` ## Contributing diff --git a/composer.json b/composer.json index 1d57a3c..9fbc830 100644 --- a/composer.json +++ b/composer.json @@ -3,10 +3,20 @@ { "name": "Tom", "email": "tomjharding@live.co.uk" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com", + "role": "Contributor" } ], "autoload": { - "files": ["src/Combinators.php"] + "psr-4": { + "PhpFp\\Combinators\\": "src/" + }, + "files": [ + "src/functions.php" + ] }, "autoload-dev": { "psr-4": { @@ -27,6 +37,6 @@ "phpunit/phpunit": "^5.7" }, "require": { - "cypresslab/php-curry": "^0.4.0" + "php": ">=7.0" } } diff --git a/src/Combinators.php b/src/Combinators.php deleted file mode 100644 index fc19341..0000000 --- a/src/Combinators.php +++ /dev/null @@ -1,84 +0,0 @@ -fn = $fn; + $this->arity = $arity ?? $this->numberOfParameters($fn); + $this->args = []; + } + + /** + * Apply the curry with some parameters. + */ + public function __invoke(...$args) + { + $args = array_merge($this->args, $args); + + if (count($args) >= $this->arity) { + // All parameters have been defined, execute the function + return call_user_func_array($this->fn, $args); + } + + // Additional parameters are needed, return another curry + $copy = clone $this; + $copy->args = $args; + + return $copy; + } + + private function reflect(callable $fn): Reflector + { + if (is_array($fn)) { + return new ReflectionMethod($fn[0], $fn[1]); + } + + if (is_string($fn) && strpos($fn, '::')) { + return new ReflectionMethod($fn); + } + + if (is_object($fn)) { + return new ReflectionMethod($fn, '__invoke'); + } + + return new ReflectionFunction($fn); + } + + private function numberOfParameters(callable $fn): int + { + return $this->reflect($fn)->getNumberOfRequiredParameters(); + } +} diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..2b30c0d --- /dev/null +++ b/src/functions.php @@ -0,0 +1,79 @@ + c), (a -> b) -> a -> c + */ +function compose(callable $f, callable $g): callable +{ + return static function ($x) use ($f, $g) { + return $f($g($x)); + }; +} +define('compose', '\PhpFp\compose'); + +/** + * curry :: a -> b -> c + */ +function curry(callable $fn, int $arity = null): callable +{ + return new Curry($fn, $arity); +} +define('curry', '\PhpFp\curry'); + +/** + * flip :: (a, b -> c) -> (b, a) -> c + */ +function flip(callable $f): callable +{ + return static function ($x, $y) use ($f) { + return $f($y, $x); + }; +} +define('flip', '\PhpFp\flip'); + +/** + * id :: a -> a + */ +function id($x) +{ + return $x; +} +define('id', '\PhpFp\id'); + +/** + * if_else :: (a -> Bool), (a -> b), (a -> b) -> a -> b + */ +function if_else(callable $p, callable $f, callable $g): callable +{ + return function ($x) use ($p, $f, $g) { + return $p($x) ? $f($x) : $g($x); + }; +} +define('if_else', '\PhpFp\if_else'); + +/** + * k :: a -> b -> a + */ +function k($x): callable +{ + return static function () use ($x) { + return $x; + }; +} +define('k', '\PhpFp\k'); + +/** + * on :: (b, b -> c), (a -> b) -> a, a -> c + */ +function on(callable $f, callable $nt): callable +{ + return static function ($x, $y) use ($f, $nt) { + return $f($nt($x), $nt($y)); + }; +} +define('on', '\PhpFp\on'); diff --git a/test/ComposeTest.php b/test/ComposeTest.php index 3990847..ec3fac0 100644 --- a/test/ComposeTest.php +++ b/test/ComposeTest.php @@ -2,7 +2,7 @@ namespace PhpFp\Combinators\Test; -use PhpFp\Combinators; +use function PhpFp\compose; class ComposeTest extends \PHPUnit_Framework_TestCase { @@ -18,7 +18,7 @@ public function testCompose() return $x / 2; }; - $f = Combinators::compose($halve, $inc); + $f = compose($halve, $inc); $this->assertEquals( $f(3), diff --git a/test/CurryTest.php b/test/CurryTest.php new file mode 100644 index 0000000..f759025 --- /dev/null +++ b/test/CurryTest.php @@ -0,0 +1,61 @@ +assertSame(6, $sum(1, 2, 3)); + $this->assertSame(6, $sum(1, 2)(3)); + $this->assertSame(6, $sum(1)(2, 3)); + $this->assertSame(6, $sum(1)(2)(3)); + + $concat = curry(static function ($a, $b, $join = '') { + return $a . $join . $b; + }, 3); + + $this->assertSame( + 'a:b', + $concat('a', 'b')(':'), + 'It curries exactly with optional arguments.' + ); + + $this->assertSame( + [1, 2], + curry('array_merge')([1], [2]), + 'It curries native functions.' + ); + + $this->assertSame( + 'Hello, world!', + curry([$this, 'say'])('world'), + 'It curries object methods.' + ); + + $this->assertSame( + 'Goodbye, test!', + curry(sprintf('%s::%s', self::class, 'bye'))('test'), + 'It curries static methods.' + ); + } + + public function say($name) + { + return "Hello, $name!"; + } + + public static function bye($name) + { + return "Goodbye, $name!"; + } +} diff --git a/test/FlipTest.php b/test/FlipTest.php index 43dae88..08709ae 100644 --- a/test/FlipTest.php +++ b/test/FlipTest.php @@ -2,7 +2,9 @@ namespace PhpFp\Combinators\Test; -use PhpFp\Combinators; +use function PhpFp\compose; +use function PhpFp\curry; +use function PhpFp\flip; class FlipTest extends \PHPUnit_Framework_TestCase { @@ -13,12 +15,22 @@ public function testFlip() return $x - $y; }; - $subtractFrom = Combinators::flip($subtract); + $subtractFrom = flip($subtract); $this->assertEquals( $subtractFrom(2, 9), 7, 'Flips.' ); + + } + + public function testFlipCompose() + { + $ucstrict = compose('ucfirst', 'strtolower'); + $lcstrict = flip(curry(compose))('ucfirst', 'strtolower'); + + $this->assertSame('Abc', $ucstrict('ABC')); + $this->assertSame('abc', $lcstrict('ABC')); } } diff --git a/test/IdTest.php b/test/IdTest.php index bde71eb..fbe01f8 100644 --- a/test/IdTest.php +++ b/test/IdTest.php @@ -2,7 +2,7 @@ namespace PhpFp\Combinators\Test; -use PhpFp\Combinators; +use function PhpFp\id; class IdTest extends \PHPUnit_Framework_TestCase { @@ -10,7 +10,7 @@ public function testId() { $this->assertEquals( 2, - Combinators::id(2), + id(2), 'Identity.' ); } diff --git a/test/IfElseTest.php b/test/IfElseTest.php index fdd531d..905de72 100644 --- a/test/IfElseTest.php +++ b/test/IfElseTest.php @@ -2,17 +2,25 @@ namespace PhpFp\Combinators\Test; -use PhpFp\Combinators; +use function PhpFp\if_else; +use function PhpFp\k; class IfElseTest extends \PHPUnit_Framework_TestCase { public function testIfElse() { $isOdd = function ($x) { return $x % 2 === 1; }; + $odd = function ($x) { return $x * 3 + 1; }; + $even = function ($x) { return $x / 2; }; - $f = Combinators::ifElse( + $collatz = if_else($isOdd, $odd, $even); + + $this->assertSame(10, $collatz(3)); + $this->assertSame(1, $collatz(2)); + + $f = if_else( $isOdd, - Combinators::K('Oops!'), + k('Oops!'), function ($x) { return "$x is even!"; } diff --git a/test/KTest.php b/test/KTest.php index 21b2d39..91fb005 100644 --- a/test/KTest.php +++ b/test/KTest.php @@ -2,13 +2,13 @@ namespace PhpFp\Combinators\Test; -use PhpFp\Combinators; +use function PhpFp\k; class KTest extends \PHPUnit_Framework_TestCase { public function testK() { - $f = Combinators::K(3); + $f = k(3); $this->assertEquals( $f(2), diff --git a/test/OnTest.php b/test/OnTest.php index bf60931..76abdaa 100644 --- a/test/OnTest.php +++ b/test/OnTest.php @@ -2,7 +2,7 @@ namespace PhpFp\Combinators\Test; -use PhpFp\Combinators; +use function PhpFp\on; class OnTest extends \PHPUnit_Framework_TestCase { @@ -19,7 +19,7 @@ public function testOn() $sort = function ($xs, $f) use ($prop) { $ys = array_slice($xs, 0); - usort($ys, Combinators::on($f, $prop('test'))); + usort($ys, on($f, $prop('test'))); return $ys; };