[Из песочницы] Почему Array.isArray(Array.prototype) возвращает true?
Сегодня мы с вами разберемся в следующем: что за метод такой 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 каноничным способом проверить, является ли объект массивом, это использовать оператор instanseof
.
[] instanseof 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-ого стандарта ESMAScrip.t
- ECMAScript 6 для разработчиков | Закас Николас — очень легкая для понимания книга, которая при этом очень подробно объясняет все нововведения в язык.
- Determining with absolute accuracy whether or not a JavaScript object is an array — хорошая статья, объясняющая, что такое метод Array.isArray и зачем он нужен.