A magic memoization function
Last friday Taylor Otwell tweeted an easy to use memoization function called once
:
Wanted a slick way to generalize class method memoization. Y'all don't even want to know how it works. ? ? pic.twitter.com/xRJAY1C14y
— Taylor Otwell (@taylorotwell) November 4, 2016
Taylor was kind enough to share the source code behind the function. Because I'd like to use it in our projects I decided to make a small package out of it. I refactored Taylor's code for readability. His original code was a bit more powerful and could handle some more edge cases out of the box.
Usage
The spatie/once package provides you with a once
function. It accepts a callable
. Here's quick example:
class MyClass
{
function getNumber()
{
return once(function () {
return rand(1, 10000);
});
}
}
No matter how many times you run (new MyClass())->getNumber()
inside the same request you'll always get the same number.
The once
function will only run once per combination of argument values the containing method receives.
class MyClass
{
public function getNumberForLetter($letter)
{
return once(function () use ($letter) {
return $letter . rand(1, 10000000);
});
}
}
So calling (new MyClass())->getNumberForLetter('A')
will always return the same result, but calling (new MyClass())->getNumberForLetter('B')
will return something else.
Behind the curtains
Let's go over the code of the once
function to learn how all this magic works. In short: it will execute the given callable and save the result in a an array in the __memoized
property of the instance once
was called in. When we detect that once
has already run before, we're just going to return the value stored inside the __memoized
array instead of executing the callable again.
The first thing it does is calling debug_backtrace
. We'll use the output to determine in which function and class once
is called and to get access to the object
that function is running in. Yeah, we're already in voodoo-land. The output of the debug_backtrace
is passed to a new instance of Backtrace
. That class is just a simple wrapper so we can work more easily with the backtrace.
$trace = debug_backtrace(
DEBUG_BACKTRACE_PROVIDE_OBJECT, 2
)[1];
$backtrace = new Backtrace($trace);
Next, we're going to check if once
was called from within an object. If it was called from a static method or outside a class, we just bail out.
if (! $object = $backtrace->getObject()) {
throw new Exception('Cannot use `once` outside a non-static method of a class');
}
Now that we're certain once
is called within an instance of a class we're going to calculate a hash
of the backtrace. This hash will be unique per function once
was called in and takes into account the values of the arguments that function receives.
$hash = $backtrace->getArgumentHash();
Finally we will check if there's already a value stored for the given hash. If not, then execute the given $callback
and store the result in the __memoized
array on the object. In the other case just return the value in the __memoized
array (the $callback
isn't executed).
if (! isset($object->__memoized[$hash])) {
$result = call_user_func($callback, $backtrace->getArguments());
$object->__memoized[$hash] = $result;
}
Some things you need to be aware of
Because once
will store results on the instance of the object it's called in, you cannot call once
outside of on a object or inside a static method. Also, if you need to serialize an object that uses once
be sure to unset
the __memoized
property when once
returns objects. A perfect place for unsetting the __memoized
would be the __sleep
magic method.
If you like the package be sure to check out the framework agnostic and Laravel specific ones we've made before.