Чому 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();
}

Джерело