WeakMap — скрытое сокровище в PHP

Источник: «WeakMaps a hidden gem in PHP»
В PHP 8.0 был добавлен WeakMap — мистическая функция, которую мы никогда не использовали, пока не столкнулись со сложной проблемой, требующей решения.

Мы переписывали пакеты Flare для новой функции, что привело к возникновению проблемы. Дело обстоит следующим образом: есть элемент, хранящийся в массиве с подобными элементами:

/**
* @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;
}
}

Здесь нет ничего слишком сложного. Store должно хранить определённое количество элементов, поэтому по достижении лимита после добавления нового элемента первый элемент удаляется. Нас всегда больше интересуют товары, добавленные в конце процесса.

Подобное можно легко реализовать:

/**
* @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);
}
}
}

А теперь начинаются сложности. Мы определяем совершенно новую структуру, называемую Node. У Node могут быть дочерние элементы, которые снова являются Node. По сути, мы создаём древовидную структуру.

Дело вот в чем: Node также содержит массив элементов, идентичных тем, которые использовались ранее в Store:

/**
* @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 = [],
)
{
}
}

Мы обновим требования, чтобы при добавлении элемента в Store этот элемент также добавлялся в Node. Чтобы найти узел, мы используем MagicNodeFinder (это я придумал, чтобы сделать статью немного понятнее). MagicNodeFinder просто возвращает Node, в которой должен храниться элемент, но его работа зависит от времени выполнения, поэтому он будет возвращать разные ноды в зависимости от времени, когда он был вызван.

Store выглядит следующим образом:

/**
* @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);
}
}
}

Мы почти у цели. Осталось обновить код удаления элементов, чтобы элементы можно было удалять из узла при достижении лимита элементов.

Хотя это может показаться простым, это не так, поскольку нельзя доверять MagicNodeFinder в том, что он вернёт правильный Node, в котором хранится элемент.

Невозможно узнать, в каком узле хранится элемент, и даже если бы мы знали узел, пришлось бы обходить дерево каждый раз, когда необходимо удалить элемент, что привело бы к потере вычислительного времени.

Представляем WeakMap

В версии PHP 8.0 в язык были добавлены WeakMap. WeakMap — это карты, содержащие объекты в качестве ключей, но не увеличивающие счётчик ссылок на этот объект. Таким образом, всякий раз, когда объект, хранящийся в WeakMap, удаляется сборщиком мусора (больше не существует, потому что вышел из области видимости или был удалён), он также немедленно удаляется из карты.

Звучит сложно, давайте рассмотрим пример. Мы рефакторим класс Node следующим образом:

/**
* @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(),
) {
}
}

Давайте добавим элемент в узел:

$item = new Item('Some info here');

$node = new Node(42);

$node->items[$item] = null;

Подсчитаем количество элементов в WeakMap:

$node->items->count(); // 1

Если удалить элемент:

unset($item);

Что происходит с количеством элементов в WeakMap?

$node->items->count(); // 0

Круто! Предмет был автоматически удалён из карты путём сбора мусора.

Назад к Store

Давайте посмотрим, как можно использовать WeakMap в Store. Метод addItem в Store при использовании WeakMap в 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);
}
}

В соответствии с определёнными ранее правилами, при достижении максимального количества элементов в массиве items, первый элемент будет удалён с помощью array_shift(). Он также будет удалён из WeakMap, что просто замечательно! Всё, никаких дополнительных изменений не требуется!

Заключение

WeakMap — функция, добавленная в PHP, которую, как мне казалось, я никогда не буду использовать. Как же я ошибался! Она сделала наш код намного более производительным и легко читаемым.

Дополнительные материалы

Предыдущая Статья

Докеризация приложения Laravel 11

Следующая Статья

Тесты поддержки браузерами современных веб-функций на JavaScript