Слабая ссылка — механизм позволяющий ссылаться на объект, при этом не запрещая сборщику мусора удалять объект в языках программирования с автоматической сборкой мусора.

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

Изначально в PHP концепция слабых ссылок отсутствует, но это довольно легко исправить. Далее речь пойдет о PHP 7.

Стоит отдельно отметить, что приведенное ниже решение работает только для объектов и это связанно с особенностью работы сборщика мусора в PHP.

Рекомендовано к предварительному прочтению



Обработчики объектов


В PHP функции обработки различных событий (операций) над объектом представлены таблицей обработчиков со структурой zend_object_handlers.

Внутренние классы могут задавать и переопределять обработчики объекта, таким образом изменяя поведение при различных типовых операциях, например, клонировании, сравнении, обращения к свойствам объекта, получения отладочной информации и т.д. Полный список возможный операций, для которых может быть задан обработчик, доступен по ссылке на структур zend_object_handlers выше.

Пользовательские классы, которые не наследовали внутренние классы, получают обработчики по-умолчанию, хранящиеся в переменной std_object_handlers.

Сборка мусора


Тема сборки мусора в PHP довольно подробно разобрана, в том числе и в официальной документации и не является предметом данной статьи.

Вкратце, значение подлежит сборке как только счетчик ссылок не него становится равным 0. В штатном режиме у объекта при сборке вызывается деструктор и затем очищается занимаемая объектом память. Внутренне, за это ответственны обработчики dtor_obj и free_obj соответственно.

Внутренне, реализацией стандартного деструктора является функция zend_objects_destroy_object суть работы которой заключается в вызове метода __destruct(), если таковой имеется.

В случае если брошено исключение или вызвана языковая конструкция exit (или ее синоним die) деструктор объекта не вызывается, например:

<?php

class Test {
    public function __destruct()
    {
        echo 'Goodbye Cruel World!', PHP_EOL;
    }

}

$test = new Test();

throw new Exception('Test exception');

что выведет

    $ php test.php

Fatal error: Uncaught Exception: Test exception in /home/vagrant/Development/php-weak/test.php on line 13

Exception: Test exception in /home/vagrant/Development/php-weak/test.php on line 13

Call Stack:
    0.0011     365968   1. {main}() /home/vagrant/Development/php-weak/test.php:0

Хранение объектов


Для того чтобы более полно понять возможные подводные камни в реализации слабых ссылок, рассмотрим где и как собственно хранятся все объекты в PHP, к счастью, это очень просто — в EG(objects_store).object_buckets что представляет собой массив где ключом выступает Z_OBJ_HANDLE(zval) — целочисленный индекс объекта, именуемый далее дескриптором объекта. В каждый момент времени он уникален для каждого объекта, при удалении объекта из EG(objects_store).object_buckets значение дескриптора объекта может быть закреплено за другим объектом, например:

<?php

$obj1 = new stdClass();
$obj2 = new stdClass();

debug_zval_dump($obj1);
debug_zval_dump($obj2);

$obj2 = null; // без предварительного удаления объекта из EG(objects_store).object_buckets новому объекту будет присвоен новый дескриптор
$obj2 = new SplObjectStorage();
debug_zval_dump($obj2);

что при исполнении даст

object(stdClass)#1 (0) refcount(2){
}
object(stdClass)#2 (0) refcount(2){
}
object(SplObjectStorage)#2 (1) refcount(2){
["storage":"SplObjectStorage":private]=>
  array(0) refcount(1){
}
}

