coins one euros lying on gray table

Hacking PHP’s WeakMap for Value Object D×

This entry is part 1 of 2 in the series Software Design For Normal People

This guide will allow you to use value objects in PHP without a special
$obj->equals() method. Instead, you can use the
=== or == operator to compare two value objects. This guide will also show you how to use a WeakMap to ensure that you don’t
create multiple instances of the same value object and how to hack WeakReferences to do the same when backing
scalar values.

The Base Value

Before we get started, we need to figure out what our Value Objects will represent. In this example, we will use time as our value. Our values will be wrapped numbers, but they could also be strings or any other arbitrary value. If you have a specific, predefined set of numbers or strings, this guide isn’t for you; use enums instead. For something like time, a user should be able to say, “Are 5 seconds equal to 5,000 milliseconds?” The answer should be “Yes.” They should be able to do this without using special methods or using the == operator.

Before we write any code, we need to decide on our base value. In this example, we will use milliseconds to keep things simple.

So, let’s dive into some code! Here’s the basic structure of the class:

readonly class Time
{
    private int $milliseconds;

    public function asSeconds(): float {
        return $this->milliseconds / 1000;
    }

    public function asMilliseconds(): float {
        return $this->milliseconds;
    }

    public function asMinutes(): float {
        return $this->milliseconds / 60000;
    }
}

This class is pretty straightforward. It has a single property, $milliseconds, and three methods to convert the value to seconds, minutes, and milliseconds. We could add more methods, but this is enough for now.

Creation

To create proper value objects for our purposes,
having direct access to the constructor would prevent us from being able to enforce the value’s identity since calling new Time(5000) and new Time(5000) would create two different objects, we need to use a factory.

readonly class Time
{
    private function __construct(private int $milliseconds) {}

    public static function fromMilliseconds(int $milliseconds): Time {
        return new Time($milliseconds);
    }

    public static function fromSeconds(float $seconds): Time {
        return new Time($seconds * 1000);
    }

    public static function fromMinutes(float $minutes): Time {
        return new Time($minutes * 60000);
    }

    /* ... */
}

Now, we can create a Time object like this:

$time = Time::fromMilliseconds(5000);

However, you’ll notice that Time::fromSeconds(1) !== Time::fromMilliseconds(1000), this is because we’re creating a new instance of Time every time we call a factory method.

The Weak Map

To solve this problem, we can use a WeakMap. A WeakMap is a map with weak references to its keys, meaning that if the key isn’t used anywhere, the WeakMap will remove the key and its value from the map.

What we need to do is something like this:

  1. See if any Time objects have the same value in the WeakMap.
  2. If there are, return the existing object.
  3. If there aren’t, create a new Time object and store it in the WeakMap.

The actual implementation would look more or less like this:

function getValue(int $milliseconds): Time {
    static $weakmap;
    $weakmap ??= new WeakMap();
    return $weakmap[$millisecondes] ??= Time::fromMilliseconds($milliseconds);
}

However, you’ll note that you can’t exactly put scalar values in a WeakMap, as they’re not objects. We must be more creative and use an array to get around this. However, if we use an array, we must ensure we behave like a WeakMap. If we’re wrapping regular objects, then the above algorithm is all we need. However, if we’re wrapping scalar objects, we need an array of WeakReferences.

Array of WeakReference

As mentioned earlier, we must use an array of WeakReferences to store our values. So, let’s get started:

  1. Create a static array that we use to hold our wrapped values.
  2. Create a WeakReference for each wrapped value we store in the array.
  3. When we need a wrapped value, we check if the raw value is in the array.
  4. If it is, we return the wrapped value.
  5. If it isn’t, we create a new wrapped value from the raw value, store it in the array, and return the wrapped value.
  6. On destruction, remove the raw value from the array.

Here’s the implementation with comments:

class Time {
    private static array $map = [];

    private static function getValue(int $milliseconds): Time {
        // Attempt to get the value from the array, and if it exists, get the
        // value from the WeakReference, otherwise, create a new one
        $realValue = (self::$map[$milliseconds] ?? null)?->get() ?? new self($milliseconds);

        // Store the value in the array, even if another reference exists
        self::$map[$milliseconds] = WeakReference::create($realValue);

        return $realValue;
    }

    public function __destruct(){
        // The values no longer exist, and we can delete the value from the array
        unset(self::$map[$this->milliseconds]);
    }

