Почему typeof null === "object"?
Задача унарного оператор typeof возвращать строковое представление типа операнда. Другими словами, typeof 1
вернет строку "number"
, а typeof ""
вернет "string"
. Все возможные значения типов, возвращаемых оператором typeof изложены в спецификации ECMA-262 - 13.5.1. По задумке, возвращаемое, оператором, значение должно соответствовать принятым в той же спецификации типам данных. Однако, при детальном рассмотрении, можно заметить, что typeof null
должен возвращать "object"
, не смотря на то, что Null
- это вполне себе самостоятельный тип, он описан в разделе 6.1.2. Причина тому - обычный человеческий фактор, или, попросту, невинная ошибка в коде. Как эта ошибка могла случиться, попробуем разобраться в этой статьей.
Mocha
Начать стоит, пожалуй, с самого истока JavaScript, и именно, прототипного языка Mocha, созданного Бренданом Ейхом в 1995-м году всего за 10 дней, который позже был переименован в LiveScript, а еще позже, в 1996-м, стал известным нам сегодня JavaScript.
К сожалению, исходный код Mocha не был опубликован и мы не знаем, как именно он выглядел в далеком 1995-м, однако, в комментариях к статье в блоге доктора Алекса Раушмайера, Ейх писал, что использовал технику "Discriminated Union", она же - "Tagged Union", где он использовал struct
с двумя полями.
Структура могла бы выглядеть, например, так:
enum JSType { OBJECT, FUNCTION, NUMBER, STRING, BOOLEAN, }; union JSValue { std::string value; // ... other details }; struct TypeOf { JSType type; JSValue values; };
В самой же статье, Алекс Раушмайер приводит пример кода движка SpiderMonkey (используется в Mozilla Firefox) от 1996-го года
JS_PUBLIC_API(JSType) JS_TypeOfValue(JSContext *cx, jsval v) { JSType type = JSTYPE_VOID; 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; }
Алгоритм хоть и отличается от оригинального кода Mocha, хорошо иллюстрирует суть ошибки. В нем просто нет проверки на тип Null
. Вместо этого, в случае val === "null"
, алгоритм попадает в ветку else if (JSVAL_IS_OBJECT(v))
и возвращает JSTYPE_OBJECT
Почему именно "object"?
Дело в том, что значение переменной в ранних версиях языка являлось 32-битным числом без знака (uint_32
), где первые три бита, как раз, и указывают на тип переменной. При такой схеме были приняты следующие значения этих первых трёх битов:
000
: object - переменная является ссылкой на объект001
: int - переменная содержит 31-битное целое число010
: double - переменная является ссылкой на число с плавающей точкой100
: string - Переменная является ссылкой на последовательность символов110
: boolean - Переменная является булевым значением
В свою очередь Null
являлся указателем на машинный nullptr
, который, в свою очередь выглядит, как 0x00000000
Поэтому, проверка JSVAL_IS_OBJECT(0x00000000)
возвращает true
, ведь первые три бита равны 000
, что соответствует типу object
.
Попытки исправить баг
Позже, данная проблема была признана багом. В 2006-м году Эйх предложил упразднить оператор typeof
и заменить на функцию type(), которая учитывала бы, в том числе и Null
(архивная копия предложения). Функция могла бы быть встроенной или являться частью опционального пакета reflection
. Однако, в любом случае, такой фикс не был бы обратно совместим с предыдущими версиями языка, что породило бы множество проблем с уже существующим JavaScript кодом, написанным разработчиками по всему миру. Потребовалось бы создавать механизм проверки версий кода и/или настраиваемые опции языка, что не выглядело реалистичным.
В итоге, предложение не было принято, а оператор typeof
в спецификации ECMA-262 так и остался в своём оригинальном виде.
Еще позже, в 2017-м было выдвинуто еще одно предложение Builtin.is and Builtin.typeOf. Основная мотивация в том, что оператор instanceof
не гарантирует правильную проверку типов переменных из разных реалмов. Предложение не было связано напрямую с Null
, однако, его текст предполагал исправления и этого бага посредством создания новой функции Builtin.typeOf()
. Предложение так же не было принято, т.к. частный случай, продемонстрированный в мотивационной части, хоть и не очень элегантно, но может быть решен существующими методами.
Современный Null
Как я писал выше, баг появился в 1995-м году в прототипном языке Mocha, еще до появления самого JavaScript и до 2006-го года Брендан Ейх не оставлял надежд исправить его. Однако, с 2017-го ни разработчики, ни ECMA больше не пытались этого сделать. С тех пор язык JavaScript стал намного сложнее, как и его реализации в популярных движках.
SpiderMonkey
От кода SpiderMonkey, который публиковал Алекс Раушмайер в свом блоге 2013-м году, не осталось и следа. Теперь движок (на момент написания статьи, версия FF 121) берет значения typeof из заранее определенного тэга переменной
JSType js::TypeOfValue(const Value& v) { switch (v.type()) { case ValueType::Double: case ValueType::Int32: return JSTYPE_NUMBER; case ValueType::String: return JSTYPE_STRING; case ValueType::Null: return JSTYPE_OBJECT; case ValueType::Undefined: return JSTYPE_UNDEFINED; case ValueType::Object: return TypeOfObject(&v.toObject()); #ifdef ENABLE_RECORD_TUPLE case ValueType::ExtendedPrimitive: return TypeOfExtendedPrimitive(&v.toExtendedPrimitive()); #endif case ValueType::Boolean: return JSTYPE_BOOLEAN; case ValueType::BigInt: return JSTYPE_BIGINT; case ValueType::Symbol: return JSTYPE_SYMBOL; case ValueType::Magic: case ValueType::PrivateGCThing: break; } ReportBadValueTypeAndCrash(v); }
Теперь движок точно знает, какого типа переменная передана в оператор, т.к. после декларирования, объект переменной содержит бит, указывающий на её тип. Для Null
оператор возвращает значение JSTYPE_OBJECT
явным образом, как того требует спецификация
enum JSValueType : uint8_t { JSVAL_TYPE_DOUBLE = 0x00, JSVAL_TYPE_INT32 = 0x01, JSVAL_TYPE_BOOLEAN = 0x02, JSVAL_TYPE_UNDEFINED = 0x03, JSVAL_TYPE_NULL = 0x04, JSVAL_TYPE_MAGIC = 0x05, JSVAL_TYPE_STRING = 0x06, JSVAL_TYPE_SYMBOL = 0x07, JSVAL_TYPE_PRIVATE_GCTHING = 0x08, JSVAL_TYPE_BIGINT = 0x09, #ifdef ENABLE_RECORD_TUPLE JSVAL_TYPE_EXTENDED_PRIMITIVE = 0x0b, #endif JSVAL_TYPE_OBJECT = 0x0c, // This type never appears in a Value; it's only an out-of-band value. JSVAL_TYPE_UNKNOWN = 0x20 };
V8
Схожий подход применяется и в движке V8 (на момент написания статьи, версия 12.2.165). Здесь, Null
является так называемым типом Oddball, т.е. объект типа Null
инциализируется еще до исполнения JS-кода, а все последующие ссылки на значение Null
ведут на этот единственный объект.
Инициализатор класса Oddball выглядит следующим образом
void Oddball::Initialize(Isolate* isolate, Handle<Oddball> oddball, const char* to_string, Handle<Object> to_number, const char* type_of, uint8_t kind) { STATIC_ASSERT_FIELD_OFFSETS_EQUAL(HeapNumber::kValueOffset, offsetof(Oddball, to_number_raw_)); Handle<String> internalized_to_string = isolate->factory()->InternalizeUtf8String(to_string); Handle<String> internalized_type_of = isolate->factory()->InternalizeUtf8String(type_of); if (IsHeapNumber(*to_number)) { oddball->set_to_number_raw_as_bits( Handle<HeapNumber>::cast(to_number)->value_as_bits(kRelaxedLoad)); } else { oddball->set_to_number_raw(Object::Number(*to_number)); } oddball->set_to_number(*to_number); oddball->set_to_string(*internalized_to_string); oddball->set_type_of(*internalized_type_of); oddball->set_kind(kind); }
Помимо зоны Isolate, ссылки на само значение переменной и enum
типа, он так же, явным образом принимает значения toString
, toNumber
и typeof
, которые далее будет хранить внутри класса. Что позволяет, при инициализации глобальной кучи (Heap), определить нужные значения этих параметров Oddball
// Initialize the null_value. Oddball::Initialize(isolate(), factory->null_value(), "null", handle(Smi::zero(), isolate()), "object", Oddball::kNull);
Здесь мы видим, что при инициализации Null, в класс передаются: toString="null"
, toNumber=0
, typeof="object"
.
Сам же оператор typeof
просто берет значение через геттер класса type_of()
// static Handle<String> Object::TypeOf(Isolate* isolate, Handle<Object> object) { if (IsNumber(*object)) return isolate->factory()->number_string(); if (IsOddball(*object)) return handle(Oddball::cast(*object)->type_of(), isolate); // <- typeof null === "object" if (IsUndetectable(*object)) { return isolate->factory()->undefined_string(); } if (IsString(*object)) return isolate->factory()->string_string(); if (IsSymbol(*object)) return isolate->factory()->symbol_string(); if (IsBigInt(*object)) return isolate->factory()->bigint_string(); if (IsCallable(*object)) return isolate->factory()->function_string(); return isolate->factory()->object_string(); }
EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru
English version: https://blog.frontend-almanac.com/VDN5uvu2Fe4