Подробно об объектах и классах в PHP

Сегодня объекты используются очень активно, хотя это трудно было предположить после выхода PHP 5 в 2005 году. Тогда я ещё мало что знал о возможностях этого языка. Пятую версию PHP сравнивали с предыдущей, четвёртой, и главным преимуществом нового релиза стала новая, очень мощная объектная модель. И сегодня, десять лет спустя, около 90% всего PHP-кода содержит объекты, не изменившиеся со времени PHP 5.0. Это убедительно говорит о том, какую роль сыграло внедрение объектной модели, неоднократно улучшавшейся на протяжении последующих лет. В этом посте я хотел бы рассказать о том, как всё устроено «под капотом». Чтобы люди понимали суть процессов — почему сделано так, а не иначе — и лучше, полнее использовали возможности языка. Также я затрону тему использования памяти объектами, в том числе в сравнении с эквивалентными массивами (когда это возможно).

Я буду рассказывать на примере версии PHP 5.4, и описываемые мной вещи справедливы для 5.5 и 5.6, потому что устройство объектной модели там почти не претерпело изменений. Обратите внимание, что в версии 5.3 всё не так хорошо с точки зрения возможностей и общей производительности.

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

В качестве демонстрации начну с синтетических бенчмарков:

class Foo {
    public $a = "foobarstring";
    public $b;
    public $c = ['some', 'values'];
}
for ($i=0; $i<1000; $i++) {
    $m = memory_get_usage();
    ${'var'.$i} = new Foo;
    echo memory_get_usage() - $m"\n";
}

Здесь объявляется простой класс с тремя атрибутами, а затем в цикле создаётся 1000 объектов этого класса. Обратите внимание, как в этом примере используется память: при создании объекта класса Foo и переменной для его хранения выделяется 262 байт динамической памяти PHP.

Давайте заменим объект на эквивалентный массив:

for ($i=0; $i<1000; $i++) {
    $m = memory_get_usage();
    ${'var'.$i} = [['some', 'values'], null, 'foobarstring'];
    echo memory_get_usage() - $m . "\n";
}

В данном случае используются те же элементы: сам массив, null и строковая переменная foobarstring. Вот только потребляется уже 1160 байт памяти, что в 4,4 раза больше.

Вот ещё один пример:

$class = <<<'CL'
class Foo {
    public $a = "foobarstring";
    public $b;
    public $c = ['some', 'values'];
}
CL;
echo memory_get_usage() . "\n";
eval($class);
echo memory_get_usage() . "\n";

Поскольку класс декларируется во время компиляции, то для декларирования и измерения используемой памяти (с помощью диспетчера памяти PHP) мы используем оператор eval(). При этом никакие объекты в данном коде не создаются. Объём задействованной памяти (diff memory) составляет 2216 байт.

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

Всё начинается с классов

Внутри PHP класс представляется с помощью структуры zend_class_entry:

struct _zend_class_entry {
    char type;
    const char *name;
    zend_uint name_length;
    struct _zend_class_entry *parent;
    int refcount;
    zend_uint ce_flags;
    HashTable function_table;
    HashTable properties_info;
    zval **default_properties_table;
    zval **default_static_members_table;
    zval **static_members_table;
    HashTable constants_table;
    int default_properties_count;
    int default_static_members_count;
    union _zend_function *constructor;
    union _zend_function *destructor;
    union _zend_function *clone;
    union _zend_function *__get;
    union _zend_function *__set;
    union _zend_function *__unset;
    union _zend_function *__isset;
    union _zend_function *__call;
    union _zend_function *__callstatic;
    union _zend_function *__tostring;
    union _zend_function *serialize_func;
    union _zend_function *unserialize_func;
    zend_class_iterator_funcs iterator_funcs;
    /* handlers */
    zend_object_value (*create_object)(zend_class_entry *class_type TSRMLS_DC);
    zend_object_iterator *(*get_iterator)(zend_class_entry *ce, zval *object, int by_ref TSRMLS_DC);
    int (*interface_gets_implemented)(zend_class_entry *iface, zend_class_entry *class_type TSRMLS_DC); /* a class implements this interface */
    union _zend_function *(*get_static_method)(zend_class_entry *ce, char* method, int method_len TSRMLS_DC);
    /* serializer callbacks */
    int (*serialize)(zval *object, unsigned char **buffer, zend_uint *buf_len, zend_serialize_data *data TSRMLS_DC);
    int (*unserialize)(zval **object, zend_class_entry *ce, const unsigned char *buf, zend_uint buf_len, zend_unserialize_data *data TSRMLS_DC);
    zend_class_entry **interfaces;
    zend_uint num_interfaces;
    zend_class_entry **traits;
    zend_uint num_traits;
    zend_trait_alias **trait_aliases;
    zend_trait_precedence **trait_precedences;
    union {
        struct {
            const char *filename;
            zend_uint line_start;
            zend_uint line_end;
            const char *doc_comment;
            zend_uint doc_comment_len;
        } user;
        struct {
            const struct _zend_function_entry *builtin_functions;
            struct _zend_module_entry *module;
        } internal;
    } info;
};

