Эти три объявления свойств — синтаксис, добавленный RFC о хуках свойств в 8.4, — во время выполнения делают три разные вещи. Компилятор же генерирует для всех них один и тот же опкод.

 <?php

final class Plain { public int $v = 0; }
final class Backed { public int $v = 0 { get => $this->v; } }
final class Virt { public int $u = 0; public int $v { get => $this->u; } }

function readPlain(Plain $o): int { return $o->v; }
function readBacked(Backed $o): int { return $o->v; }
function readVirtual(Virt $o): int { return $o->v; } 
 readPlain:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1

readBacked:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1

readVirtual:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1 

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

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

PHP 8.5.10, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro с 24 ГБ и 12 логическими ядрами, под управлением macOS, без перевода системы в режим покоя. В этой сборке OPcache вкомпилирован внутрь, а не подключен как разделяемое расширение, поэтому  --ri "Zend OPcache"  отвечает там, где  --ri opcache  молчит.

По умолчанию в архиве OPcache и JIT отключены, и в основной таблице ниже сохранены эти параметры. Две дополнительные конфигурации подписаны там, где они появляются: включенный OPcache с отключенным JIT, а также JIT в обоих его режимах. Дампы опкодов получены из  opcache.opt_debug_level=0x20000 , который выводит каждую функцию после работы оптимизатора — тот же вывод, из которого статья о жизненном цикле запроса читает  $_main :

 php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 dump-access.php 

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

Хук — это функция, и дамп это подтверждает

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

 Backed::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("v")
0001 RETURN T0

Virt::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("u")
0001 RETURN T0 

Каждый хук компилируется в функцию со своим собственным массивом опкодов и своим именем — именем свойства с суффиксом  ::get . Ничто в вызывающей функции не ссылается на них. Связь устанавливается в рантайме обработчиком за  FETCH_OBJ_R , который проверяет, есть ли у разрешенного свойства хуки, и вызывает один из них, если они есть.

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

Одну деталь внутри  Backed::$v::get  стоит прочитать дважды:  $this->v  скомпилировался в обычный  FETCH_OBJ_R , а не в еще один вызов хука. Внутри собственного хука backed-свойство обращается к backing storage, что и предотвращает очевидную бесконечную рекурсию.

Геттер — единственная из четырех форм, которая выглядит иначе:

 readGetter:
0000 CV0($o) = RECV 1
0001 INIT_METHOD_CALL 0 CV0($o) string("getV")
0002 V1 = DO_FCALL
0003 VERIFY_RETURN_TYPE V1
0004 RETURN V1 

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

Виртуальное свойство не занимает слот

По части памяти всё сходится с обещаниями. У виртуального свойства нет backing storage, поэтому оно ничего не забирает из таблицы свойств, которую в этой серии статей оценили в 40 байт плюс 16 на слот:

 ThreeOnly            96 bytes
ThreePlusVirtual     96 bytes
Four                112 bytes
FourBackedHooks     112 bytes 

Три объявленных свойства и три-плюс-одно-виртуальное — это один и тот же объект. Четыре объявленных свойства и четыре свойства с backed-хуками — тоже один и тот же объект. Так что на вопрос о памяти есть четкий ответ: хук на backed-свойстве ничего не стоит, а виртуальное свойство экономит слот, который вычисляемое значение иначе заняло бы, если бы вы его кэшировали.

Сколько стоит чтение

Пять миллионов чтений одного целого числа за вычетом пустого цикла в 2,986 нс:

Чтение Всего Чистое
Обычное свойство 4.323 нс (4.307–4.403) 1.337 нс
Метод-геттер 11.807 нс (11.534–12.249) 8.821 нс
Виртуальный хук 14.314 нс (13.525–14.592) 11.328 нс
Backed-хук 15.054 нс (14.566–15.344) 12.068 нс
Виртуальный хук с арифметикой 15.825 нс (15.324–16.108) 12.839 нс
 __get  29.880 нс (28.048–30.541) 26.894 нс

Backed-хук работает в 9,0 раз медленнее обычного свойства, на которое он внешне похож, и в 1,37 раза медленнее метода-геттера, который он призван заменить. Арифметика внутри третьего хука — умножение и сложение — отняла 1,5 нс из 12,8, так что почти вся стоимость хука заключается в самом факте того, что это хук.

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

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

Хук, который вы не объявили, ничего не стоит

