Неявное преобразование типов в JavaScript. Сколько будет !+[]+[]+![]?
Приведение типов — это процесс преобразования значений из одного типа в другой (например — строки в число, объекта — в логическое значение, и так далее). Любой тип в JavaScript, идёт ли речь о примитивном типе, или об объекте, может быть преобразован в другой тип. Напомним, что примитивными типами данных в JS являются Number , String , Boolean , Null , Undefined . К этому списку в ES6 добавился тип Symbol , который ведёт себя совсем не так, как другие типы. Явное приведение типов — процесс простой и понятный, но всё меняется, когда дело доходит до неявного приведения типов. Тут то, что происходит в JavaScript, некоторые считают странным или нелогичным, хотя, конечно, если заглянуть в стандарты, становится понятно, что все эти «странности» являются особенностями языка. Как бы там ни было, любому JS-разработчику периодически приходится сталкиваться с неявным приведением типов, к тому же, каверзные вопросы о приведении типов вполне могут встретиться на собеседовании.
Эта статья посвящена особенностям работы механизмов приведения типов в JavaScript. Начнём мы её со списка выражений, результаты вычисления которых могут выглядеть совершенно неожиданными. Вы можете испытать себя, попытавшись найти значения этих выражений, не подглядывая в конец статьи, где будет приведён их разбор.
Проверь себя
Вот список интересных выражений, о которых мы только что говорили:
Тут полно такого, что выглядит более чем странно, но без проблем работает в JS, задействуя неявное приведение типов. В подавляющем большинстве случаев неявного приведения типов в JS лучше всего избегать. Рассматривайте этот список как упражнение для проверки ваших знаний о том, как работает приведение типов в JavaScript. Если же тут для вас ничего нового не нашлось — загляните на wtfjs.com.
JavaScript полон странностей
Вот страница с таблицей, в которой показаны особенности поведения оператора нестрогого равенства в JavaScript, == , при сравнении значений разных типов. Неявное преобразование типов, выполняемое оператором == , делает эту таблицу гораздо менее понятной и логичной, чем, скажем, таблица для оператора строгого равенства, === , ссылку на которую можно найти на вышеупомянутой странице. Заучить таблицу сравнений для оператора == практически невозможно. Но запоминать всё это и не нужно — достаточно освоить принципы преобразования типов, применяемые в JavaScript.
Неявное преобразование типов и явное преобразование типов
Преобразование типов может быть явным и неявным. Когда разработчик выражает намерение сконвертировать значение одного типа в значение другого типа, записывая это соответствующим образом в коде, скажем, в виде Number(value) , это называется явным приведением типов (или явным преобразованием типов).
Так как JavaScript — это язык со слабой типизацией, значения могут быть конвертированы между различными типами автоматически. Это называют неявным приведением типов. Обычно такое происходит, когда в выражениях используют значения различных типов, вроде 1 == null , 2/’5' , null + new Date() . Неявное преобразование типов может быть вызвано и контекстом выражения, вроде if (value) , где value неявно приводится к логическому типу данных.
Существует оператор, который не вызывает неявного преобразование типов — это оператор строгого равенства, === . Оператор нестрогого равенства, == , с другой стороны, выполняет и операцию сравнения, и, если нужно, выполняет неявное преобразование типов.
Неявное преобразование типов — палка о двух концах: это источник путаницы и ошибок, но это и полезный механизм, который позволяет писать меньше кода без потери его читабельности.
Три вида преобразования типов
Первая особенность работы с типами в JS, о которой нужно знать, заключается в том, что здесь есть только три вида преобразований:
- В строку ( String )
- В логическое значение ( Boolean )
- В число ( Number )
Примитивные типы данных
▍Преобразование к типу StringДля того чтобы явно преобразовать значение в строку, можно воспользоваться функцией String() . Неявное преобразование вызывает использование обычного оператора сложения, + , с двумя операндами, если один из них является строкой:
Все примитивные типы преобразуются в строки вполне естественным и ожидаемым образом:
В случае с типом Symbol дело несколько усложняется, так как значения этого типа можно преобразовать к строковому типу только явно. Здесь можно почитать подробности о правилах преобразования типа Symbol.
▍Преобразование к типу BooleanДля того, чтобы явно преобразовать значение к логическому типу, используют функцию Boolean() . Неявное преобразование происходит в логическом контексте, или вызывается логическими операторами ( || && ! ).
Обратите внимание на то, что операторы, вроде || и && выполняют преобразование значений к логическому типу для внутренних целей, а возвращают значения исходных операндов, даже если они не являются логическими.
Так как при приведении значения к логическому типу возможны лишь два результата — true или false , легче всего освоить этот вид преобразований, запомнив те выражения, которые выдают false :
Любое значение, не входящее в этот список, преобразуется в true , включая объекты, функции, массивы, даты, а также типы, определённые пользователем. Значения типа Symbol также преобразуются в true . Пустые объекты и пустые массивы тоже преобразуются в true :
▍Преобразование к типу NumberЯвное преобразование к числовому типу выполняется с помощью функции Number() — то есть по тому же принципу, который используется для типов Boolean и String .
Неявное приведение значения к числовому типу — тема более сложная, так как оно применяется, пожалуй, чаще чем преобразование в строку или в логическое значение. А именно, преобразование к типу Number выполняют следующие операторы:
- Операторы сравнения ( > , < , <= , >= ).
- Побитовые операторы ( | , & , ^ ,
Вот как в числа преобразуются примитивные значения:
При преобразовании строк в числа система сначала обрезает пробелы, а также символы \n и \t , находящиеся в начале или в конце строки, и возвращает NaN , если полученная строка не является действительным числом. Если строка пуста — возвращается 0 .
Значения null и undefined обрабатываются иначе: null преобразуется в 0 , в то время как undefined превращается в NaN .
Значения типа Symbol не могут быть преобразованы в число ни явно, ни неявно. Более того, при попытке такого преобразования выдаётся ошибка TypeError . Можно было бы ожидать, что подобное вызовет преобразование значения типа Symbol в NaN , как это происходит с undefined , но этого не происходит. Подробности о правилах преобразования значений типа Symbol вы можете найти на MDN.
Вот два особых правила, которые стоит запомнить:
При применении оператора == к null или undefined преобразования в число не производится. Значение null равно только null или undefined и не равно ничему больше.
Значение NaN не равно ничему, включая себя. В следующем примере, если значение не равно самому себе, значит мы имеем дело с NaN
Преобразование типов для объектов
Итак, мы рассмотрели преобразование типов для примитивных значений. Тут всё довольно просто. Когда же дело доходит до объектов, и система встречает выражения вроде [1] + [2,3] , сначала ей нужно преобразовать объект в примитивное значение, которое затем преобразуется в итоговой тип. При работе с объектами, напомним, также существует всего три направления преобразований: в число, в строку, и в логическое значение.
Самое простое — это преобразование в логическое значение: любое значение, не являющееся примитивом, всегда неявно конвертируется в true , это справедливо и для пустых объектов и массивов.
Объекты преобразуются в примитивные значения с использованием внутреннего метода [[ToPrimitive]] , который ответственен и за преобразование в числовой тип, и за преобразование в строку.
Вот псевдо-реализация метода [[ToPrimitive]] :
Методу [[ToPrimitive]] передаётся входное значение и предпочитаемый тип, к которому его надо преобразовать: Number или String . При этом аргумент preferredType необязателен.
И при конверсии в число, и при конверсии в строку используются два метода объекта, передаваемого [[ToPrimitive]] : это valueOf и toString . Оба метода объявлены в Object.prototype , и, таким образом, доступны для любого типа, основанного на Object , например — это Date , Array , и так далее.
В целом, работа алгоритма выглядит следующим образом:
- Если входное значение является примитивом — не делать ничего и вернуть его.
- Вызвать input.toString() , если результат является значением примитивного типа — вернуть его.
- Вызвать input.valueOf() , если результат является значением примитивного типа — вернуть его.
- Если ни input.toString() , ни input.valueOf() не дают примитивное значение — выдать ошибку TypeError .
Большинство встроенных типов не имеют метода valueOf , или имеют valueOf , который возвращает сам объект, для которого он вызван ( this ), поэтому такое значение игнорируется, так как примитивом оно не является. Именно поэтому преобразование в число и в строку может работать одинаково — и то и другое сводится к вызову toString() .
Различные операторы могут вызывать либо преобразование в число, либо преобразование в строку с помощью параметра preferredType . Но есть два исключения: оператор нестрогого равенства == и оператор + с двумя операндами вызывают конверсию по умолчанию ( preferredType не указывается или устанавливается в значение default ). В этом случае большинство встроенных типов рассматривают, как стандартный вариант поведения, конверсию в число, за исключением типа Date , который выполняет преобразование объекта в строку.
Вот пример поведения Date при преобразовании типов:
Стандартные методы toString() и valueOf() можно переопределить для того, чтобы вмешаться в логику преобразования объекта в примитивные значения.
Обратите внимание на то, что obj + ‘’ возвращает ‘101’ в виде строки. Оператор + вызывает стандартный режим преобразования. Как уже было сказано, Object рассматривает приведение к числу как преобразование по умолчанию, поэтому использует сначала метод valueOf() а не toString() .
Метод Symbol.toPrimitive ES6
В ES5 допустимо менять логику преобразования объекта в примитивное значение путём переопределения методов toString и valueOf .
В ES6 можно пойти ещё дальше и полностью заменить внутренний механизм [[ToPrimitive]] , реализовав метод объекта [Symbol.toPrimtive] .
Разбор примеров
Вооружённые теорией, вернёмся к выражениям, приведённым в начале материала. Вот каковы результаты вычисления этих выражений:
Разберём каждый из этих примеров.
▍true + falseОператор + с двумя операндами вызывает преобразование к числу для true и false :
▍12 / '6'Арифметический оператор деления, / , вызывает преобразование к числу для строки '6' :
▍«number» + 15 + 3Оператор + имеет ассоциативность слева направо, поэтому выражение "number" + 15 выполняется первым. Так как один из операндов является строкой, оператор + вызывает преобразование к строке для числа 15 . На втором шаге вычисления выражения "number15" + 3 обрабатывается точно так же:
▍15 + 3 + «number»Выражение 15 + 3 вычисляется первым. Тут совершенно не нужно преобразование типов, так как оба операнда являются числами. На втором шаге вычисляется значение выражения 18 + 'number' , и так как один из операндов является строкой — вызывается преобразование в строку.
▍[1] > nullОператор сравнения > выполняет числовое сравнение [1] и null :
▍«foo» + + «bar»Унарный оператор + имеет более высокий приоритет, чем обычный оператор + . В результате выражение +'bar' вычисляется первым. Унарный + вызывает для строки 'bar' преобразование в число. Так как строка не является допустимым числом, в результате получается NaN . На втором шаге вычисляется значение выражения 'foo' + NaN .
▍'true' == true и false == 'false'Оператор == вызывает преобразование в число, строка 'true' преобразуется в NaN , логическое значение true преобразуется в 1 .
Оператор == обычно вызывает преобразование в число, но это не так в случае со значением null . Значение null равно только null или undefined и ничему больше.
▍!!«false» == !!«true»Оператор !! конвертирует строки 'true' и 'false' в логическое true , так как они являются непустыми строками. Затем оператор == просто проверяет равенство двух логических значений true без преобразования типов.
Оператор == вызывает для массивов преобразование к числовому типу. Метод объекта Array.valueOf() возвращает сам массив, и это значение игнорируется, так как оно не является примитивом. Метод массива toString() преобразует массив ['x'] в строку 'x' .
▍[] + null + 1Оператор + вызывает преобразование в число для пустого массива [] . Метод объекта Array valueOf() игнорируется, так как он возвращает сам массив, который примитивом не является. Метод массива toString() возвращает пустую строку.
На втором шаге вычисляется значение выражения '' + null + 1 .
Логические операторы || и && в процессе работы приводят значение операндов к логическому типу, но возвращают исходные операнды (которые имеют тип, отличный от логического). Значение 0 ложно, а значение '0' истинно, так как является непустой строкой. Пустой объект так же преобразуется к истинному значению.
▍[1,2,3] == [1,2,3]Преобразование типов не требуется, так как оба операнда имеют один и тот же тип. Так как оператор == выполняет проверку на равенство ссылок на объекты (а не на то, содержат ли объекты одинаковые значения) и два массива являются двумя разными объектами, в результате будет выдано false .
Все операнды не являются примитивными значениями, поэтому оператор + начинается с самого левого и вызывает его преобразование к числу. Метод valueOf для типов Object и Array возвращают сами эти объекты, поэтому это значение игнорируется. Метод toString() используется как запасной вариант. Хитрость тут в том, что первая пара фигурных скобок не рассматривается как объектный литерал, она воспринимается как блок кода, который игнорируется. Вычисление начинается со следующего выражения, +[] , которое преобразуется в пустую строку через метод toString() , а затем в 0.
Этот пример лучше объяснить пошагово в соответствии с порядком выполнения операций.
▍new Date(0) — 0Оператор - вызывает преобразование в число для объекта типа Date . Метод Date.valueOf() возвращает число миллисекунд с начала эпохи Unix.
▍new Date(0) + 0Оператор + вызывает преобразование по умолчанию. Объекты типа Data считают таким преобразованием конверсию в строку, в результате используется метод toString() , а не valueOf() .
Итоги
Преобразование типов — это один из базовых механизмом JavaScript, знание которого является основой продуктивной работы. Надеемся, сегодняшний материал помог тем, кто не очень хорошо разбирался в неявном преобразовании типов, расставить всё по своим местам, а тем, кто уверенно, с первого раза, никуда не подсматривая, смог решить «вступительную задачу», позволил вспомнить какой-нибудь интересный случай из их практики.
Уважаемые читатели! А в вашей практике случалось так, чтобы путаница с неявным преобразованием типов в JavaScript приводила к таинственным ошибкам?