Размер структуры, исходя из модели LP64, составляет 568 байт. То есть каждый раз, когда PHP декларирует класс, он вынужден создавать zend_class_entry, используя только для этого более половины килобайта динамической памяти. Конечно, дело этим не ограничивается: как вы заметили, структура содержит немало указателей, которые тоже надо разместить в памяти. То есть сами по себе классы потребляют памяти гораздо больше, чем все создаваемые из них впоследствии объекты.

Помимо прочего, классы содержат атрибуты (статические и динамические), а также методы. Всё это тоже требует памяти. Что касается методов, то здесь сложно вычислить точную зависимость, но одно верно: чем больше тело метода, тем больше его OPArray, а значит, тем больше памяти он потребляет. Добавьте к этому статические переменные, которые могут быть объявлены в методе. Далее идут атрибуты, позже они тоже будут размещены в памяти. Объём зависит от их значений по умолчанию: целочисленные займут немного, а вот большой статический массив съест немало памяти.

Важно знать ещё об одном моменте, связанном с zend_class_entry — о PHP-комментариях. Они также известны как аннотации. Это строковые переменные (в языке С — буферы char*), которые тоже надо разместить в памяти. Для языка С, не использующего Unicode, в отличие от PHP, правило очень простое: один символ = один байт. Чем больше у вас в классе аннотаций, тем больше памяти будет использовано после парсинга.

У zend_class_entry поле doc_comment содержит аннотации класса. У методов и атрибутов тоже есть такое поле.

Пользовательские и внутренние классы

Пользовательский класс — это класс, заданный с помощью PHP, а внутренний класс задаётся либо благодаря внедрению исходного кода в сам PHP, либо с помощью расширения. Самое большое различие между этими двумя видами классов заключается в том, что пользовательские классы оперируют памятью, выделяемой по запросу, а внутренние — «постоянной» памятью.

Это означает, что когда PHP заканчивает обработку текущего HTTP-запроса, он убирает из памяти и уничтожает все пользовательские классы, готовясь к обработке следующего запроса. Этот подход известен под названием «архитектура без разделения ресурсов» (the share nothing architecture). Так было заложено в PHP с самого начала, и изменять это пока не планируется.

Итак, каждый раз при формировании запроса и парсинге классов происходит выделение памяти для них. После использования класса уничтожается всё, что с ним связано. Так что обязательно используйте все объявленные классы, в противном случае будет теряться память. Применяйте автозагрузчики, они задерживают парсинг/объявление во время выполнения, когда PHP нужно задействовать класс. Несмотря на замедление выполнения, автозагрузчик позволяет грамотно использовать память, поскольку он не будет запущен, пока действительно не возникнет потребность в классе.

С внутренними классами всё иначе. Они размещаются в памяти постоянно, вне зависимости от того, использовали их или нет. То есть они уничтожаются только тогда, когда прекращается работа самого PHP — после завершения обработки всех запросов (подразумеваются веб SAPI, например, PHP-FPM). Поэтому внутренние классы более эффективны, чем пользовательские (в конце запроса уничтожаются только статические атрибуты, больше ничего).

if (EG(full_tables_cleanup)) {
    zend_hash_reverse_apply(EG(function_table), (apply_func_t) clean_non_persistent_function_full TSRMLS_CC);
    zend_hash_reverse_apply(EG(class_table), (apply_func_t) clean_non_persistent_class_full TSRMLS_CC);
} else {
    zend_hash_reverse_apply(EG(function_table), (apply_func_t) clean_non_persistent_function TSRMLS_CC);
    zend_hash_reverse_apply(EG(class_table), (apply_func_t) clean_non_persistent_class TSRMLS_CC);
}
static int clean_non_persistent_class(zend_class_entry **ce TSRMLS_DC)
{
    return ((*ce)->type == ZEND_INTERNAL_CLASS) ? ZEND_HASH_APPLY_STOP : ZEND_HASH_APPLY_REMOVE;
}

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

Как вы заметили, если активировать много PHP-расширений, каждое из которых объявляет много классов, но при этом использовать лишь небольшое их количество, то теряется память. Помните, что PHP-расширения объявляют классы во время запуска PHP, даже если в последующих запросах эти классы использоваться не будут. Поэтому не рекомендуется держать расширения активными, если они не применяются в данный момент, иначе вы будете терять память. Особенно если эти расширения объявляют много классов — хотя они могут забить память и чем-нибудь другим.

Классы, интерфейсы или трейты — без разницы

Для управления классами, интерфейсами и трейтами в PHP используется одна и та же структура — zend_class_entry. И как вы уже видели, эта структура весьма громоздка. Иногда разработчики объявляют в коде интерфейсы, чтобы иметь возможность использовать свои названия в catch-блоках. Это позволяет ловить только определённый вид исключений. Например, так:

interface BarException { }
class MyException extends Exception implements BarException { }
try {
    $foo->bar():
} catch (BarException $e) { }

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

$class = <<<'CL'
interface Bar { }
CL;
$m = memory_get_usage();
eval($class);
echo memory_get_usage() - $m . "\n"; /* 912 bytes */