Значение после хеш-символа (#) есть то самое значение дескриптора объекта. Как видим, значение дескриптора 2 было использовано повторно.

Реализация слабых ссылок


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

Указатель на таблицу обработчиков объекта имеет спецификатор const, на что стандарт С90 и С99 говорят что изменение такого значения путем приведения к неконстантному типу приведет к неопределенному поведению.

Ссылки по теме изменения const значений

Сделано это не просто так. Изменяя значение обработчика одного объекта мы изменим обработчик для всех объектов данного класса, и более того, и обработчики подклассов, в случае если они конeчно не были переопределены, а в случае с объектом пользовательского класса (который не является потомком внутреннего) — для всех объектов созданных из пользовательских классов.

Оптимальным решением будет заменить указатель на таблицу обработчиков отдельно взятого объекта путем копирования исходной таблицы и замены в ней нужного нам обработчика — dtor_obj. Сокращенная запись выглядит следующим образом:

php_weak_referent_t *referent = (php_weak_referent_t *) ecalloc(1, sizeof(php_weak_referent_t));

memcpy(&referent->custom_handlers, referent->original_handlers, sizeof(zend_object_handlers));
referent->custom_handlers.dtor_obj = php_weak_referent_object_dtor_obj;

Z_OBJ_P(referent_zv)->handlers = &referent->custom_handlers;

Препятствия


В процессе тестирования выявился довольно неприятным факт — изменения значения указателя таблицы обработчиков влияло на результат работы PHP функции spl_object_hash(), которая использует значения указателя на таблицу обработчиков для генерации последних 16 символов id (первые 16 — это хеш дескриптора объекта) и, как результат, возвращаемое значение id объекта будет различаться до и после создания слабой ссылки для него:

<?php
$obj = new stdClass();
var_dump(spl_object_hash($obj)); // 0000000046d2e51 a000000003e3e4a43
$ref = new Weak\Reference($obj);
var_dump(spl_object_hash($obj)); // 0000000046d2e51 a00007fc62b682d1b

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

Но мы пойдем дальше. Путем обсуждений с разработчиками PHP появилась идея… не использовать значение указателя на таблицу обработчиков при генерации хеша, так как даже его первая часть удовлетворяет условию уникальности для хеша объекта во все время его жизни. Вторая же часть теперь использует просто произвольное значение которое раньше использовалось как маска:

<?php
class Test {}
class X {}
$t = new Test();
$x = new X();
$s = new SplObjectStorage();
var_dump(spl_object_hash($t)); // 00000000054acbeb 0000000050eaeb6f
var_dump(spl_object_hash($x)); // 00000000054acbe8 0000000050eaeb6f
var_dump(spl_object_hash($s)); // 00000000054acbe9 0000000050eaeb6f

Так намного лучше! Данное улучшение скорее всего будет доступно в версии PHP > 7.0.2.

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

Не очень красивым, но весьма рабочим способом является… подмена функции spl_object_hash() собственной реализацией:

<?php
$spl_hash_function = $EG->function_table['spl_object_hash'];

$custom_hash_function = function (object $obj) {
    $hash = null;

    if (weak\refcounted($obj)) {
        $referent = execute_referent_object_metadata($obj);
        $obj->handlers = $referent->original_handlers;
        $hash = $spl_hash_function($obj);
        $obj->handlers = $referent->custom_handlers;
    }

    if (null == $hash) {
        $hash = $spl_hash_function($obj);
    }

    return $hash;
};

$EG->function_table['spl_object_hash'] = $custom_hash_function;

Автором данного метода подмены является Etienne Kneuss, автор изначальной реализации слабых ссылок в PHP 5 в виде расширения php-weakref. К сожалению, я не дождался буквально пару часов до того, как он также реализовал поддержку PHP 7. Тем не менее, два рабочих расширения лучше чем ни одного, да и на данный момент у нас довольно сильно различается функциональность и концепция.

Механизм уведомления об уничтожении объекта


При создании слабой ссылки нам довольно важен механизм уведомления о том, что объект на который мы ссылаемся был уничтожен. Концептуально данную проблему можно решить путем опрашивания самой слабой ссылки, путем сохранения слабой ссылки в некий массив, как это делается в реализации WeakReference в Java; и, что собственно представляет гастрономический интерес, вызов пользовательской функции, как это делается в реализации weakref.ref в языке Python.

Для этого после вызова исходного деструктора объекта мы вызываем для каждой слабой ссылки пользовательскую функцию если такова была задана при создание слабой ссылки или сохраняем каждый объект слабой ссылки в массив, если он был задан. Стоит отдельно обратить внимание, что если будет брошено исключение в деструкторе или в одной из пользовательских функций-уведомителей, то дальнейшие функции-уведомители вызваны не будут. Упрощенно механизм уведомления об уничтожении объекта через вызов пользовательских функций можно представить как расширение базового класса объекта с переопределением деструктора, где после вызова родительского деструктора вызываются пользовательские функции одна за другой, начиная от самой последней и до самой первой.

В итоге наш деструктор-обертку можно представить в виде мета-кода:

run_original_dtor_obj($object);

foreach($weak_references as $weak_ref_object_handle => $weak_reference) {
    if (is_array($weak_reference->notifier)) {
        $weak_reference->notifier[] = $weak_reference;
    } elseif (is_callable($weak_reference->notifier) && $no_exception_thrown) {
        $weak_reference->notifier($weak_reference);
    }

    unset($weak_references[$weak_ref_object_handle]);
}

Сам же класс слабой ссылки имеет следующий вид:

namespace Weak;

class Reference
{
    /**
     * @param object                  $referent Объект на который ссылаемся
     * @param callable | array | null $notify   Механизм для уведомления об уничтожении отслеживаемого объекта 
     *
     * Если уведомитель это пользовательская функция, то она будет вызвана с данным объектом слабой ссылки.
     * Следует обратить внимание что в тот момент объекта на который мы ссылаемся уже будет уничтожен и недоступен.
     * Пользовательская функция не будет вызвана если деструктор отслеживаемого объекта или предыдущая
     * пользовательская функция-уведомитель бросили исключение.
     *
     * Если уведомитель это массив, то данный объект слабой ссылки будет добавлен его конец, причем,
     * вне зависимости от того, было ли брошено исключение деструктором отслеживаемого объекта либо
     * пользовательской функцией-уведомителем или нет.
     */
    public function __construct(object $referent, $notify = null)
    {
    }

    /**
     * Получить объект на который ссылаемся. Если объект был удален то вернется null.
     *
     * @return object | null
     */
    public function get()
    {
    }

    /**
     * Проверить статус объекта на который мы ссылаемся: не уничтожен ли он еще.
     *
     * @return bool
     */
    public function valid() : bool
    {
    }

    /**
     * Получить уведомитель
     *
     * @param callable | array | null $notify Механизм уведомления. Если задан, заменит существующий.
     *
     * @return callable | array | null Текущий уведомитель или предыдущий, если функция была вызвана с новым уведомителем
     */
    public function notifier($notify = null)
    {
    }
}

Для получения дополнительной информации по значениям и объектам данное расширение предоставляет ряд функций, которые дополняют функциональность класса слабой ссылки и позволяют ни в чем себе не отказывать создавать различные решения уже на пользовательском уровне.

Листинг функций расширения
namespace Weak;

/**
 * Проверить, является ли значение переданного аргумента ссылочным или нет.
 *
 * @param mixed $value
 *
 * @return bool
 */
function refcounted(mixed $value) : bool {}

/**
 * Возвращает количество ссылок на значение переданного аргумента. Если значение не ссылочное то вернет 0.
 *
 * Данная функция не учитывает ссылку которая образуется вследствие передачи значения как аргумента функции, поэтому
 * вызов вида weak\refcount(new stdClass()) вернет 0, так как помимо переданного аргумента больше на это значение никто
 * не ссылается.
 *
 * @param mixed $value
 *
 * @return int
 */
function refcount(mixed $value) : int {}

/**
 * Проверить, есть ли слабые ссылки на данный объект или нет.
 *
 * @param object $value
 *
 * @return bool
 */
function weakrefcounted(object $value) : bool {}

/**
 * Получить количество слабых ссылок на объект. Если таковых нет то вернется 0.
 *
 * @param object $value
 *
 * @return int
 */
function weakrefcount(object $value) : int {}

/**
 * Получить массив слабых ссылок на объект. Если таковых нет то вернется пустой массив.
 *
 * @param object $value
 *
 * @return mixed
 */
function weakrefs(object $value) : array {}

/**
 * Получить значение дескриптора объекта.
 *
 * @param object $value
 *
 * @return int
 */
function object_handle(object $value) : int {}

Данные функции довольно специфичны и их следует использовать никогда только в тех случаях когда пользователь понимает что и зачем он делает.

Практическое применение


Данное расширение предоставляющее описанную выше функциональность стало результатом работы над другим расширением — интеграции v8 JavaScript движка в PHP (не v8js, более простое и мощное, на данный момент исходный код публично не доступен).

При написании слоя абстракции по переводу php объектов в js объекты, возникла необходимость однозначно сопоставлять и, что самое главное, чистить таблицу соответствий php объектов и js представлений. Типовой проблемой стало то, что в разных местах возникает ситуация когда в js возвращается один и тот же php объект для которого уже имеется js представление, и для того чтобы внутри js представления тоже были идентичными, нужно хранить таблицу соответствий.

Первоначально составлялась таблица соответствия id php объекта и непосредственно js представления, но по мере работы складывалась ситуация когда образовывался переизбыток осиротевших js представлений: соответствующие им php объекты удалялись сборщиком мусора, так как на них никто не ссылался. Механизм уведомления который реализован вместе со слабыми ссылками позволят полностью решить данную проблему.

Для людей же не страдающих описанными выше симптомами и ведущими здоровый ночной образ жизни данное расширение может быть полезным для следующего:
  • реализации кеширования объектов в рантайме.
  • связывания данных с объектом, например, добавления свойств и методов в ситуациях когда наследование и/или композиция не очень подходят (привет, Java).
  • удаление соответствующих event listenter'ов после того как будет удален объект.

Предлагаю в комментариях поделится возможными способами применения слабых ссылок в PHP.

Для собственных нужд была создана реализация WeakMap структуры данных на базе SplObjectStorageWeak\SplObjectStorage

Расширение доступно по ссылке github.com/pinepain/php-weak и распространяется по лицензии MIT. Для работы требуется PHP 7.

Комментарии (7)


  1. dim_s
    15.01.2016 12:24
    +2

    На мой взгляд namespace Weak совершенно некорректен в данном случае! Класс логичнее назвать WeakReference, т.к. могут быть еще разного типа Reference как в Java, например, SoftReference.


    1. zaq178miami
      15.01.2016 13:18
      +3

      Замечание дельное. Спасибо. Исходя из моего довольно общего понимания того, как работают разного вида Reference в Java, на данный момент появление подобных WeakReference, SoftReference или PhantomReference в PHP на уровне расширения маловероятно от слова совсем. Это связанно с тем, как работает сборщик мусора в PHP. Собственно говоря, даже Weak\Reference это не чистая слабая ссылка в терминах Java. Это самый обыкновенный указатель в терминах С/С++. Без внесения изменений в работу сборщика мусора в PHP достичь поведения выше указанных ссылок разумным способом, пожалуй, не возможно. Учитывая основную область применения PHP — web, здесь это на данный момент не особо и нужно.


      1. Gugic
        15.01.2016 15:03

        Но неймспейс-то от этого корректнее не становится.


      1. dim_s
        15.01.2016 15:05

        Ну думаю тут дело не только в том, что могут быть другие типы Reference и поэтому нельзя использовать Namespace «Weak», а то что weak это прилагательное, а использовать прилагательное в качестве пространства имен немного странно. Обычно используют имена вендоров или существительные.


        1. zaq178miami
          15.01.2016 15:12

          Полагаю, namespace WeakReference будет более репрезентативен, как вы считаете?


          1. dim_s
            15.01.2016 15:54
            +1

            WeakReference\WeakReference? Тоже немного странновато. Мне сложно придумать для 1 такого класса неймспейс, особенно когда это не библиотека. Логичнее такое namespace Pinepain; от имени вашего в github, как название вендора.


            1. KAndy
              16.01.2016 16:34

              В любом случаи стоит соответствовать лучшим практикам PHP комьюнити и использовать VendorName префикс