    /** ... */
}

This implementation is a bit more complex than the WeakMap but still pretty straightforward. We create a new Time object if one doesn’t exist yet; otherwise, return the existing one. Upon destroying the object, we remove it from the array since the WeakReference is about to be empty.

You may notice the odd way the “getting from the array” is structured and that we store it in a $realValue object. There are several reasons for this.

  1. This is actually pretty darn fast.
  2. We have to hold a reference to the created object, or it will be removed from our array before we ever return it.
  3. Just because we have a WeakReference, doesn’t mean it holds a value.

Even with this knowledge, you might be tempted to rewrite it like so:

$realValue = self::$map[$milliseconds] ?? WeakReference::create(new self($milliseconds);

However, because there isn’t anything but the WeakReference holding a reference to new self(), it will immediately be deleted. The provided listing is an efficient way to extract a value if one exists and hold a reference to it in $realValue.

Comparisons

Using a consistent base value means relying on regular PHP comparisons without special magic. The way that PHP compares two objects is by inspecting their properties — if they are the same type. Since there is only one property and it is numeric, the following are true:

Time::from(TimeUnit::Seconds, 1) > Time::from(TimeUnit::Milliseconds, 1)

To simply “just work.”

Thus, the only special operators we need are mathematical operators.

Full Listing

Before we get to the full listing, there are few more small improvements:

  1. We should make the class final. I’m usually against final classes, but we must do careful manipulations in this case, and a child class may override important behaviors which may cause memory leaks. I’ll do that now.
  2. We can use enums for units to simplify our from/to logic. I’ll go ahead and do this as well.
  3. I’ve updated my Time Library to use this.

Here’s the full listing:

enum TimeUnit : int {
    case Milliseconds = 1;
    case Seconds = 1000;
    case Minutes = 60000;
}

final class Time {
    private static array $map = [];
    
    private static function getValue(int $milliseconds): Time {
        // Attempt to get the value from the array, and if it exists, get the
        // value from the WeakReference, otherwise, create a new one
        $realValue = (self::$map[$milliseconds] ?? null)?->get() ?? new self($milliseconds);
        
        // Store the value in the array, even if another reference exists
        self::$map[$milliseconds] = WeakReference::create($realValue);
        
        return $realValue;
    }
    
    public function __destruct(){
        // The values no longer exist, and we can delete the value from the array
        unset(self::$map[$this->milliseconds]);
    }
    
    private function __construct(private readonly int $milliseconds) {}
    
    public static function from(TimeUnit $unit, float $value): Time {
        return self::getValue($unit->value * $value);
    }
    
    public function as(TimeUnit $unit): float {
        return $this->milliseconds / $unit->value;
    }
}

With the advent of property hooks in PHP 8.4, it might be simpler to mix the behavior and provide actual hooked properties:

final class Time {
    private static array $map = [];

    public float $seconds {
        get => $this->milliseconds / 1000.0;
    }

    public float $minutes {
        get => $this->seconds / 60.0;
    }

    private static function getValue(int $milliseconds): Time {
        // Attempt to get the value from the array, and if it exists, get the
        // value from the WeakReference, otherwise, create a new one
        $realValue = (self::$map[$milliseconds] ?? null)?->get() ?? new self($milliseconds);

        // Store the value in the array, even if another reference exists
        self::$map[$milliseconds] = WeakReference::create($realValue);

        return $realValue;
    }

    public function __destruct(){
        // The values no longer exist, and we can delete the value from the array
        unset(self::$map[$this->milliseconds]);
    }

    private function __construct(public readonly int $milliseconds) {}

    public static function from(TimeUnit $unit, float $value): Time {
        return self::getValue($unit->value * $value);
    }
}

Conclusion

I’m really excited to share this approach with you, as it enables some nice benefits when working with value objects. You no longer need special ->equals() methods and accidentally forgetting to use them. You can ask questions like assert(Time::fromSeconds(10) === Time::fromMilliseconds(10000)) and get the expected result.

I hope you find this guide useful and can use it in your projects. If you have any questions or suggestions, please email me or post them in the comments section below.

Playground: https://3v4l.org/dVEOP/rfc#vrfc.property-hooks

Series NavigationClassifying Pull Requests: Enhancing Code Review Effectiveness >>