Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
language: php
sudo: false

cache:
directories:
- $HOME/.composer/cache/files
- vendor

php:
- 7.1
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"src/Functional/True.php",
"src/Functional/Truthy.php",
"src/Functional/Unique.php",
"src/Functional/ValueToKey.php",
"src/Functional/With.php",
"src/Functional/Zip.php",
"src/Functional/ZipAll.php"
Expand Down
14 changes: 10 additions & 4 deletions docs/functional-php.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [Partial application](#partial-application)
- [Introduction](#introduction)
- [partial_left() & partial_right()](#partial_left--partial_right)
- [ary()](#ary)
- [partial_any()](#partial_any)
- [partial_method()](#partial_method)
- [converge()](#converge)
Expand Down Expand Up @@ -49,7 +50,8 @@
- [compose()](#compose)
- [tail_recursion()](#tail_recursion)
- [flip()](#flip)
- [Other](#other)
- [not](#not)
- [Other](#other-1)
- [Mathematical functions](#mathematical-functions)
- [Transformation functions](#transformation-functions)
- [partition()](#partition)
Expand All @@ -58,13 +60,14 @@
- [flatten()](#flatten)
- [reduce_left() & reduce_right()](#reduce_left--reduce_right)
- [intersperse()](#intersperse)
- [Other](#other-1)
- [Other](#other-2)
- [Conditional functions](#conditional-functions)
- [if_else()](#if_else)
- [match()](#match)
- [Higher order comparison functions](#higher-order-comparison-functions)
- [compare_on() & compare_object_hash_on()](#compare_on--compare_object_hash_on)
- [compare_on & compare_object_hash_on](#compare_on--compare_object_hash_on)
- [Miscellaneous](#miscellaneous)
- [concat()](#concat)
- [const_function()](#const_function)
- [id()](#id)
- [tap()](#tap)
Expand Down Expand Up @@ -904,9 +907,12 @@ var_dump($is_odd(2)); // false

## Other

`mixed Functional\memoize(callable $callback[, array $arguments = []], [mixed $key = null]])`
`mixed Functional\memoize(callable $callback[, array $arguments = []], [string|array $key = null]])`
Returns and stores the result of the function call. Second call to the same function will return the same result without calling the function again

`string value_to_key(...$values)`
Builds an array key out of any values, correctly handling object identity and traversables. Resources are not supported

# Mathematical functions

`mixed Functional\maximum(array|Traversable $collection)`
Expand Down
5 changes: 5 additions & 0 deletions src/Functional/Functional.php
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,11 @@ final class Functional
*/
const unique = '\Functional\unique';

/**
* @see \Functional\value_to_key
*/
const value_to_key = '\Functional\value_to_key';

/**
* @see \Functional\with
*/
Expand Down
36 changes: 9 additions & 27 deletions src/Functional/Memoize.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

namespace Functional;

use Functional\Exceptions\InvalidArgumentException;
use const E_USER_DEPRECATED;

/**
* Memoizes callbacks and returns their value instead of calling them
Expand All @@ -23,42 +23,24 @@
function memoize(callable $callback = null, $arguments = [], $key = null)
{
static $storage = [];

if ($callback === null) {
$storage = [];

return null;
}

if (\is_callable($arguments)) {
$key = $arguments;
$arguments = [];
} else {
InvalidArgumentException::assertCollection($arguments, __FUNCTION__, 2);
}

static $keyGenerator = null;
if (!$keyGenerator) {
$keyGenerator = function ($value) use (&$keyGenerator) {
$type = \gettype($value);
if ($type === 'array') {
$key = \join(':', map($value, $keyGenerator));
} elseif ($type === 'object') {
$key = \get_class($value) . ':' . \spl_object_hash($value);
} else {
$key = (string) $value;
}

return $key;
};
if (\is_callable($key)) {
\trigger_error('Passing a callable as key is deprecated and will be removed in 2.0', E_USER_DEPRECATED);
$key = $key();
} elseif (\is_callable($arguments)) {
\trigger_error('Passing a callable as key is deprecated and will be removed in 2.0', E_USER_DEPRECATED);
$key = $arguments();
}

if ($key === null) {
$key = $keyGenerator(\array_merge([$callback], $arguments));
} elseif (\is_callable($key)) {
$key = $keyGenerator($key());
$key = value_to_key(\array_merge([$callback], $arguments));
} else {
$key = $keyGenerator($key);
$key = value_to_key($key);
}

if (!isset($storage[$key]) && !\array_key_exists($key, $storage)) {
Expand Down
88 changes: 88 additions & 0 deletions src/Functional/ValueToKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

/**
* @package Functional-php
* @author Lars Strojny <lstrojny@php.net>
* @copyright 2011-2017 Lars Strojny
* @license https://opensource.org/licenses/MIT MIT
* @link https://github.com/lstrojny/functional-php
*/

namespace Functional;

use Functional\Exceptions\InvalidArgumentException;
use Traversable;
use WeakReference;

use function serialize;

use const PHP_VERSION_ID;

function value_to_key(...$any)
{
/** @var object[]|WeakReference[] $objectReferences */
static $objectReferences = [];

static $objectToRef = null;
if (!$objectToRef) {
$objectToRef = static function ($value) use (&$objectReferences) {
$hash = \spl_object_hash($value);
/**
* spl_object_hash() will return the same hash twice in a single request if an object goes out of scope
* and is destructed.
*/
if (PHP_VERSION_ID >= 70400) {
/**
* For PHP >=7.4, we keep a weak reference to the relevant object that we use for hashing. Once the
* object gets out of scope, the weak ref will no longer return the object, that’s how we know we
* have a collision and increment a version in the collisions array.
*/
/** @var int[] $collisions */
static $collisions = [];

if (isset($objectReferences[$hash])) {
if ($objectReferences[$hash]->get() === null) {
$collisions[$hash] = ($collisions[$hash] ?? 0) + 1;
$objectReferences[$hash] = WeakReference::create($value);
}
} else {
$objectReferences[$hash] = WeakReference::create($value);
}

$key = \get_class($value) . ':' . $hash . ':' . ($collisions[$hash] ?? 0);
} else {
/**
* For PHP < 7.4 we keep a static reference to the object so that cannot accidentally go out of
* scope and mess with the object hashes
*/
$objectReferences[$hash] = $value;
$key = \get_class($value) . ':' . $hash;
}
return $key;
};
}

static $valueToRef = null;
if (!$valueToRef) {
$valueToRef = static function ($value, $key = null) use (&$valueToRef, $objectToRef) {
$type = \gettype($value);
if ($type === 'array') {
$ref = '[' . \implode(':', map($value, $valueToRef)) . ']';
} elseif ($value instanceof Traversable) {
$ref = $objectToRef($value) . '[' . \implode(':', map($value, $valueToRef)) . ']';
} elseif ($type === 'object') {
$ref = $objectToRef($value);
} elseif ($type === 'resource') {
throw new InvalidArgumentException(
'Resource type cannot be used as part of a memoization key. Please pass a custom key instead'
);
} else {
$ref = \serialize($value);
}

return ($key !== null ? ($valueToRef($key) . '~') : '') . $ref;
};
}

return $valueToRef($any);
}
79 changes: 59 additions & 20 deletions tests/Functional/MemoizeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use PHPUnit_Framework_MockObject_MockObject as MockObject;
use BadMethodCallException;
use RuntimeException;

use function Functional\memoize;

Expand Down Expand Up @@ -123,7 +124,6 @@ public function testMemoizeWithCustomKey()

$this->assertSame('FOO BAR', memoize([$this->callback, 'execute'], ['FOO', 'BAR'], 'MY:CUSTOM:KEY'));
$this->assertSame('FOO BAR', memoize([$this->callback, 'execute'], ['BAR', 'BAZ'], 'MY:CUSTOM:KEY'), 'Result already memoized');
$this->assertSame('FOO BAR', memoize([$this->callback, 'execute'], ['BAR', 'BAZ'], ['MY', 'CUSTOM', 'KEY']), 'Result already memoized');

$this->assertSame('BAR BAZ', memoize([$this->callback, 'execute'], ['BAR', 'BAZ'], 'MY:DIFFERENT:KEY'));
$this->assertSame('BAR BAZ', memoize([$this->callback, 'execute'], ['BAR', 'BAZ'], 'MY:DIFFERENT:KEY'), 'Result already memoized');
Expand All @@ -150,25 +150,6 @@ public function testResultIsNotStoredIfExceptionIsThrown()
}
}

public function testPassKeyGeneratorCallable()
{
$this->callback
->expects($this->exactly(2))
->method('execute');

$keyGenerator = function () {
static $index;
return ($index++ % 2) === 0;
};

memoize([$this->callback, 'execute'], $keyGenerator);
memoize([$this->callback, 'execute'], [], $keyGenerator);
memoize([$this->callback, 'execute'], [], $keyGenerator);
memoize([$this->callback, 'execute'], $keyGenerator);
memoize([$this->callback, 'execute'], $keyGenerator);
memoize([$this->callback, 'execute'], [], $keyGenerator);
}

public function testResetByPassingNullAsCallable()
{
$this->callback
Expand All @@ -189,4 +170,62 @@ public function testPassNoCallable()
$this->expectArgumentError('Argument 1 passed to Functional\memoize() must be callable');
memoize('invalidFunction');
}

public function testSplObjectHashCollisions()
{
self::assertSame(0, memoize(self::createFn(0, 1)));
self::assertSame(1, memoize(self::createFn(1, 1)));
self::assertSame(2, memoize(self::createFn(2, 1)));
}

private static function createFn(int $id, int $number): callable
{
return new class ($id, $number) {
private $id;
private $expectedInvocations;
private $actualInvocations = 0;

public function __construct(int $id, int $expectedInvocations)
{
$this->id = $id;
$this->expectedInvocations = $expectedInvocations;
}

public function getId(): int
{
return $this->id;
}

public function __invoke(): int
{
$this->actualInvocations++;
if ($this->actualInvocations > $this->expectedInvocations) {
throw new RuntimeException(
sprintf(
'ID %d: Expected %d invocations, got %d',
$this->id,
$this->expectedInvocations,
$this->actualInvocations
)
);
}

return $this->id;
}

public function __destruct()
{
if ($this->actualInvocations !== $this->expectedInvocations) {
throw new RuntimeException(
sprintf(
'ID %d: Expected %d invocations, got %d',
$this->id,
$this->expectedInvocations,
$this->actualInvocations
)
);
}
}
};
}
}
Loading