Every two weeks I send out a newsletter containing lots of interesting stuff for the modern PHP developer. You can expect quick tips, links to interesting tutorials, opinions and packages. Want to learn the cool stuff? Then sign up now!

A magic memoization function

Last friday Taylor Otwell tweeted an easy to use memoization function called once:

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 stores 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.

Freek Van der Herten is a partner and developer at Spatie, an Antwerp based company that specializes in creating web apps with Laravel. After hours he writes about modern PHP and Laravel on this blog. When not coding he’s probably rehearsing with his kraut rock band.
  • sciamannikoo

    Interesting.

    What’s the performance impact of using “debug_backtrace”?

    • Kuba Szymanowski

      I’ve seen it used in Eloquent automatic relationship foreign key resolution for some time, so probably not that significant.

      • sciamannikoo

        I see the “once” function limits the backtrace to the last two elements.
        I guess this considerably reduces the performance impact.

        In some legacy code I still have to deal with, where I can’t always use this limit, reading the backtrace does have a significant performance impact.
        That’s why I was asking.

  • Daniel Mason

    – Backtrace is not going to be efficient. You could add an optional key parameter for once to allow skipping the Backtrace.
    – Creating new properties on already instantiated objects is not efficient. You could get around this by making a Memoizable trait which includes the cache (and the function). You could then also make the “once” function a part of the class, which avoids creating a global function, which considered code smell… especially when you don’t check if a function with that name already exists.
    – You could also make a StaticMemoizable trait for the places where you need to do this statically.

    In the end though, it seems far simpler to do this:

    class MyMemoizable {
    private $myCachedResult;
    public getMyResult() {
    // Watch out for null results, though this is trivially worked around
    if (!$this->myCachedResult) {
    $this->myCachedResult = ...
    }
    return $myCachedResult;
    }
    }

    An example of how to get around the issue of potential null values would be to make myCachedResult an array on to which you push a single result, then put the array inside a reset() before returning it to get the first result. A more efficient way still would be to make the cached result a class that contains the value you want.

    • The current implementation greatly favours easy of use over performance. You’re certainly right that such a caching function could be written in a more performance optimized way.

      • Daniel Mason

        Hmm, I’d have thought the exact point of Memoization was efficiency and I’d personally argue that a trait that avoids analysing the structure of your application with debug_backtrace is much eaiser to understand. However, I guess we all learn in different ways, perhaps this is easier for other people.