Не хочу сказать, что это плохо или глупо, я не пытаюсь никого и ничто обвинять. Просто обращаю ваше внимание на этот момент. С точки зрения внутренней структуры PHP, классы, интерфейсы и трейты используются совершенно одинаково. В интерфейс нельзя добавить атрибуты, парсер или компилятор просто не позволят этого сделать. Однако структура zend_class_entry никуда не девается, просто ряд полей, включая static_members_table, не будут размещёнными в памяти указателями. Объявление класса, эквивалентного трейта или эквивалентного интерфейса потребует одинакового объёма памяти, поскольку все они используют одну и ту же структуру.

Привязка класса

Многие разработчики не вспоминают о привязке класса, пока не начинают задавать вопросом, а как же всё устроено на самом деле. Привязку класса можно описать как «процесс, в ходе которого сам класс и все связанные с ним данные подготавливаются для полноценного использования разработчиком». Этот процесс очень прост и не требует много ресурсов, если речь идёт о каком-то одном классе, не дополняющем другой, не использующем трейты и не внедряющим интерфейс. Процесс привязки для таких классов полностью протекает во время компиляции, а в ходе выполнения ресурсы на это уже не тратятся. Обратите внимание, что речь шла привязке класса, задекларированного пользователем. Для внутренних классов тот же самый процесс выполняется, когда классы зарегистрированы ядром или расширениями PHP, как раз перед запуском пользовательских скриптов — и делается это лишь один раз за всё время работы PHP.

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

/* Single class */
case ZEND_DECLARE_CLASS:
    if (do_bind_class(CG(active_op_array), opline, CG(class_table), 1 TSRMLS_CC) == NULL) {
        return;
    }
    table = CG(class_table);
    break;

В случае простого объявления класса мы запускаем do_bind_class(). Эта функция всего лишь регистрирует полностью определённый класс в таблице классов с целью дальнейшего использования во время выполнения, а также осуществляет проверку на возможные абстрактные методы:

void zend_verify_abstract_class(zend_class_entry *ce TSRMLS_DC)
{
    zend_abstract_info ai;
    if ((ce->ce_flags & ZEND_ACC_IMPLICIT_ABSTRACT_CLASS) && !(ce->ce_flags & ZEND_ACC_EXPLICIT_ABSTRACT_CLASS)) {
        memset(&ai, 0, sizeof(ai));
        zend_hash_apply_with_argument(&ce->function_table, (apply_func_arg_t) zend_verify_abstract_class_function, &ai TSRMLS_CC);
        if (ai.cnt) {
            zend_error(E_ERROR, "Class %s contains %d abstract method%s and must therefore be declared abstract or implement the remaining methods (" MAX_ABSTRACT_INFO_FMT MAX_ABSTRACT_INFO_FMT MAX_ABSTRACT_INFO_FMT ")",
                ce->name, ai.cnt,
                ai.cnt > 1 ? "s" : "",
                DISPLAY_ABSTRACT_FN(0),
                DISPLAY_ABSTRACT_FN(1),
                DISPLAY_ABSTRACT_FN(2)
                );
        }
    }
}

Тут добавить нечего, простой случай.

При привязке класса, внедряющего интерфейс, нужно осуществить следующие действия:

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

Под «копированием» подразумевается не полное глубокое копирование. Для констант, атрибутов и функций по очереди ведется пересчет, сколько сущностей в памяти их использует.

ZEND_API void zend_do_implement_interface(zend_class_entry *ce, zend_class_entry *iface TSRMLS_DC)
{
    /* ... ... */
    } else {
        if (ce->num_interfaces >= current_iface_num) {
            if (ce->type == ZEND_INTERNAL_CLASS) {
                ce->interfaces = (zend_class_entry **) realloc(ce->interfaces, sizeof(zend_class_entry *) * (++current_iface_num));
            } else {
                ce->interfaces = (zend_class_entry **) erealloc(ce->interfaces, sizeof(zend_class_entry *) * (++current_iface_num));
            }
        }
        ce->interfaces[ce->num_interfaces++] = iface;
        zend_hash_merge_ex(&ce->constants_table, &iface->constants_table, (copy_ctor_func_t) zval_add_ref, sizeof(zval *), (merge_checker_func_t) do_inherit_constant_check, iface);
        zend_hash_merge_ex(&ce->function_table, &iface->function_table, (copy_ctor_func_t) do_inherit_method, sizeof(zend_function), (merge_checker_func_t) do_inherit_method_check, ce);
        do_implement_interface(ce, iface TSRMLS_CC);
        zend_do_inherit_interfaces(ce, iface TSRMLS_CC);
    }
}

Обратите внимание на разницу между внутренними и пользовательскими классами. Первые для распределения памяти будут использовать realloc(), вторые — erealloc()realloc() распределяет «постоянную» память, а erealloc() оперирует памятью, «выделяемой по запросу».

Вы можете видеть, что, когда объединяются две константные таблицы (интерфейс-1 и класс-1), они делают это с помощью колбека zval_add_ref. Он не копирует константы из одной таблицы в другую, а расшаривает их указатели, просто добавляя количество референсов.

Для каждой из таблиц функций (методов) используется do_inherit_method:

static void do_inherit_method(zend_function *function)
{
    function_add_ref(function);
}
ZEND_API void function_add_ref(zend_function *function)
{
    if (function->type == ZEND_USER_FUNCTION) {
        zend_op_array *op_array = &function->op_array;
        (*op_array->refcount)++;
        if (op_array->static_variables) {
            HashTable *static_variables = op_array->static_variables;
            zval *tmp_zval;
            ALLOC_HASHTABLE(op_array->static_variables);
            zend_hash_init(op_array->static_variables, zend_hash_num_elements(static_variables), NULL, ZVAL_PTR_DTOR, 0);
            zend_hash_copy(op_array->static_variables, static_variables, (copy_ctor_func_t) zval_add_ref, (void *) &tmp_zval, sizeof(zval *));
        }
        op_array->run_time_cache = NULL;
    }
}

К OPArray функции добавлен refcount, а также с помощью zval_add_ref скопированы все возможные статические переменные, объявленные в функции (здесь это метод). Таким образом, для всего процесса копирования нужно немало вычислительных ресурсов, потому что здесь задействовано много циклов и проверок. Но памяти задействуется немного. К сожалению, сегодня привязка интерфейса полностью протекает во время выполнения, и вы будете это чувствовать при каждом запросе. Возможно, скоро разработчики это изменят.

Что касается наследования, то здесь, в принципе, всё то же самое, что и при внедрении интерфейса. Только вовлечено ещё больше «участников». Но хочу отметить, что если PHP уже знает о классе, то привязка осуществляется во время компилирования, а если не знает — то во время выполнения. Так что лучше объявлять так:

/* good */
class A { }
class B extends A { }

вместо:

/* bad */
class B extends A { }
class A { }

Кстати, рутинная процедура привязки класса может привести к очень странному поведению:

/* это работает */
class B extends A { }
class A { }

 

/* а это нет */
Fatal error: Class 'B' not found */
class C extends B { }
class B extends A { }
class A { }

В первом варианте привязка класса В отложена на время выполнения, потому что когда компилятор доходит до объявления этого класса, он ещё ничего не знает о классе А. Когда начинается выполнение, то привязка класса А происходит без вопросов, потому что он уже скомпилирован, будучи одиночным классом. Во втором случае всё иначе. Привязка класса С отложена на время выполнения, потому что компилятор ещё ничего не знает о В, пытаясь скомпилировать его. Но когда во время выполнения начинается привязка класса С, то он ищет В, который не существует, поскольку не скомпилирован по причине того, что В является дополнением. Вылетает сообщение “Class B doesn’t exist”.

Объекты

Итак, теперь мы знаем, что:

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

Теперь поговорим об объектах. В первой главе показано, что создание «классического» объекта («классического» пользовательского класса) потребовало очень мало памяти, около 200 байт. Всё дело в классе. Дальнейшая компиляция класса тоже потребляет память, но это к лучшему, потому что для создания одиночного объекта требуется меньше байт. По сути, объект представляет собой крохотный набор из крохотных структур.

Управление методами объекта

На уровне движка методы и функции являются одним и тем же — структурой zend_function_structure. Различаются лишь названия. Методы компилируются и добавляются к атрибуту function_table в zend_class_entry. Поэтому во время выполнения представлен каждый метод, это лишь вопрос перевода указателя на исполнение.

typedef union _zend_function {
    zend_uchar type;
    struct {
        zend_uchar type;
        const char *function_name;
        zend_class_entry *scope;
        zend_uint fn_flags;
        union _zend_function *prototype;
        zend_uint num_args;
        zend_uint required_num_args;
        zend_arg_info *arg_info;
    } common;
    zend_op_array op_array;
    zend_internal_function internal_function;
} zend_function;

Когда объект пытается вызвать метод, то движок по умолчанию ищет в таблице значений функций класса этого объекта. Если метод не существует, то вызывается __call(). Также проверяется видимость — public/protected/private — в зависимости от чего предпринимаются следующие действия:

static union _zend_function *zend_std_get_method(zval **object_ptr, char *method_name, int method_len, const zend_literal *key TSRMLS_DC)
{
    zend_function *fbc;
    zval *object = *object_ptr;
    zend_object *zobj = Z_OBJ_P(object);
    ulong hash_value;
    char *lc_method_name;
    ALLOCA_FLAG(use_heap)
    if (EXPECTED(key != NULL)) {
        lc_method_name = Z_STRVAL(key->constant);
        hash_value = key->hash_value;
    } else {
        lc_method_name = do_alloca(method_len+1, use_heap);
        zend_str_tolower_copy(lc_method_name, method_name, method_len);
        hash_value = zend_hash_func(lc_method_name, method_len+1);
    }
    /* If the method is not found */
    if (UNEXPECTED(zend_hash_quick_find(&zobj->ce->function_table, lc_method_name, method_len+1, hash_value, (void **)&fbc) == FAILURE)) {
        if (UNEXPECTED(!key)) {
            free_alloca(lc_method_name, use_heap);
        }
        if (zobj->ce->__call) { /* if the class has got a __call() handler */
            return zend_get_user_call_function(zobj->ce, method_name, method_len); /* call the __call() handler */
        } else {
            return NULL; /* else return NULL, which will likely lead to a fatal error : method not found */
        }
    }
    /* Check access level */
    if (fbc->op_array.fn_flags & ZEND_ACC_PRIVATE) {
        zend_function *updated_fbc;
        updated_fbc = zend_check_private_int(fbc, Z_OBJ_HANDLER_P(object, get_class_entry)(object TSRMLS_CC), lc_method_name, method_len, hash_value TSRMLS_CC);
        if (EXPECTED(updated_fbc != NULL)) {
            fbc = updated_fbc;
        } else {
            if (zobj->ce->__call) {
                fbc = zend_get_user_call_function(zobj->ce, method_name, method_len);
            } else {
                zend_error_noreturn(E_ERROR, "Call to %s method %s::%s() from context '%s'", zend_visibility_string(fbc->common.fn_flags), ZEND_FN_SCOPE_NAME(fbc), method_name, EG(scope) ? EG(scope)->name : "");
            }
        }
    } else {
    /* ... ... */
}

Вы могли заметить интересную вещь, посмотрите на первые строки:

if (EXPECTED(key != NULL)) {
        lc_method_name = Z_STRVAL(key->constant);
        hash_value = key->hash_value;
    } else {
        lc_method_name = do_alloca(method_len+1, use_heap);
        /* Create a zend_copy_str_tolower(dest, src, src_length); */
        zend_str_tolower_copy(lc_method_name, method_name, method_len);
        hash_value = zend_hash_func(lc_method_name, method_len+1);
    }

Это проявление невосприимчивости PHP к разным регистрам. Система сначала должна привести каждую функцию к нижнему регистру (zend_str_tolower_copy()), прежде чем вызывать её. Не совсем каждую, а те, где присутствует оператор if. Переменная key предотвращает исполнение функции, переводящей в нижний регистр (часть с else) — это часть оптимизации PHP, осуществлённой в версии 5.4. Если вызов метода не динамический, то компилятор уже вычислил key, и во время выполнения тратится меньше ресурсов.

class Foo { public function BAR() { } }
$a = new Foo;
$b = 'bar';
$a->bar(); /* static call : good */
$a->$b(); /* dynamic call : bad */

Во время компиляции функции/метода происходит немедленный перевод в нижний регистр. Вышеприведённая функция BAR() превращается в bar() компилятором при добавлении метода таблице классов и функций.

В приведённом примере первый вызов статический: компилятор вычислил key для строковой “bar”, а когда приходит время вызова метода, ему нужно делать меньше работы. Второй вызов уже динамический, компилятор ничего не знает о “$b”, не может вычислить key для вызова метода. Затем, во время выполнения, нам придётся перевести строковую в нижний регистр и вычислить её хеш (zend_hash_func()), что не лучшим образом сказывается на производительности.

Что касается __call(), то она не настолько сильно снижает производительность. Тем не менее, в этом случае тратится больше ресурсов, чем при вызове существующей функции.

Управление атрибутами объекта

Вот что происходит:

Как видите, когда создаётся несколько объектов одного класса, движок перенаправляет каждый атрибут на тот же указатель, что и в случае с атрибутами класса. На протяжении своей жизни класс хранит не только свои, статические, атрибуты, но также и атрибуты объектов. В случае с внутренними классами — в течение всего времени работы PHP. Создание объекта не подразумевает создания его атрибутов, так что это довольно быстрый и экономичный подход. Только когда объект собирается поменять один из своих атрибутов, движок создаёт для этого новый, предполагая, что вы меняете атрибут $a объекта Foo #2:

Так что, создавая объект, мы «всего лишь» создаём структуру zend_object весом 32 байта:

typedef struct _zend_object {
    zend_class_entry *ce;
    HashTable *properties;
    zval **properties_table;
    HashTable *guards; /* protects from __get/__set ... recursion */
} zend_object;

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

ZEND_API zend_object_value zend_objects_new(zend_object **object, zend_class_entry *class_type TSRMLS_DC)
{
    zend_object_value retval;
    *object = emalloc(sizeof(zend_object));
    (*object)->ce = class_type;
    (*object)->properties = NULL;
    (*object)->properties_table = NULL;
    (*object)->guards = NULL;
    /* Add the object into the store */
    retval.handle = zend_objects_store_put(*object, (zend_objects_store_dtor_t) zend_objects_destroy_object, (zend_objects_free_object_storage_t) zend_objects_free_object_storage, NULL TSRMLS_CC);
    retval.handlers = &std_object_handlers;
    return retval;
}

Далее движок создаёт вектор признаков нашего объекта:

ZEND_API void object_properties_init(zend_object *object, zend_class_entry *class_type)
{
    int i;
    if (class_type->default_properties_count) {
        object->properties_table = emalloc(sizeof(zval*) * class_type->default_properties_count);
        for (i = 0; i < class_type->default_properties_count; i++) {
            object->properties_table[i] = class_type->default_properties_table[i];
            if (class_type->default_properties_table[i]) {
#if ZTS
                ALLOC_ZVAL( object->properties_table[i]);
                MAKE_COPY_ZVAL(&class_type->default_properties_table[i], object->properties_table[i]);
#else
                Z_ADDREF_P(object->properties_table[i]);
#endif
            }
        }
        object->properties = NULL;
    }
}

Как видите, мы разместили в памяти таблицу/вектор (как в языке С) для zval*, основанный на объявленных свойствах класса объекта. В случае непоточнобезопасного PHP мы просто добавляем к признаку refcount, а если используется поточнобезопасный Zend (ZTS, Zend thread safety), то нужно полностью скопировать zval. Это один из многочисленных примеров, подтверждающих низкую производительность и высокую ресурсоёмкость режима ZTS по сравнению с не ZTS PHP.

Вероятно, у вас возникли два вопроса:

