Почему Array.isArray(Array.prototype) возвращает true?

в 16:45, , рубрики: ECMAScript, javascript

Сегодня мы с вами разберемся в следующем: что за метод такой Array.isArray(), как он устроен под капотом, что изменилось с ним после выхода ES6, почему он возвращает для Array.prototype значение true и еще много связанных с этим методом тем.

Метод isArray() конструктора Array был добавлен начиная с 5-ой версии стандарта ECMAScript. На страничке описания этого метода на сайте MDN написано:

Метод Array.isArray() возвращает true, если объект является массивом и false, если он массивом не является.

И действительно, данный метод хорошо подходит для проверки различных значений на то, является ли это значение массивом. Однако у него есть одна особенность (куда же без них). В случае, если передать этому методу Array.prototype, который является объектом, то возвращается true. При том, что:

Array.prototype instanceof Array // false
Object.getPrototypeOf(Array.prototype) === Array.prototype // false
Array.prototype.isPrototypeOf(Array.prototype) // false

Array.prototype instanceof Object // true
Object.getPrototypeOf(Array.prototype) === Object.prototype // true
Object.prototype.isPrototypeOf(Array.prototype) // true

Такое неожиданное поведение может смутить не только рядового программиста на языке JavaScript, но и уже опытного бойца. Собственно это и побудило меня написать эту статью. Кто-то может сравнить это поведение со знаменитой особенностью JS:

typeof null === 'object' // true

Однако не надо спешить добавлять этот кейс в список wtfjs, потому что этому (внезапно) есть логичное объяснение. Но сначала давайте разберемся, зачем был создан метод isArray() и что скрыто у него под капотом.

Спойлер: Для тех, кто хочет знать ответ уже сейчас
Потому что Array.prototype это массив!

Предыстория

До ES5 каноничным способом проверить, является ли объект массивом, это использовать оператор instanceof.

[] instanceof Array // true

Данный оператор проверяет содержит ли указанный объект (левый операнд) в своей цепочке прототипов свойство prototype переданного конструктора (правый операнд). Условно данную проверку можно перезаписать следующим образом:

Object.getPrototypeOf([]) === Array.prototype // true

Однако, если разработчику приходится иметь дело с несколькими пространствами (realm), что случается, когда разработка происходит в нескольких iframe, каждый такой iframe имеет свой собственный глобальный объект (window). Поэтому при проверке с помощью instanseof Array массива полученного из другого пространства вернется false, так как конструктор Array одного глобального объекта не равен Array другого глобального объекта.

В таком случае ушлые разработчики нашли способ, как можно проверить объект на массив, не используя конструктор Array. Они выяснили, что метод Object.prototype.toString() выводит строку содержащую внутреннее свойство [[Class]] объекта. Так во многих библиотеках появилась следующая функция для проверки массивов:

function isArray(obj) {
  return Object.prototype.toString.call(obj) === '[object Array]';
}

Впоследствии данный метод добавили в спецификацию, как метод конструктора Array.

Array.isArray для Array.prototype

До ES6 внутреннее представление этого метода было именно таким. Но почему для объекта Arrray.prototype метод Object.prototype.toString() возвращает [object Array] если:

Object.prototype.toString.call(Date.prototype) // [object Object]
Object.prototype.toString.call(RegExp.prototype) // [object Object]

В спецификацию! В ней про метод Array.isArray() написано следующее:

1. Если тип аргумента не является объектом то вернуть false.
2. Если значение внутреннего свойства [[Class]] переданного аргумента равно «Array» то вернуть true.
3. Вернуть false.

По этому же принципу для массивов работает метод Object.prototype.toString(). То есть получается, что внутреннее свойство [[Class]] объекта Array.prototype является «Array»? Не ошибка ли это?

Следует также сказать о реализации метода isArray() в ES6. Несмотря на то, что выполняется этот метод также как и раньше, внутренняя реализация этого метода существенно отличается. В ES6 внутреннее свойство [[Class]] больше не используется и метод Object.prototype.toString() внутренне теперь устроен совершенно по-другому. Если использовать этот метод для массивов то спецификация пишет следующее:


3. Пусть O это результат вызова ToObject(this value).
4. Пусть isArray это результат вызова isArray(O).
5. Если isArray равно true, то builtinTag равен «Array».
...

Где isArray() это внутренняя функция ES6 и именно она вызывается при вызове метода Array.isArray() вместо выполнения старого поведения. Полное описание внутреннее метода isArray() займет много строк этой статьи, поэтому я скажу самое главное, а для любителей почитать спеку оставлю ссылку. Данный метод возвращает true для тех объектов у которых определен внутренний метод [[DefineOwnProperty]], который отвечает за магию массивов (это когда вы меняете количество элементов массива и это влияет на изменение свойства length и наоборот).

Возвращаясь к Array.prototype мы получаем, что у этого прототипа есть свойство [[DefineOwnProperty]]. Чудеса. Не верю. Пойдем проверять.

console.log(Array.prototype);
// [constructor: f, concat: f, ..., length: 0, ..., __proto__: Object]

Хм. Как оказалось у нашего прототипа есть свойство length, несмотря на то, что в прототипе (__proto__) указан Object. Но это еще ничего не значит! Проверим его дескриптор.

