Чому typeof null === «object» у сучасному прочитанні
Завдання унарного оператора typeof рядкове подання типу операнда. Інакше кажучи, typeof 1
поверне рядок "number"
, а typeof ""
поверне "string"
. Усі можливі значення типів, що повертаються оператором типувикладені в специфікації 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) бере значення типувід заздалегідь визначеного тегу змінної
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();
}