  • Чем отличаются properties_table и properties в структуре zend_object?
  • Если мы поместили атрибуты нашего объекта в С-вектор, то как вернуть их обратно? Каждый раз просматривать вектор (что снижает производительность)?

Ответ на оба вопроса даёт zend_property_info.

typedef struct _zend_property_info {
    zend_uint flags;
    const char *name;
    int name_length;
    ulong h;
    int offset;
    const char *doc_comment;
    int doc_comment_len;
    zend_class_entry *ce;
} zend_property_info;

Каждый объявленный атрибут (свойство) нашего объекта имеет соответствующую информацию о свойстве, добавляемую в поле property_info в zend_class_entry. Делается это во время компиляции объявленных в классе атрибутов:

class Foo
{
    public $a = 'foo';
    protected $b;
    private $c;
}
struct _zend_class_entry {
        /* ... ... */
        HashTable function_table;
        HashTable properties_info; /* here are the properties infos about $a, $b and $c */
        zval **default_properties_table; /* and here, we'll find $a, $b and $c with their default values */
        int default_properties_count; /* this will have the value of 3 : 3 properties */
        /* ... ... */

Properties_infos представляет собой таблицу, сообщающую объекту о существовании запрашиваемого атрибута. И если он существует, то передаёт его индексный номер в массиве object->properties. Потом мы проверяем видимость и доступ к scope (public/protected/private).

Если же атрибут не существует и нам нужно записать в него, то можно попытаться вызвать __set(). В случае неудачи создаём динамический атрибут, который будет храниться в поле object->property_table.

property_info = zend_get_property_info_quick(zobj->ce, member, (zobj->ce->__set != NULL), key TSRMLS_CC);
if (EXPECTED(property_info != NULL) &&
    ((EXPECTED((property_info->flags & ZEND_ACC_STATIC) == 0) &&
     property_info->offset >= 0) ?
        (zobj->properties ?
            ((variable_ptr = (zval**)zobj->properties_table[property_info->offset]) != NULL) :
            (*(variable_ptr = &zobj->properties_table[property_info->offset]) != NULL)) :
        (EXPECTED(zobj->properties != NULL) &&
          EXPECTED(zend_hash_quick_find(zobj->properties, property_info->name, property_info->name_length+1, property_info->h, (void **) &variable_ptr) == SUCCESS)))) {
/* ... ... */
} else {
    zend_guard *guard = NULL;
        if (zobj->ce->__set && /* class has a __set() ? */
        zend_get_property_guard(zobj, property_info, member, &guard) == SUCCESS &&
        !guard->in_set) {
        Z_ADDREF_P(object);
        if (PZVAL_IS_REF(object)) {
            SEPARATE_ZVAL(&object);
        }
        guard->in_set = 1; /* prevent circular setting */
        if (zend_std_call_setter(object, member, value TSRMLS_CC) != SUCCESS) { /* call __set() */
        }
        guard->in_set = 0;
        zval_ptr_dtor(&object);
    /* ... ... */

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

Объекты, ведущие себя как ссылки благодаря хранилищу объектов

Объекты не являются ссылками. Это демонстрируется на маленьком скрипте:

function foo($var) {
    $var = 42;
}
$o = new MyClass;
foo($o);
var_dump($o); /* this is still an object, not the integer 42 */

Все сейчас скажут, что «в PHP 5 объекты являются ссылками», об этом упоминает даже официальный мануал. Технически это совершенно неверно. Тем не менее, объекты могут вести себя так же, как и ссылки. Например, когда вы передаёте переменную, являющуюся объектом функции, эта функция может модифицировать тот же объект.

Так происходит потому, что zval, передаваемый в виде функции, передаёт не сам объект, а его уникальный идентификатор, используемый для поиска в общем хранилище объектов. А результат получается тот же самый. Можно разместить в памяти три разных zval, и все они могут содержать один и тот же дескриптор объекта.

object(MyClass)#1 (0) { } /* #1 is the object handle (number), it is unique */

Zend_object_store обеспечивает однократное занесение объектов в память. Единственный способ записать в хранилище заключается в создании нового объекта с ключевым словом new, функцией unserialize(), reflection API или ключевым словом clone. Никакие другие операции не позволят дуплицировать или создать новый объект в хранилище.

typedef struct _zend_objects_store {
    zend_object_store_bucket *object_buckets;
    zend_uint top;
    zend_uint size;
    int free_list_head;
} zend_objects_store;
typedef struct _zend_object_store_bucket {
    zend_bool destructor_called;
    zend_bool valid;
    zend_uchar apply_count;
    union _store_bucket {
        struct _store_object {
            void *object;
            zend_objects_store_dtor_t dtor;
            zend_objects_free_object_storage_t free_storage;
            zend_objects_store_clone_t clone;
            const zend_object_handlers *handlers;
            zend_uint refcount;
            gc_root_buffer *buffered;
        } obj;
        struct {
            int next;
        } free_list;
    } bucket;
} zend_object_store_bucket;

 

Что такое $this?

Понять устройство $this не так уж сложно, но с этим инструментом связаны куски кода в нескольких местах движка: в компиляторе, в коде получения переменных во время выполнения и т.д. $thisпоявляется и исчезает по мере необходимости, автоматически присваивая себе текущий объект — в общем, «волшебная» штука. А внутренний код прекрасно позволяет ей управлять.

Во-первых, компилятор не позволит записывать в $this. Для этого он проверят каждое осуществляемое вами присваивание, и если обнаруживает присвоение $this, то возникает фатальная ошибка.

/* ... ... */
 if (opline_is_fetch_this(last_op TSRMLS_CC)) {
    zend_error(E_COMPILE_ERROR, "Cannot re-assign $this");
}
/* ... ... */
static zend_bool opline_is_fetch_this(const zend_op *opline TSRMLS_DC)
{
    if ((opline->opcode == ZEND_FETCH_W) && (opline->op1_type == IS_CONST)
        && (Z_TYPE(CONSTANT(opline->op1.constant)) == IS_STRING)
        && ((opline->extended_value & ZEND_FETCH_STATIC_MEMBER) != ZEND_FETCH_STATIC_MEMBER)
        && (Z_HASH_P(&CONSTANT(opline->op1.constant)) == THIS_HASHVAL)
        && (Z_STRLEN(CONSTANT(opline->op1.constant)) == (sizeof("this")-1))
        && !memcmp(Z_STRVAL(CONSTANT(opline->op1.constant)), "this", sizeof("this"))) {
        return 1;
    } else {
        return 0;
    }
}

Как управляется $this? Его использование возможно только внутри метода, во время вызова которого компилятор генерирует OPCode INIT_METHOD_CALL. Движок знает, кто вызывает метод, в случае с $a->foo() это $a. После чего извлекается значение $a и сохраняется в общем пространстве. Далее происходит вызов метода с помощью OPCode DO_FCALL. На этом этапе снова извлекается сохранённое значение (объект вызывает метод) и присваивается глобальному внутреннему $this-указателю — EG(This).

if (fbc->type == ZEND_USER_FUNCTION || fbc->common.scope) {
    should_change_scope = 1;
    EX(current_this) = EG(This);
    EX(current_scope) = EG(scope);
    EX(current_called_scope) = EG(called_scope);
    EG(This) = EX(object); /* fetch the object prepared in previous INIT_METHOD opcode and affect it to EG(This) */
    EG(scope) = (fbc->type == ZEND_USER_FUNCTION || !EX(object)) ? fbc->common.scope : NULL;
    EG(called_scope) = EX(call)->called_scope;
}

Теперь, когда метод вызван, если в его теле вы используете $this для действия с переменной или вызова метода (например, $this->a = 8), то это приведёт к OPCode ZEND_ASSIGN_OBJ, который, в свою очередь, обратно извлечёт $this из EG(This).

static zend_always_inline zval **_get_obj_zval_ptr_ptr_unused(TSRMLS_D)
{
    if (EXPECTED(EG(This) != NULL)) {
        return &EG(This);
    } else {
        zend_error_noreturn(E_ERROR, "Using $this when not in object context");
        return NULL;
    }
}

В том случае, если вы использовали $this для вызова метода (например, $this->foo()) или передали другому вызову функции ($this->foo($this);), то движок попытается извлечь $this из текущей символьной таблицы, как он это делает для каждой стандартной переменной. Но здесь осуществляется специальная подготовка в ходе создания кадра стека текущей функции:

if (op_array->this_var != -1 && EG(This)) {
     Z_ADDREF_P(EG(This));
    if (!EG(active_symbol_table)) {
        EX_CV(op_array->this_var) = (zval **) EX_CV_NUM(execute_data, op_array->last_var + op_array->this_var);
        *EX_CV(op_array->this_var) = EG(This);
    } else {
        if (zend_hash_add(EG(active_symbol_table), "this", sizeof("this"), &EG(This), sizeof(zval *), (void **) EX_CV_NUM(execute_data, op_array->this_var))==FAILURE) {
            Z_DELREF_P(EG(This));
        }
    }
}

Когда мы вызываем метод, движок изменяет область видимости:

if (fbc->type == ZEND_USER_FUNCTION || fbc->common.scope) {
    /* ... ... */
    EG(scope) = (fbc->type == ZEND_USER_FUNCTION || !EX(object)) ? fbc->common.scope : NULL;
    /* ... ... */
}

EG(scope) относится к типу zend_class_entry. Это класс, которому принадлежит запрашиваемый вами метод. И он будет использоваться для любой операции с объектом, которую вы будете выполнять в теле метода после проверки видимости движком:

static zend_always_inline int zend_verify_property_access(zend_property_info *property_info, zend_class_entry *ce TSRMLS_DC)
{
    switch (property_info->flags & ZEND_ACC_PPP_MASK) {
        case ZEND_ACC_PUBLIC:
            return 1;
        case ZEND_ACC_PROTECTED:
            return zend_check_protected(property_info->ce, EG(scope));
        case ZEND_ACC_PRIVATE:
            if ((ce==EG(scope) || property_info->ce == EG(scope)) && EG(scope)) {
                return 1;
            } else {
                return 0;
            }
            break;
    }
    return 0;
}

Вот так можно получать доступ к приватным членам объектов, не принадлежащим вам, но являющимся дочерними по отношению к вашей текущей области видимости:

class A
{
    private $a;
    public function foo(A $obj)
    {
        $this->a = 'foo';
        $obj->a  = 'bar'; /* yes, this is possible */
    }
}
$a = new A;
$b = new A;
$a->foo($b);

Эта особенность стала причиной большого количества баг-репортов от разработчиков. Но так устроена объектная модель в PHP — на самом деле, мы задаём область видимости на основе не объекта, а класса. В случае с нашим классом “Foo”, вы можете работать с любым приватным Foo любого другого Foo, как показано выше.

О деструкторе

Деструкторы опасны, не полагайтесь на них, поскольку PHP их не вызывает даже в случае фатальной ошибки:

class Foo { public function __destruct() { echo "byebye foo"; } }
$f = new Foo;
thisfunctiondoesntexist();
/* fatal error, function not found, the Foo's destructor is NOT run */

А что насчёт порядка вызова деструкторов в том случае, если они всё-таки вызываются? Ответ хорошо виден в коде:

void shutdown_destructors(TSRMLS_D)
{
    zend_try {
        int symbols;
        do {
            symbols = zend_hash_num_elements(&EG(symbol_table));
            zend_hash_reverse_apply(&EG(symbol_table), (apply_func_t) zval_call_destructor TSRMLS_CC);
        } while (symbols != zend_hash_num_elements(&EG(symbol_table)));
        zend_objects_store_call_destructors(&EG(objects_store) TSRMLS_CC);
    } zend_catch {
        /* if we couldn't destruct cleanly, mark all objects as destructed anyway */
        zend_objects_store_mark_destructed(&EG(objects_store) TSRMLS_CC);
    } zend_end_try();
}
static int zval_call_destructor(zval **zv TSRMLS_DC)
{
    if (Z_TYPE_PP(zv) == IS_OBJECT && Z_REFCOUNT_PP(zv) == 1) {
        return ZEND_HASH_APPLY_REMOVE;
    } else {
        return ZEND_HASH_APPLY_KEEP;
    }
}

Здесь продемонстрированы три стадии вызова деструктора:

  • Цикличный просмотр глобальной символьной таблицы в обратном направлении и вызов деструкторов для объектов, у которых refcount = 1.
  • Затем направление цикличности меняется, а деструкторы вызываются уже для всех остальных объектов, с refcount > 1.
  • Если на одном из предыдущих этапов возникает проблема, то вызов оставшихся деструкторов прерывается.

К чему это приводит:

class Foo { public function __destruct() { var_dump("destroyed Foo"); } }
class Bar { public function __destruct() { var_dump("destroyed Bar"); } }

Пример первый:

$a = new Foo;
$b = new Bar;
"destroyed Bar"
"destroyed Foo"

Тот же пример:

$a = new Bar;
$b = new Foo;
"destroyed Foo"
"destroyed Bar"

Пример второй:

$a = new Bar;
$b = new Foo;
$c = $b; /* increment $b's object refcount */
"destroyed Bar"
"destroyed Foo"

Пример третий:

class Foo { public function __destruct() { var_dump("destroyed Foo"); die();} } /* notice the die() here */
class Bar { public function __destruct() { var_dump("destroyed Bar"); } }
$a = new Foo;
$a2 = $a;
$b = new Bar;
$b2 = $b;
destroyed Foo

Эта процедура была выбрана не просто так. Но если она вас не устраивает, то лучше уничтожайте свои объекты самостоятельно. Это единственный способ контролировать вызовы __destruct(). Если вы предоставите PHP делать это за вас, то не возмущайтесь потом результатами его работы. У вас всегда есть возможность уничтожать свои объекты вручную, чтобы полностью контролировать очерёдность.

PHP не вызывает деструкторы в случае возникновения какой-либо фатальной ошибки. Дело в том, что в этом случае Zend работает нестабильно, а вызов деструкторов приводит к выполнению пользовательского кода, который может получить доступ к ошибочным указателям и, в результате, к падению PHP. Уж лучше сохранять стабильность системы — поэтому вызов деструкторов и блокируется. Возможно, в PHP 7 что-то и поменяется.

Что касается рекурсий, то в PHP они слабо защищены, да и относится это только к __get() и __set(). Если вы уничтожаете свой объект где-то в стековом кадре деструктора, то окажетесь в бесконечном рекурсивном цикле, который сожрёт все ресурсы вашего стека процесса (обычно 8 кбайт, ulimit –s) и сломает PHP.

class Foo
{
    public function __destruct() { new Foo; } /* you will crash */
}

Суммируя вышесказанное: не доверяйте деструкторам критически важный код, например, управление механизмом блокировки (lock mechanism), поскольку PHP может и не вызвать деструктор или вызвать его в неконтролируемой последовательности. Если всё-таки важный код обрабатывается деструктором, то как минимум самостоятельно контролируйте жизненный цикл объектов. PHP вызовет деструктор, когда refcount вашего объекта упадёт до нуля, а это значит, что объект больше не используется и его можно безопасно уничтожить.

Заключение

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