Задача унарного оператор 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), где первые три бита, как раз, и указывают на тип переменной. При такой схеме были приняты следующие значения этих первых трёх битов:

  • 000object - переменная является ссылкой на объект

  • 001int - переменная содержит 31-битное целое число

  • 010double - переменная является ссылкой на число с плавающей точкой

  • 100string - Переменная является ссылкой на последовательность символов

  • 110boolean - Переменная является булевым значением

В свою очередь 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 типа, он так же, явным образом принимает значения toStringtoNumber и 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=0typeof="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();
}

Эту и другие мои статьи, так же, читайте в моем канале

RU: https://t.me/frontend_almanac_ru
EN: https://t.me/frontend_almanac

Комментарии (8)


  1. santjagocorkez
    04.01.2024 17:08
    +11

    TL;DR версия для ленивых:

    Раньше:

    это баг

    В современном прочтении:

    это баг. а это ссылка на мой уютненький


    1. datacompboy
      04.01.2024 17:08
      +2

      Не "это баг" а "это легаси"


    1. Parker0 Автор
      04.01.2024 17:08
      +3

      Статья, в оригинале, действительно писался не для Хабра. Мотивацией к написанию послужило то, что я постоянно слышу от разработчиков, что typeof смотрит на первые три бита значения переменной и по ним определяет тип. Так было много лет назад, но давно уже ушло в историю. И V8, и SpiderMonkey сейчас построены совершенно по другому, и я посчитал нужным этот вопрос освятить. На Хабр пост попал только потому, что мне показалось, он здесь уместен и будет интересен сообществу. Все ссылки в тексте статьи, которые, в изначальном варианте, ведут на оригинальные публикации (не на Хабре) были заменены на кросс-статьи на Хабре, кроме стандартной, для моих публикаций, подписи в конце статьи.

      Печально, что вы увидели в посте только рекламу и никакой полезной информации.


      1. santjagocorkez
        04.01.2024 17:08
        +2

        Да божечки мои...

        Case-переход в "современных" движках — это всего лишь FizzBuzz, реализованный сотней if/else для, соответственно, первой сотни последовательности. Там, где раньше был алгоритм (пусть и кривой), этот алгоритм нынче всего лишь захардкодили прямым джампом в зависимости от конкретного значения. На забагованном результате, как это и написано в статье, это никак не отразилось. Мой первый комментарий остаётся полностью в силе.


  1. nin-jin
    04.01.2024 17:08
    +3

    Алгоритм хоть и отличается от оригинального кода Mocha, хорошо иллюстрирует суть ошибки. В нем просто нет проверки на тип Null. Вместо этого, в случае val === "null", алгоритм попадает в ветку else if (JSVAL_IS_OBJECT(v)) и возвращает JSTYPE_OBJECT

    Вот она проверка на null:

            if (obj &&

    И никакая это не ошибка. null - это во всех языках нулевой указатель на объект. Так же как NaN - это буквально не число имеющее тип числа. И так же как "" - это отсутствие текста, имеющее тип строки.

    Зачем в спецификации значение null вынесли в отдельный unit тип - хороший вопрос. Смысла в этом мало. Разве что были планы ввести конструктор Null, и дать возможность вызывать некоторые методы на null, как это сделали с числами, строками и прочими примитивными типами.


    1. santjagocorkez
      04.01.2024 17:08

      Это сделали для того, чтобы можно было отличить указатель на языковой конструкт null/nil/None от неинициализированного указателя на объект любого другого типа. При этом null в JS и None в питоне не просто так сделали синглтон-объектами: это и проверять проще (сравнил адрес и готово), и следить за ссылками легче.

      В питоне, например, NoneType не экспортирует никаких свойств и методов у экземпляров, то есть, обособление типа не обусловлено особыми интерфейсами.


      1. nin-jin
        04.01.2024 17:08
        +1

        true и false такие же "синглтоны".


  1. p3n-CIL
    04.01.2024 17:08

    Вообще вполне логично когда тип null это базовый объект, а не какой-то производный. Поскольку любой производный от объекта тип может быть null.