WeakMaps a hidden gem in PHP
WeakMaps a hidden gem in PHP
We're working on a prominent new feature for Flare in the coming months. I hope to share more information about it in the coming weeks, but for now, we're going to keep it a secret.
We're rewriting the internals of some of the Flare packages for this feature, which led to a problem. It goes like this: we have an item that we store in an array with similar items like this:
/**
* @template T
*/
class Store
{
/**
* @param array<T> $items
*/
public function __construct(
protected array $items = []
) {
}
/**
* @param T $item
*/
public function addItem(mixed $item): void
{
$this->items[] = $item;
}
}
There's nothing too fancy right now. The Store
should keep a configured number of items, so we drop the first item added when we add a new item and the limit has been reached. We are always more interested in the items added at the process's end.
Such a thing can be easily implemented as such:
/**
* @template T
*/
class Store
{
/**
* @param array<T> $items
*/
public function __construct(
protected array $items = [],
protected int $maxEntries = 200,
) {
}
/**
* @param T $item
*/
public function addItem(mixed $item): void
{
$this->items[] = $item;
if (count($this->items) > $this->maxEntries) {
array_shift($this->items);
}
}
}
Now starts the complexity. We define a completely new structure called a Node
. A Node
can have children, which are again Nodes
. We're basically creating a tree structure.
Here's the thing: our Node
also has an array of items, the identical items we used in our Store
earlier:
/**
* @template T
*/
class Node
{
/**
* @param array<Node> $children
* @param array<T> $items
*/
public function __construct(
public readonly string $id,
public array $children = [],
public array $items = [],
)
{
}
}
We update our requirements so that when adding an item to the store, we'll also add that item to a node. To find the node, we use a MagicNodeFinder
(something I just made up to keep this article a bit clearer). The MagicNodeFinder
will just return a node where the item should be stored, but it is dependent on the time it runs, so it will return different nodes based on the time it has been called.
Our Store
now looks like this:
/**
* @template T
*/
class Store
{
/**
* @param array<T> $items
*/
public function __construct(
protected array $items = [],
protected int $maxEntries = 200,
) {
}
/**
* @param T $item
*/
public function addItem(mixed $item): void
{
$node = (new MagicNodeFinder())->execute();
$this->items[] = $item;
$node->items[] = $item;
if (count($this->items) > $this->maxEntries) {
array_shift($this->items);
}
}
}
We're almost there. We need to update the item removal code so that items can be removed from the node when the item limit is reached.
Although this might look easy, it isn't since MagicNodeFinder
cannot be trusted to return the correct node where the item is stored.
There's no way to know in which node the item is stored, and even if we knew the node, we'd have to traverse the tree every time we need to remove an item, resulting in wasted computing time.
Introducing the WeakMap
In PHP 8.0, WeakMaps were added to the language. Weakmaps are maps that hold objects as keys but without raising the reference count for that object. Thus, whenever an object stored in the WeakMap is garbage collected(it doesn't exist anymore because it went out of scope or was unset), it is also immediately removed from the map.
That all sounds difficult; let's take a look at an example. We refactor our Node
class like this:
/**
* @template T
*/
class Node
{
/**
* @param string $id
* @param array<Node> $children
* @param WeakMap<T, null> $items
*/
public function __construct(
public readonly string $id,
public array $children = [],
public WeakMap $items = new WeakMap(),
) {
}
}
Let's add an item to our node:
$item = new Item('Some info here');
$node = new Node(42);
$node->items[$item] = null;
We count the number of items in the WeakMap:
$node->items->count(); // 1
If we garbage collect our item by unsetting it:
unset($item);
What happens to the WeakMap's count?
$node->items->count(); // 0
Cool! The item was automatically removed from the map by garbage collecting the item.
Back to our Store
Let's see how we can use this Weakmap
within our Store
. The addItem
method of our Store
looks like this when using a WeakMap
in Node
:
/**
* @param T $item
*/
public function addItem(mixed $item): void
{
$node = (new MagicNodeFinder())->execute();
$this->items[] = $item;
$node->items[$item] = null;
if (count($this->items) > $this->maxEntries) {
array_shift($this->items);
}
}
By shifting the items array, the object will, due to scoping rules, be unset when the end of the method is reached. Thus, it will also be removed from the WeakMap, which is excellent! We're done; no extra changes are required here!
Conclusions
WeakMap was a feature added to PHP that I thought I would never use. Boy, was I wrong! It made our code a lot more performant and easier to read.
We have great things in the pipeline and would love to show them in the next months. See you soon!