Объект, который не является объектом В 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 #include 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 ==============