Ветка, которая всем этим управляет, находится в  zend_std_read_property  в  zend_object_handlers.c , и она выбирается по смещению свойства, а не по чему-либо в месте вызова:

 } else if (IS_HOOKED_PROPERTY_OFFSET(property_offset)) {
    zend_function *get = prop_info->hooks[ZEND_PROPERTY_HOOK_GET];
    if (!get) {
        ...
        /* Cache the fact that this hook has trivial read. This only applies to
         * BP_VAR_R and BP_VAR_IS fetches. */
        ZEND_SET_PROPERTY_HOOK_SIMPLE_READ(cache_slot);

        retval = OBJ_PROP(zobj, prop_info->offset); 

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

Чтение Всего Чистое
Обычное свойство 4.373 нс (4.346–4.516) 1.347 нс
Свойство только с хуком  set  4.569 нс (4.533–4.718) 1.543 нс
Свойство с хуком  get  15.012 нс (14.559–15.491) 11.986 нс

Объявление хука  set  не стоит чтению ничего ощутимого — 0,196 нс, что укладывается в разброс между прогонами для строки с обычным свойством. Накладные расходы приходятся конкретно на хук  get , и валидации при записи, ради которой и создается большинство хуков, не приходится за это расплачиваться.

Запись стоит дороже чтения

На стороне  set  разрыв оказывается наибольшим:

Запись Всего Чистое
Обычное свойство 4.291 нс (4.261–4.340) 1.305 нс
Метод-сеттер 12.798 нс (12.540–13.268) 9.812 нс
Хук  set  23.892 нс (23.179–24.527) 20.906 нс

Хук  set  в 2,13 раза медленнее эквивалентного метода-сеттера и в 16 раз медленнее обычного присваивания. Хук, выполняющий валидацию при записи — случай, ради которого эта фича и существует, — стартует с этой отметки и прибавляет к ней всё, чего стоит сама валидация.

JIT делает только хуже

Очевидная надежда заключается в том, что JIT сократит этот разрыв. Но он делает обратное. Тот же бенчмарк под tracing JIT за вычетом базовой линии в 1,184 нс в этой конфигурации:

Доступ Без JIT Tracing JIT
Обычное чтение 1.337 нс 0.388 нс
Метод-геттер 8.821 нс 3.281 нс
Чтение виртуального хука 11.328 нс 19.250 нс
Чтение backed-хука 12.068 нс 25.484 нс
Обычная запись 1.305 нс 2.396 нс
Метод-сеттер 9.812 нс 4.771 нс
Хук  set  20.906 нс 27.567 нс

JIT сократил время обычного чтения до менее чем половины наносекунды, а вызов метода — в три раза. Чтение с хуком замедлилось. Что бы JIT ни делал с циклом, вызов хука в это не входит, и в итоге две формы различаются в 7,8 раза в сторону, противоположную той, на которую намекает синтаксис.

Function JIT дает ту же картину с меньшими цифрами — 0,818 нс для обычного, 4,769 для геттера, 26,091 для backed-хука, за вычетом базовой линии 2,941 нс в этом режиме, — так что это не причуда трассирующей компиляции. Оба режима оптимизируют обычный доступ и вызов метода, и ни один не добирается до хука.

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

Сколько обращений требуется, чтобы это стало заметно

Само по себе всё это ничего не решает, потому что наносекунда есть наносекунда. Число, которое всё решает, — это количество обращений.

Без JIT разница между хуком и геттером составляет 3,247 нс, так что около 308 000 обращений за запрос дают одну миллисекунду разницы. С tracing JIT разница составляет 22,203 нс, и ту же миллисекунду дают уже 45 000 обращений.

Это не какие-то запредельные цифры для пути гидратации. Результирующий набор ORM из 5 000 сущностей с двенадцатью запрашиваемыми полями у каждой — это 60 000 чтений еще до того, как произойдет что-либо еще, а шаблон, который повторно обращается к той же view model, умножает это число снова.

Ориентир, к которому восходит большинство советов, — это бенчмарк 8.4 от Tideways, в котором сообщается «всего лишь о 9% разницы в производительности» между хуками и методами геттеров/сеттеров и делается вывод, что «производительность не должна вызывать беспокойства» при выборе между ними. В тексте статьи не указаны версия PHP, количество итераций или конфигурация JIT — код спрятан за ссылкой, — поэтому я не могу согласовать эти два результата, могу лишь сообщить свой и его условия. На этой сборке разрыв составляет 37% без JIT и от 5,5 до 7,8 раза с ним, а по сравнению с обычным свойством, которое в том обсуждении описывается как практически идентичное, он составляет 9,0 раз.

В чем обе точки зрения сходятся, так это в самой важной части: при обычном количестве обращений доступ к свойствам составляет малую долю запроса, и эта разница является шумом. Разногласие заключается в том, что считать «обычным», а это число берется из вашего собственного профилирования, а не из какого-либо из наших бенчмарков.

Что делать с аксессорами

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

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

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

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