
Объект, который не является объектом
В JavaScript есть свои забавные особенности, и одна из них — оператор typeof, который определяет тип значения.
Ожидаемое поведение:
typeof 100 //число
typeof "string" //строка
typeof {} //объект
typeof Symbol //функция
typeof undefinedVariable // undefined
А вот это — наш фаворит, главный герой сегодняшней статьи:
typeof null // объект
JavaScript, как и другие языки программирования, имеет типы, которые можно разделить на «примитивные» — те, которые возвращают единственное значение (null, undefined, boolean, symbol, bigint, string), и типы «object» со сложной структурой. Проще говоря, например, boolean в JavaScript представляет собой нечто, не имеющее очень сложной структуры, так как возвращает только одно значение: true или false.
К примеру, в современной реализации Firefox используется техника под названием «pointer tagging», где 64-битное значение кодирует тип и значение или адрес в куче. Посмотрим, как в этой реализации обрабатываются булевы значения:
const flagTrue = true;
Keyword | Tag | Payload
false | JSVAL_TAG_BOOLEAN | (0xFFFE*) 0x000000000000
true | JSVAL_TAG_BOOLEAN | (0xFFFE*) 0x000000000001
Можно заметить, что старшие биты отвечают за определение типа данных, а младшие — за полезную нагрузку или адрес выделенного объекта в куче. Таким образом, в данном случае наши true/false представлены в двоичном виде как 1/0.
Вероятно, вы задаетесь вопросом, какое отношение это имеет к тому, что typeof null возвращает object вместо null.
Чтобы понять это, нужно вернуться на 30 лет назад к оригинальной реализации JavaScript в Netscape, которая использовала 32-битную схему разметки, совершенно отличную от современных движков.
Брендану Эйху, которого наняла Netscape, в то время являвшаяся основным игроком на рынке браузеров, из-за значительных требований рынка и жесткой конкуренции со стороны таких компаний, как Microsoft и Sun Microsystems, поручили создание прототипа языка программирования, который должен был соответствовать ключевым критериям:
- быть простым для широкого круга людей (без статической типизации и установки компилятора);
- позволять пользователям манипулировать DOM на базовом уровне.
Через 10 дней был создан язык программирования, который носил поочередно такие названия, как Mocha, LiveScript и, наконец, JavaScript — из-за маркетингового давления с целью использования популярности Java.
Спустя 10 дней родился прототип языка программирования, который, несмотря на последующий закат браузера Netscape из-за конкуренции с Microsoft и установки Internet Explorer по умолчанию в Windows, сохранился до наших дней и продолжает развиваться.
Браузер Netscape был написан на C, как и сама реализация JavaScript. Поэтому обратимся к реализации typeof в Netscape Navigator 1.3, которая приветствовала программистов того времени командой help с таким сообщением:
js> help()
JavaScript-C 1.3 1998 06 30
А код, реализующий typeof, выглядел так:
JS_TypeOfValue(JSContext *cx, jsval v)
{
JSType type;
JSObject *obj;
JSObjectOps *ops;
JSClass *clasp;
CHECK_REQUEST(cx);
if (JSVAL_IS_VOID(v)) {
type = JSTYPE_VOID;
} else if (JSVAL_IS_OBJECT(v)) {
obj = JSVAL_TO_OBJECT(v);
if (obj &&
(ops = obj->map->ops,
ops == &js_ObjectOps
? (clasp = OBJ_GET_CLASS(cx, obj),
clasp->call || clasp == &js_FunctionClass)
: ops->call != 0)) {
type = JSTYPE_FUNCTION;
} else {
type = JSTYPE_OBJECT;
}
} else if (JSVAL_IS_NUMBER(v)) {
type = JSTYPE_NUMBER;
} else if (JSVAL_IS_STRING(v)) {
type = JSTYPE_STRING;
} else if (JSVAL_IS_BOOLEAN(v)) {
type = JSTYPE_BOOLEAN;
}
return type;
}
Макросы, определяющие типы данных в Netscape 1.3, выглядели следующим образом:
#define JSVAL_OBJECT 0x0 /* непомеченная ссылка на объект */
#define JSVAL_INT 0x1 /* помеченное 31-битное целочисленное значение */
#define JSVAL_DOUBLE 0x2 /* помеченная ссылка на число двойной точности */
#define JSVAL_STRING 0x4 /* помеченная ссылка на строку */
#define JSVAL_BOOLEAN 0x6 /* помеченное булево значение */
Что соответствовало такому представлению в памяти (32-битная система):
Type Tag (Low 3 bits) Memory (32 bits) Value
Object 000 (0x0) [29-bit pointer][000] 0x12345000
Integer 001 (0x1) [29-bit int value][001] 0x00006401 (42)
Double 010 (0x2) [29-bit pointer][010] 0xABCDE002 → heap
String 100 (0x4) [29-bit pointer][100] 0x78901004 → “hello”
Boolean 110 (0x6) [29-bit value][110] 0x00000006 (true)
На основе этой информации можно создать упрощенную программу, перенеся несколько макросов из Netscape для исследования задачи (код упрощен в учебных целях):
#include <stdlib.h>
#include <stdio.h>
typedef unsigned long pruword;
typedef long prword;
typedef prword jsval;
#define PR_BIT(n) ((pruword)1 << (n))
#define PR_BITMASK(n) (PR_BIT(n) - 1)
#define JSVAL_OBJECT 0x0 /* непомеченная ссылка на объект */
#define OBJECT_TO_JSVAL(obj) ((jsval)(obj))
#define JSVAL_NULL OBJECT_TO_JSVAL(0)
#define JSVAL_TAGMASK PR_BITMASK(JSVAL_TAGBITS)
#define JSVAL_TAG(v) ((v) & JSVAL_TAGMASK)
#define JSVAL_IS_OBJECT(v) (JSVAL_TAG(v) == JSVAL_OBJECT)
#define JSVAL_TAGBITS 3
struct JSObject {
struct JSObjectMap *map;
};
struct JSObjectMap {
};
// Вспомогательная функция для вывода двоичного представления
void print_binary(unsigned long n) {
for (int i = 31; i >= 0; i--) {
printf("%d", (n >> i) & 1);
}
printf("\n");
}
int main() {
struct JSObject* obj = malloc(sizeof(struct JSObject));
jsval objectValue = OBJECT_TO_JSVAL(obj);
jsval null = JSVAL_NULL;
printf("Is object %d\n", JSVAL_IS_OBJECT(objectValue));
printf("Is null an object %d\n", JSVAL_IS_OBJECT(null));
printf("Binary representation of object: ");
print_binary(objectValue);
printf("Binary representation of null: ");
print_binary(null);
}
Результат выполнения этой программы:
Is object 1
Is null an object 1
Binary representation of object: 01011000000010100011000111100000
Binary representation of null: 00000000000000000000000000000000
Как видите, null и object возвращают одинаковое значение в макросе JSVAL_IS_OBJECT.
Почему же null и object неразличимы при проведении такой проверки?
Объяснение этому заключается в упомянутой выше модели разметки и использовании памяти в качестве идентификатора типов object в JavaScript. Поскольку JavaScript является языком с динамической типизацией, объявления типов должны были где-то храниться, поэтому в данном случае разработчик решил выделить 3 младших бита для идентификации типа.
Установка 000 в качестве идентификатора объекта происходит из механизма работы 32-битной архитектуры и требований аппаратного обеспечения, связанных с выравниванием памяти. Объекты и массивы — это структуры, более сложные, чем примитивные типы, поэтому они выделяются в куче.
В 32-битной архитектуре ЦП загружает данные порциями по 32 бита (4 байта), и система управления памятью обеспечивает выравнивание адресов объектов по границам 4 байт. Это означает, что каждый адрес указателя на объект делится на 4, что в двоичном представлении приводит к тому, что адреса объектов всегда оканчиваются двумя нулями в двоичной записи (поскольку 4 = 100 в двоичной системе). Однако на практике в качестве меток использовались три младших бита, поэтому адреса имели 8-байтовое выравнивание, что обеспечивало три нуля в конце.
В случае представления null мы видим, что это значение 0 (все нули), которое ссылается на нулевой указатель в C, что в большинстве архитектур определяется как ((void*)0), означая несуществующее место в памяти. Поскольку null представлен как 0x00000000, а три младших бита — 000, макрос JSVAL_IS_OBJECT считает null объектом!
Можно ли было это исправить? Конечно!
Как видим, представление null — это просто 0, несуществующее место в памяти, тогда как объект — это нечто существующее, а макрос, который корректно проверял на null, присутствовал в коде, но не использовался в функции typeof!
#define JSVAL_IS_NULL(v) ((v) == JSVAL_NULL)
Поэтому функция typeof должна выглядеть так:
JS_TypeOfValue(JSContext *cx, jsval v)
{
JSType type;
JSObject *obj;
JSObjectOps *ops;
JSClass *clasp;
CHECK_REQUEST(cx);
if (JSVAL_IS_NULL(v)) { //проверка, является ли значение null!
type = JSTYPE_NULL;
} else if (JSVAL_IS_VOID(v)) {
type = JSTYPE_VOID;
} else if (JSVAL_IS_OBJECT(v)) {
obj = JSVAL_TO_OBJECT(v);
if (obj &&
(ops = obj->map->ops,
ops == &js_ObjectOps
? (clasp = OBJ_GET_CLASS(cx, obj),
clasp->call || clasp == &js_FunctionClass)
: ops->call != 0)) {
type = JSTYPE_FUNCTION;
} else {
type = JSTYPE_OBJECT;
}
} else if (JSVAL_IS_NUMBER(v)) {
type = JSTYPE_NUMBER;
} else if (JSVAL_IS_STRING(v)) {
type = JSTYPE_STRING;
} else if (JSVAL_IS_BOOLEAN(v)) {
type = JSTYPE_BOOLEAN;
}
return type;
}
Пример реализации с извлеченным кодом, который можно скомпилировать, находится здесь.
Если ошибку было так просто исправить, почему ее не исправили?
Дело в том, что миллионы страниц уже начали использовать JavaScript с этой ошибкой; о ней знали и обрабатывали ее соответствующим образом.
Более того, в 2013 году поступило официальное предложение исправить это поведение в стандарте ECMAScript, но оно было отклонено именно из-за обратной совместимости — слишком большое количество уже написанного кода могло перестать работать.
Поэтому, несмотря на то, что прошло 30 лет, это поведение напоминает нам о контексте создания JavaScript и исторических решениях в его разработке. Чтобы действительно проверить, является ли значение объектом, а не null, нужно обрабатывать это следующим образом:
if (value !== null && typeof value === 'object') {
// это настоящий объект!
}
Читайте также:
- Ключевые понятия JavaScript, которые должен знать каждый разработчик — часть 2
- 5 основных методов работы с @Cacheable в JavaScript
- Функции call, apply и bind: использование и сравнение
Читайте нас в Telegram, VK и Дзен
Перевод статьи Piotr Zarycki: Why typeof null === object





