Сто тысяч записей, по четыре поля в каждой. В виде объектов с объявленными свойствами они занимают по 143,54 байта на штуку. В виде ассоциативных массивов с теми же четырьмя ключами — 397,14.

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

Как были получены эти цифры

PHP 8.5.10, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro с 24 ГБ памяти и 12 логическими ядрами под управлением macOS, без изоляции процессов. OPcache и JIT отключены, а  memory_limit  равен 4G.

Показатели памяти представляют собой разницу (дельта)  memory_get_usage()  и совпали с точностью до байта во всех 15 прогонах для каждой конфигурации, поэтому они указаны одиночными значениями. Время выполнения — это медиана 15 чередующихся запусков с отбрасыванием первых трёх прогревочных, измеренная с помощью  hrtime()  внутри процесса, с указанием диапазона. Воспринимайте время относительно друг друга, а не как абсолютные величины, в силу причин, изложенных в статье о методологии.

Там, где значение указано на один экземпляр, это общий объём за прогон, делённый на количество экземпляров, и сюда входят две вещи, которых сам экземпляр не содержит: упакованный массив, хранящий экземпляры (21,05 байта на слот), и хранилище объектов (object store) движка, о котором пойдёт речь ниже. Оба этих накладных расхода одинаковы для всех сравниваемых структур. Размеры изолированных объектов приведены отдельно и абсолютно точны.

Объявленное свойство — это слот, а не запись

Класс знает свои свойства ещё на этапе компиляции, поэтому движок нумерует их один раз и сохраняет значения в обычном C-массиве внутри собственной выделенной памяти объекта. Таблица соответствия имён и слотов принадлежит классу и разделяется всеми экземплярами.

Благодаря этому размер экземпляра рассчитывается простой арифметикой:

 <?php

declare(strict_types=1);

class P0 {}
class P1 { public $a; }
class P2 { public $a, $b; }
class P4 { public $a, $b, $c, $d; }
class P8 { public $a, $b, $c, $d, $e, $f, $g, $h; }

foreach (['P0', 'P1', 'P2', 'P4', 'P8'] as $class) {
    $baseline = memory_get_usage();
    $instance = new $class();
    printf("%-3s %4d bytes\n", $class, memory_get_usage() - $baseline);
    unset($instance);
} 
 P0    40 bytes
P1    56 bytes
P2    80 bytes
P4   112 bytes
P8   192 bytes 

Сорок байт заголовка плюс шестнадцать на каждое свойство с округлением вверх до размера бина, который выдаёт аллокатор: 40 + 16 дают ровно 56, 40 + 64 дают 104 и округляются до 112, 40 + 128 дают 168 и превращаются в 192. Заголовок здесь тот же самый, что у клона класса с одним свойством, занявшего 56 байт в первой статье этого цикла.

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

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

Массив так не умеет, и он округляет вверх

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

Пошаговое добавление ключей в обе структуры наглядно показывает, к чему ведёт этот порог:

 keys        assoc       list
1             408          216
2             440          216
4             504          216
8             632          216
9             984          376 

Список из одного элемента стоит столько же, сколько список из восьми. Колонка ассоциативного массива растёт на 32 байта с каждым ключом, но это не сам бакет — это строка ключа,  zend_string  для  field0  и ему подобных. Сами же бакеты были выделены на минимальном пороге, как и слоты списка.

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

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

Где находится точка пересечения

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

Поля Объект Ассоциативный массив Список
1 87.38 397.05 237.05
2 111.38 397.05 237.05
4 143.38 397.05 237.05
8 223.38 397.05 237.05
16 351.38 717.05 397.05
32 671.38 1,357.05 717.05
64 1,311.38 2,637.05 1,357.05

Байт на экземпляр. В этом диапазоне точки пересечения попросту нет. Объект оказывается от 1,8 до 4,5 раз меньше ассоциативного массива при любом числе полей, и он меньше даже простого списка при любой ширине — включая 64 поля, где стоимость каждого отдельного поля в объекте имела наибольший простор для накопления.

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

Каждый живой объект стоит восемь байт в другом месте

Столбец объектов не сводится в точности к размеру отдельного объекта плюс массив-хранилище. Пятьдесят тысяч объектов с одним свойством стоят 87,38 байта каждый; сам объект занимает 56, а слот массива — 21,05, что оставляет неучтёнными 10,33 байта.

Дело в хранилище объектов (object store). Движок держит таблицу указателей на каждый живой объект, по одному  zend_object *  на каждый дескриптор (handle), которая расширяется в  zend_objects_API.c  с помощью  new_size = 2 * size  и  erealloc()  — соответственно, память под неё выделяется аллокатором запроса и учитывается в  memory_get_usage() . Для пятидесяти тысяч живых объектов требуется таблица на 65 536 указателей, что даёт 10,49 байта на объект против 10,33, оставшихся в результате замеров.

У массивов подобного эквивалента нет. Это реальные накладные расходы, они пропорциональны числу живых объектов, а не их размеру, и при четырёх полях составляют 7% от размера объекта против 176% накладных расходов у массива.

Чтение данных

Три миллиона операций чтения одного поля с замером времени внутри процесса за вычетом пустого цикла, измеренного тем же способом:

Чтение Всего Чистое время (за вычетом 3.023 нс на цикл)
Объявленное свойство 4.325 нс (4.316–4.371) 1.302 нс
Свойство  readonly  4.355 нс (4.337–4.387) 1.332 нс
Элемент списка по индексу 4.521 нс (4.482–4.570) 1.498 нс
Динамическое свойство 4.781 нс (4.749–4.879) 1.758 нс
Ассоциативный ключ 7.973 нс (7.757–8.070) 4.950 нс

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

Чтение свойств  readonly  бесплатно, о чём стоит сказать отдельно, поскольку именно перед этим модификатором разработчики часто колеблются. Дополнительную проверку он привносит только при записи.

Единственное свойство, которое всё переворачивает

Теперь обратная ситуация. Стоит убрать объявление свойства — и объект обрастает ровно той структурой, которой он избегал:

 <?php

declare(strict_types=1);

#[\AllowDynamicProperties]
class Bag {}

$baseline = memory_get_usage();
$bag = new Bag();
printf("empty            %3d bytes\n", memory_get_usage() - $baseline);
$bag->one = 1;
printf("one dynamic      %3d bytes\n", memory_get_usage() - $baseline);
$bag->two = 2;
printf("two dynamic      %3d bytes\n", memory_get_usage() - $baseline);
$bag->three = 3;
printf("three dynamic    %3d bytes\n", memory_get_usage() - $baseline); 
 empty             40 bytes
one dynamic      416 bytes
two dynamic      416 bytes
three dynamic    416 bytes 

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

На ста тысячах экземпляров структуры распределяются соответствующим образом:

Структура, четыре поля Байт на экземпляр Создание, 100k
Объявленные, без типов 143.54 3.966 мс (3.852–4.250)
Объявленные, типизированные 143.54 6.016 мс (5.836–6.663)
Объявленные, promotion в конструкторе 143.54 6.459 мс (6.294–6.726)
Ассоциативный массив 397.14 4.401 мс (4.233–4.599)
Динамические свойства 447.54 8.089 мс (7.745–8.565)
 stdClass  447.54 7.580 мс (7.381–7.856)

Объект с необъявленными свойствами оказывается самой прожорливой структурой в таблице, работая хуже массива, вместо которого его обычно и берут.  stdClass  из  json_decode()  или выборка PDO в объекты приводят ровно к этому результату.

В этой строке есть и вторая статья расходов. Присвоение свойства, которое класс не объявил и не пометил атрибутом  #[\AllowDynamicProperties] , вызывает предупреждение об устаревании (deprecation), а его генерация требует ресурсов: 100 000 таких записей заняли 6,6 мс с атрибутом и 16,1 мс без него (с подавлением вывода в месте вызова, чтобы вывод не влиял на замер). В 8.5 это всё ещё просто deprecation — уровень ошибки не изменится вплоть до 9.0 — поэтому именно эти накладные расходы, а не приближающийся дедлайн, служат главной причиной исправить код.

Типы стоят времени, а не памяти

Три строки с объявленными свойствами совпадают по байтам один в один, что само по себе является выводом: типизированное свойство хранит своё значение в том же слоте, что и нетипизированное, а структуры, в которых движок держит информацию о типе, не оплачиваются самим экземпляром.

Разница кроется в записи. Создание 100 000 экземпляров заняло 3,966 мс без типов, 6,016 мс с типами и 6,459 мс с promotion в конструкторе — на 52% и 63% дольше, чем без типов, для четырёх операций присваивания в каждом. Это проверка типов, выполняющаяся четыре раза на каждый экземпляр, и это цена за гарантии типов, а не какой-то изъян.

В абсолютных величинах она также невелика: около 20 наносекунд на экземпляр с типами и 25 с promotion против 254 байт на экземпляр, в которые обошёлся бы массив.

Ходящая по сети формула осталась из 2013 года

Первоисточник, к которому восходят советы практически всех авторов, — это заметка nikic от февраля 2013 года, где для массивов приводится формула  104 + 96*n  байт, а для объектов —  128 + 8*n  для PHP 5.4 и новее. Сделанный тогда вывод оставался в силе тринадцать лет. Но ни одна из этих формул не описывает PHP 8.5: объект здесь требует 40 + 16n до выравнивания по бинам, а размер массива вовсе не линеен по  n  — это ступенчатая функция с порогом.

Обе поправки ведут к одному результату. Базовый размер объекта стал меньше, порог массива остался на месте, а разрыв при малом числе полей сейчас шире, чем предсказывали старые формулы.

Что использовать

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

К массивам стоит прибегать, когда имена полей неизвестны вплоть до рантайма — строка, чьи столбцы получены из запроса, написанного не кодом приложения, или полезная нагрузка (payload), где ключи сами являются данными. Это решение диктуется не памятью, и отдать 397 байт за хеш-таблицу, которая действительно нужна, — совсем не то же самое, что отдать их за запись с четырьмя известными полями.

Если в качестве структуры уже выбраны объекты, а память всё ещё остаётся проблемой, посчитайте их количество, прежде чем пытаться их оптимизировать. Затраты на каждый объект, не зависящие от ширины — 40 байт заголовка, 8 в хранилище объектов, 16 в удерживающей его структуре — составляют около 64 байт ещё до добавления первого поля. Поэтому сто тысяч небольших объектов имеют минимальный порог примерно в 6 МБ, что бы вы ни делали с их свойствами. И здесь вопрос сводится к тому, должны ли все они находиться в памяти одновременно, но это уже тема для отдельной статьи и совсем другого решения.