console.log(Object.getOwnPropertyDescriptor(Array.prototype, 'length'));
// {value: 0, writable: true, enumerable: false, configurable: false}

Все верно. Такой дескриптор имеет каждое свойство length у массивов. Но и это еще не все. Необходимо проверить что прототип является Array exotic object

console.log(Array.prototype.length); // 0

Array.prototype[42] = 'I'm array';
Array.prototype[18] = 'I'm array exotic object';
console.log(Array.prototype.length); // 43

Array.prototype.length = 20;
console.log(Array.prototype[42]); // undefined
console.log(Array.prototype[18]); // 'I'm array exotic object'

Выходит, что Array.prototype это действительно массив и никакой ошибки и нелогичности здесь нет. Давайте попробую представить (как умею), как выглядит определение свойства prototype для конструктора Array под капотом.

Array.prototype = new Array();
Object.assign(Array.prototype, {constructor() { ... }, concat() { ... }, ...});
Object.setPrototypeOf(Array.prototype, Object.prototype);

Примерно таким образом можно создать прототип, который является массивом, но не наследует ни одного метода от Array.prototype. Это также объясняет, почему у этого объекта в свойстве [[Class]] (которое инициируется при создании экземпляра) было значение 'Array'.

Другие объекты

Function, Date, RegExp

Прототипы конструкторов Date и RegExp представляют из себя обычные объекты (Object), т.е. не являются экземплярами своих собственных объектов, как это произошло в случае с массивами.

Object.prototype.toString.call(Date.prototype); // [object Object]
Object.prototype.toString.call(RegExp.prototype); // [object Object]

Однако Function.prototype не является просто объектом. В случае если вызвать метод Object.prototype.toString() для этого прототипа то получим

Object.prototype.toString.call(Function.prototype); // [object Function]

Это значит, что Function.prototype является функцией и её можно вызвать.

Function.prototype() // undefined;

Такие дела)))

Примитивные объекты

В случае с использованием прототипов конструкторов примитивных объектов (Boolean, Number, String) в методе Object.prototype.toString то получится следующее

Object.prototype.toString.call(Boolean.prototype); // [object Boolean]
Object.prototype.toString.call(Number.prototype); // [object Number]
Object.prototype.toString.call(String.prototype); // [object String]

В данном случае принцип такой же как и с массивами. Все эти прототипы действительно являются экземплярами примитивных конструкторов. У них есть соответствующее значение свойства [[Class]] и также они содержат другие внутренние свойства для идентификации их как примитивных объектов


3. Пусть O это результат вызова ToObject(this value).

7. Иначе, если O является exotic String object то builtinTag равен «String».

11. Иначе, если O имеет внутреннее свойство [[BooleanData]] то builtinTag равен «Boolean».
12. Иначе, если O имеет внутреннее свойство [[NumberData]] то builtinTag равен «Number».

Такое поведение сразу рождает в голове интересные примеры)))

String.prototype + Number.prototype + Boolean.prototype // '0false'
(String.prototype + Boolean.prototype)[Number.prototype]; // 'f'
'Агент ' + Number.prototype + Number.prototype + '7'; // 'Агент 007'

Symbol.toStringTag

В случае, если применять метод Object.prototype.toString() к прототипам конструкторов добавленных начиная с ES6, например Set, Symbol, Promise, то будет выводится следующее:

Object.prototype.toString.call(Map.prototype); // [object Map]
Object.prototype.toString.call(Set.prototype); // [object Set]
Object.prototype.toString.call(Promise.prototype); // [object Promise]
Object.prototype.toString.call(Symbol.prototype); // [object Symbol]

У всех таких прототипов нет внутренних свойств, которые могли бы влиять на вывод Object.prototype.toString, как у массивов и примитивных объектов. Однако подобный вывод стал возможен с появлением в языке стандартных символов, а именно символа @@toStringTag. Его используют как свойство объекта и в качестве значения передают строку которая будет выводится в методе Object.prototype.toString(). У всех объектов, появившихся после ES5 такой метод определен в прототипе, поэтому мы и имеем такой результат, хотя в действительности ни Set.prototype, ни Promise.prototype не являются объектами Set и Promise соответственно.

Также данное свойство можно определить в своих собственных конструкторах и классах, чтобы управлять выводом метода Object.prototype.toString().

Вывод

Array.prototype является массивом в понимании ECMAScript спецификации. И хотя наследуется он от объекта, его внутренние свойства говорят, что является массивом, а значит метод Array.isArray() работает верно. Единственный оставшийся у меня вопрос это, зачем было так делать. Зачем делать из прототипа конструктора массив? Есть ли у вас какие-то версии?

Источники и ссылки на почитать

  • ES5 — ссылка на спецификацию 5-ого стандарта ESMAScript.
  • ES6 — ссылка на спецификацию 6-ого стандарта ESMAScript.
  • ECMAScript 6 для разработчиков | Закас Николас — очень легкая для понимания книга, которая при этом очень подробно объясняет все нововведения в язык.
  • Determining with absolute accuracy whether or not a JavaScript object is an array — хорошая статья, объясняющая, что такое метод Array.isArray и зачем он нужен.

Автор: 8gradeboy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js