javascript

Смена типа в объекте тоже не бесплатна

  • воскресенье, 16 августа 2026 г. в 00:00:07
https://habr.com/ru/articles/1070790/

В прошлой статье про объекты и скорость доступа к известному полю я рассказал про hidden classes, inline cache и форму объекта. Практический совет там был простой: если поле опциональное, создай его сразу и положи null. Тогда экземпляры сохранят одинаковую форму.

Совет рабочий, но неполный. Форма объекта у V8 может остаться прежней, а вот внутренний контракт хранения поля может измениться. Если горячая функция успела прогреться под старый контракт, V8 пометит её на деоптимизацию.

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

Map знает не только форму

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

Упрощённо нужная нам часть выглядит так:

Smi        маленькое целое, закодированное прямо в tagged-слоте
Double     число с плавающей точкой
HeapObject ссылка на объект в куче: строка, объект, `null`, `undefined` и так далее
Tagged     широкое представление, которое принимает и Smi, и ссылки

Это контракт хранения поля. В самом V8 отдельно существует ещё и field type, но для перехода из number в string достаточно представления.

Когда V8 много раз видит { x: 42 }, поле x получает представление Smi. Оптимизатор по известной форме читает поле как маленькое целое и строит код под это допущение.

Потом кто-то делает так:

obj.x = 'jopa'

Строка в Smi не помещается. V8 расширяет представление до Tagged, чтобы поле могло хранить и целые числа, и ссылки на объекты в куче.

Ключевой момент: это не обязательно создаёт новую форму (shape, Map). Для некоторых расширений V8 обновляет дескриптор на месте, и адрес Map остаётся тем же. Но вот оптимизированный код зависел не только от адреса Map, а ещё и от представления. Старому коду больше нельзя доверять.

Вот сам деопт

Минимальный пример проверен на Node.js 24.17.0 с V8 13.6.233.17-node.49:

function readX(object) {
  return object.x
}

const a = { x: 1 }
const b = { x: 2 }

// До прогрева помечаем поля как изменяемые (kConst/kMutable в V8)
a.x = 3
b.x = 4

for (let i = 0; i < 100_000; i++) {
  readX(i & 1 ? a : b)
}

// --allow-natives-syntax
%OptimizeFunctionOnNextCall(readX)
readX(a)

a.x = 'jopa'

Запускаем:

node --allow-natives-syntax --trace-deopt demo.js

В трейсе будет строка такого вида:

[marking dependent code ... (readX) for deoptimization,
 reason: dependent field representation changed]

Одна запись строки пометила уже оптимизированную readX на деоптимизацию. На этой версии V8 %HaveSameMap(a, b) остаётся истинным и после присваивания строки: форма та же, Map тот же, а вот зависимый код всё равно отправлен на деоптимизацию.

%OptimizeFunctionOnNextCall и %HaveSameMap являются внутренними средствами диагностики V8. В прод тащить их, разумеется, не нужно.

Где здесь подвох с null

Вернёмся к совету «создай поле заранее и положи null»:

const user = { id, name, role: null }

if (isAdmin) {
  user.role = 'admin'
}

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

С числом история иная:

const stats = { score: null } // HeapObject
stats.score = 42              // теперь поле должно принять ещё и Smi, получаем Tagged

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

Для целочисленного поля лучше сразу задать числовое значение:

const stats = { score: 0 }
stats.score = 42

С undefined та же история, что с null: это не числовая заглушка. А если поле хранит дробные числа, одного 0 тоже недостаточно для идеальной стабильности. Переход от Smi к Double может потребовать ещё одно расширение и привести к деоптимизации.

Но не стоит гнаться за этим и превращать в абсурд. Если null является нормальным и честным состоянием бизнес-модели, оставь null. Все конечно же зависит от контекста. Семантика бизнесовой модели важнее потенциальной деоптимизации на прогреве. А вот для горячей структуры идеальный вариант другой: присвоить реальное значение до того, как код станет горячим.

Расширение происходит не на каждом присваивании

Тут можно сделать неправильный вывод, вроде как каждое переключение 42 → 'jopa' → 42 снова деоптимизирует функцию из-за представления.

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

Деопты всё ещё возможны, если горячий код специализировался на фактических значениях. Например, выражение object.x + 1 сначала выполняет числовое сложение, а со строкой начинает конкатенацию.

Это уже другая история: у операции своя обратная связь по типам, а у конкатенации ещё и свои аллокации. Без --trace-deopt эти эффекты не увидеть. В трейсе будет видно, что у смены представления, обратной связи операции и конкатенации разные причины.

Поэтому я не буду продавать красивое «переключение типов замедляет цикл ровно в два раза». Такой микробенч легко измеряет одновременно чтение поля, смену представления, арифметическую специализацию, конкатенацию и повторную оптимизацию. Цифра получится эффектной, но инженерного смысла в ней будет примерно ни фи га.

Тут в целом достаточно трейса: первое расширение представления способно отправить на деоптимизацию весь оптимизированный код, который от него зависит. Цена конкретного деопта зависит от функции, нагрузки, версии V8 и того, сколько зависимого кода придётся пересобрать.

Что делать

В большинстве случаев ничего. Это не призыв писать TypeScript ради V8 и не повод заменять честный null на ноль. Это просто ещё один контракт, который ты заключаешь с рантаймом на горячем пути.

  • Создавай горячие объекты с полным и стабильным набором полей.

  • Для целочисленного поля используй числовое начальное значение, а не null или undefined.

  • Присваивай реальное значение до прогрева, особенно если поле может хранить дробные числа.

  • Не смешивай число и строку в одном поле без причины.

  • Проверяй подозрения через --trace-deopt на той версии Node.js, которая работает у тебя в проде.

Что в итоге

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

Переход number → string опасен не потому, что строка сама по себе медленная. Проблема возникает в момент, когда ломается контракт уже скомпилированной функции. V8 расширяет поле, помечает зависимый код на деоптимизацию и затем работает с более широким представлением.

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

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

Что почитать