[Перевод] Неявное преобразование типов в JavaScript. Сколько будет !+[]+[]+![]?
Приведение типов — это процесс преобразования значений из одного типа в другой (например — строки в число, объекта — в логическое значение, и так далее). Любой тип в JavaScript, идёт ли речь о примитивном типе, или об объекте, может быть преобразован в другой тип. Напомним, что примитивными типами данных в JS являются Number
, String
, Boolean
, Null
, Undefined
. К этому списку в ES6 добавился тип Symbol
, который ведёт себя совсем не так, как другие типы. Явное приведение типов — процесс простой и понятный, но всё меняется, когда дело доходит до неявного приведения типов. Тут то, что происходит в JavaScript, некоторые считают странным или нелогичным, хотя, конечно, если заглянуть в стандарты, становится понятно, что все эти «странности» являются особенностями языка. Как бы там ни было, любому JS-разработчику периодически приходится сталкиваться с неявным приведением типов, к тому же, каверзные вопросы о приведении типов вполне могут встретиться на собеседовании.
Эта статья посвящена особенностям работы механизмов приведения типов в JavaScript. Начнём мы её со списка выражений, результаты вычисления которых могут выглядеть совершенно неожиданными. Вы можете испытать себя, попытавшись найти значения этих выражений, не подглядывая в конец статьи, где будет приведён их разбор.
Проверь себя
Вот список интересных выражений, о которых мы только что говорили:
true + false
12 / "6"
"number" + 15 + 3
15 + 3 + "number"
[1] > null
"foo" + + "bar"
'true' == true
false == 'false'
null == ''
!!"false" == !!"true"
[‘x’] == ‘x’
[] + null + 1
0 || "0" && {}
[1,2,3] == [1,2,3]
{}+[]+{}+[1]
!+[]+[]+![]
new Date(0) - 0
new Date(0) + 0
Тут полно такого, что выглядит более чем странно, но без проблем работает в 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
)
Вторая особенность JS, которую нужно учитывать, заключается в том, логика преобразования для примитивных типов и для объектов работает по-разному, но и примитивы и объекты могут быть конвертированы в эти три типа. Начнём с примитивных типов данных.
Примитивные типы данных
▍Преобразование к типу String
Для того чтобы явно преобразовать значение в строку, можно воспользоваться функцией String()
. Неявное преобразование вызывает использование обычного оператора сложения, +
, с двумя операндами, если один из них является строкой:
String(123) // явное преобразование
123 + '' // неявное преобразование
Все примитивные типы преобразуются в строки вполне естественным и ожидаемым образом:
String(123) // '123'
String(-12.3) // '-12.3'
String(null) // 'null'
String(undefined) // 'undefined'
String(true) // 'true'
String(false) // 'false'
В случае с типом Symbol
дело несколько усложняется, так как значения этого типа можно преобразовать к строковому типу только явно. Здесь можно почитать подробности о правилах преобразования типа Symbol.
String(Symbol('my symbol')) // 'Symbol(my symbol)'
'' + Symbol('my symbol') // ошибка TypeError
▍Преобразование к типу Boolean
Для того, чтобы явно преобразовать значение к логическому типу, используют функцию Boolean()
. Неявное преобразование происходит в логическом контексте, или вызывается логическими операторами (||
&&
!
).
Boolean(2) // явное преобразование
if (2) { ... } // неявное преобразование в логическом контексте
!!2 // неявное преобразование логическим оператором
2 || 'hello' // неявное преобразование логическим оператором
Обратите внимание на то, что операторы, вроде ||
и &&
выполняют преобразование значений к логическому типу для внутренних целей, а возвращают значения исходных операндов, даже если они не являются логическими.
// это выражение возвращает число 123, а не true
// 'hello' и 123 неявно преобразуются к логическому типу при работе оператора && для вычисления значения выражения
let x = 'hello' && 123; // x === 123
Так как при приведении значения к логическому типу возможны лишь два результата — true
или false
, легче всего освоить этот вид преобразований, запомнив те выражения, которые выдают false
:
Boolean('') // false
Boolean(0) // false
Boolean(-0) // false
Boolean(NaN) // false
Boolean(null) // false
Boolean(undefined) // false
Boolean(false) // false
Любое значение, не входящее в этот список, преобразуется в true
, включая объекты, функции, массивы, даты, а также типы, определённые пользователем. Значения типа Symbol
также преобразуются в true
. Пустые объекты и пустые массивы тоже преобразуются в true
:
Boolean({}) // true
Boolean([]) // true
Boolean(Symbol()) // true
!!Symbol() // true
Boolean(function() {}) // true
▍Преобразование к типу Number
Явное преобразование к числовому типу выполняется с помощью функции Number()
— то есть по тому же принципу, который используется для типов Boolean
и String
.
Неявное приведение значения к числовому типу — тема более сложная, так как оно применяется, пожалуй, чаще чем преобразование в строку или в логическое значение. А именно, преобразование к типу Number
выполняют следующие операторы:
- Операторы сравнения (
>
,<
,<=
,>=
). - Побитовые операторы (
|
,&
,^
,~
). - Арифметические операторы (
-
,+
,*
,/
,%
). Обратите внимание на то, что оператор+
с двумя операндами не вызывает неявное преобразование к числовому типу, если хотя бы один оператор является строкой. - Унарный оператор
+
. - Оператор нестрогого равенства
==
(а также!=
). Обратите внимание на то, что оператор==
не производит неявного преобразования в число, если оба операнда являются строками.
Number('123') // явное преобразование
+'123' // неявное преобразование
123 != '456' // неявное преобразование
4 > '5' // неявное преобразование
5/null // неявное преобразование
true | 0 // неявное преобразование
Вот как в числа преобразуются примитивные значения:
Number(null) // 0
Number(undefined) // NaN
Number(true) // 1
Number(false) // 0
Number(" 12 ") // 12
Number("-12.34") // -12.34
Number("\n") // 0
Number(" 12s ") // NaN
Number(123) // 123
При преобразовании строк в числа система сначала обрезает пробелы, а также символы \n
и \t
, находящиеся в начале или в конце строки, и возвращает NaN
, если полученная строка не является действительным числом. Если строка пуста — возвращается 0
.
Значения null
и undefined
обрабатываются иначе: null
преобразуется в 0
, в то время как undefined
превращается в NaN
.
Значения типа Symbol
не могут быть преобразованы в число ни явно, ни неявно. Более того, при попытке такого преобразования выдаётся ошибка TypeError
. Можно было бы ожидать, что подобное вызовет преобразование значения типа Symbol
в NaN
, как это происходит с undefined
, но этого не происходит. Подробности о правилах преобразования значений типа Symbol
вы можете найти на MDN.
Number(Symbol('my symbol')) // Ошибка TypeError
+Symbol('123') // Ошибка TypeError
Вот два особых правила, которые стоит запомнить:
При применении оператора ==
к null
или undefined
преобразования в число не производится. Значение null
равно только null
или undefined
и не равно ничему больше.
null == 0 // false, null не преобразуется в 0
null == null // true
undefined == undefined // true
null == undefined // true
Значение NaN
не равно ничему, включая себя. В следующем примере, если значение не равно самому себе, значит мы имеем дело с NaN
if (value !== value) { console.log("we're dealing with NaN here") }
Преобразование типов для объектов
Итак, мы рассмотрели преобразование типов для примитивных значений. Тут всё довольно просто. Когда же дело доходит до объектов, и система встречает выражения вроде [1] + [2,3]
, сначала ей нужно преобразовать объект в примитивное значение, которое затем преобразуется в итоговой тип. При работе с объектами, напомним, также существует всего три направления преобразований: в число, в строку, и в логическое значение.
Самое простое — это преобразование в логическое значение: любое значение, не являющееся примитивом, всегда неявно конвертируется в true
, это справедливо и для пустых объектов и массивов.
Объекты преобразуются в примитивные значения с использованием внутреннего метода [[ToPrimitive]]
, который ответственен и за преобразование в числовой тип, и за преобразование в строку.
Вот псевдо-реализация метода [[ToPrimitive]]
:
function ToPrimitive(input, preferredType){
switch (preferredType){
case Number:
return toNumber(input);
break;
case String:
return toString(input);
break
default:
return toNumber(input);
}
function isPrimitive(value){
return value !== Object(value);
}
function toString(){
if (isPrimitive(input.toString())) return input.toString();
if (isPrimitive(input.valueOf())) return input.valueOf();
throw new TypeError();
}
function toNumber(){
if (isPrimitive(input.valueOf())) return input.valueOf();
if (isPrimitive(input.toString())) return input.toString();
throw new TypeError();
}
}
Методу [[ToPrimitive]]
передаётся входное значение и предпочитаемый тип, к которому его надо преобразовать: Number
или String
. При этом аргумент preferredType
необязателен.
И при конверсии в число, и при конверсии в строку используются два метода объекта, передаваемого [[ToPrimitive]]
: это valueOf
и toString
. Оба метода объявлены в Object.prototype
, и, таким образом, доступны для любого типа, основанного на Object
, например — это Date
, Array
, и так далее.
В целом, работа алгоритма выглядит следующим образом:
- Если входное значение является примитивом — не делать ничего и вернуть его.
- Вызвать
input.toString()
, если результат является значением примитивного типа — вернуть его. - Вызвать
input.valueOf()
, если результат является значением примитивного типа — вернуть его. - Если ни
input.toString()
, ниinput.valueOf()
не дают примитивное значение — выдать ошибкуTypeError
.
При преобразовании в число сначала вызывается valueOf
(3), если результат получить не удаётся — вызывается toString
(2). При преобразовании в строку используется обратная последовательность действий — сначала вызывается toString
(2), а в случае неудачи вызывается valueOf
(3).
Большинство встроенных типов не имеют метода valueOf
, или имеют valueOf
, который возвращает сам объект, для которого он вызван (this
), поэтому такое значение игнорируется, так как примитивом оно не является. Именно поэтому преобразование в число и в строку может работать одинаково — и то и другое сводится к вызову toString()
.
Различные операторы могут вызывать либо преобразование в число, либо преобразование в строку с помощью параметра preferredType
. Но есть два исключения: оператор нестрогого равенства ==
и оператор +
с двумя операндами вызывают конверсию по умолчанию (preferredType
не указывается или устанавливается в значение default
). В этом случае большинство встроенных типов рассматривают, как стандартный вариант поведения, конверсию в число, за исключением типа Date
, который выполняет преобразование объекта в строку.
Вот пример поведения Date
при преобразовании типов:
let d = new Date();
// получение строкового представления
let str = d.toString(); // 'Wed Jan 17 2018 16:15:42'
// получение числового представления, то есть - числа миллисекунд с начала эпохи Unix
let num = d.valueOf(); // 1516198542525
// сравнение со строковым представлением
// получаем true так как d конвертируется в ту же строку
console.log(d == str); // true
// сравнение с числовым представлением
// получаем false, так как d не преобразуется в число с помощью valueOf()
console.log(d == num); // false
// Результат 'Wed Jan 17 2018 16:15:42Wed Jan 17 2018 16:15:42'
// '+', так же, как и '==', вызывает режим преобразования по умолчанию
console.log(d + d);
// Результат 0, так как оператор '-' явно вызывает преобразование в число, а не преобразование по умолчанию
console.log(d - d);
Стандартные методы toString()
и valueOf()
можно переопределить для того, чтобы вмешаться в логику преобразования объекта в примитивные значения.
var obj = {
prop: 101,
toString(){
return 'Prop: ' + this.prop;
},
valueOf() {
return this.prop;
}
};
console.log(String(obj)); // 'Prop: 101'
console.log(obj + '') // '101'
console.log(+obj); // 101
console.log(obj > 100); // true
Обратите внимание на то, что obj + ‘’
возвращает ‘101’
в виде строки. Оператор +
вызывает стандартный режим преобразования. Как уже было сказано, Object
рассматривает приведение к числу как преобразование по умолчанию, поэтому использует сначала метод valueOf()
, а не toString()
.
Метод Symbol.toPrimitive ES6
В ES5 допустимо менять логику преобразования объекта в примитивное значение путём переопределения методов toString
и valueOf
.
В ES6 можно пойти ещё дальше и полностью заменить внутренний механизм [[ToPrimitive]]
, реализовав метод объекта [Symbol.toPrimtive]
.
class Disk {
constructor(capacity){
this.capacity = capacity;
}
[Symbol.toPrimitive](hint){
switch (hint) {
case 'string':
return 'Capacity: ' + this.capacity + ' bytes';
case 'number':
// преобразование в KiB
return this.capacity / 1024;
default:
// считаем преобразование в число стандартным
return this.capacity / 1024;
}
}
}
// 1MiB диск
let disk = new Disk(1024 * 1024);
console.log(String(disk)) // Capacity: 1048576 bytes
console.log(disk + '') // '1024'
console.log(+disk); // 1024
console.log(disk > 1000); // true
Разбор примеров
Вооружённые теорией, вернёмся к выражениям, приведённым в начале материала. Вот каковы результаты вычисления этих выражений:
true + false // 1
12 / "6" // 2
"number" + 15 + 3 // 'number153'
15 + 3 + "number" // '18number'
[1] > null // true
"foo" + + "bar" // 'fooNaN'
'true' == true // false
false == 'false' // false
null == '' // false
!!"false" == !!"true" // true
['x'] == 'x' // true
[] + null + 1 // 'null1'
0 || "0" && {} // {}
[1,2,3] == [1,2,3] // false
{}+[]+{}+[1] // '0[object Object]1'
!+[]+[]+![] // 'truefalse'
new Date(0) - 0 // 0
new Date(0) + 0 // 'Thu Jan 01 1970 02:00:00(EET)0'
Разберём каждый из этих примеров.
▍true + false
Оператор +
с двумя операндами вызывает преобразование к числу для true
и false
:
true + false
==> 1 + 0
==> 1
▍12 / '6'
Арифметический оператор деления, /
, вызывает преобразование к числу для строки '6'
:
12 / '6'
==> 12 / 6
==>> 2
▍«number» + 15 + 3
Оператор +
имеет ассоциативность слева направо, поэтому выражение "number" + 15
выполняется первым. Так как один из операндов является строкой, оператор +
вызывает преобразование к строке для числа 15
. На втором шаге вычисления выражения "number15" + 3
обрабатывается точно так же:
"number" + 15 + 3
==> "number15" + 3
==> "number153"
▍15 + 3 + «number»
Выражение 15 + 3
вычисляется первым. Тут совершенно не нужно преобразование типов, так как оба операнда являются числами. На втором шаге вычисляется значение выражения 18 + 'number'
, и так как один из операндов является строкой — вызывается преобразование в строку.
15 + 3 + "number"
==> 18 + "number"
==> "18number"
▍[1] > null
Оператор сравнения >
выполняет числовое сравнение [1]
и null
:
[1] > null
==> '1' > 0
==> 1 > 0
==> true
▍«foo» + + «bar»
Унарный оператор +
имеет более высокий приоритет, чем обычный оператор +
. В результате выражение +'bar'
вычисляется первым. Унарный +
вызывает для строки 'bar'
преобразование в число. Так как строка не является допустимым числом, в результате получается NaN
. На втором шаге вычисляется значение выражения 'foo' + NaN
.
"foo" + + "bar"
==> "foo" + (+"bar")
==> "foo" + NaN
==> "fooNaN"
▍'true' == true и false == 'false'
Оператор ==
вызывает преобразование в число, строка 'true'
преобразуется в NaN
, логическое значение true
преобразуется в 1
.
'true' == true
==> NaN == 1
==> false
false == 'false'
==> 0 == NaN
==> false
▍null == ''
Оператор ==
обычно вызывает преобразование в число, но это не так в случае со значением null
. Значение null
равно только null
или undefined
и ничему больше.
null == ''
==> false
▍! «false» == ! «true»
Оператор !!
конвертирует строки 'true'
и 'false'
в логическое true
, так как они являются непустыми строками. Затем оператор ==
просто проверяет равенство двух логических значений true
без преобразования типов.
!!"false" == !!"true"
==> true == true
==> true
▍['x'] == 'x'
Оператор ==
вызывает для массивов преобразование к числовому типу. Метод объекта Array.valueOf()
возвращает сам массив, и это значение игнорируется, так как оно не является примитивом. Метод массива toString()
преобразует массив ['x']
в строку 'x'
.
['x'] == 'x'
==> 'x' == 'x'
==> true
▍[] + null + 1
Оператор +
вызывает преобразование в число для пустого массива []
. Метод объекта Array
valueOf()
игнорируется, так как он возвращает сам массив, который примитивом не является. Метод массива toString()
возвращает пустую строку.
На втором шаге вычисляется значение выражения '' + null + 1
.
[] + null + 1
==> '' + null + 1
==> 'null' + 1
==> 'null1'
▍0 || »0» && {}
Логические операторы ||
и &&
в процессе работы приводят значение операндов к логическому типу, но возвращают исходные операнды (которые имеют тип, отличный от логического). Значение 0
ложно, а значение '0'
истинно, так как является непустой строкой. Пустой объект {}
так же преобразуется к истинному значению.
0 || "0" && {}
==> (0 || "0") && {}
==> (false || true) && true // внутреннее преобразование
==> "0" && {}
==> true && true // внутреннее преобразование
==> {}
▍[1,2,3] == [1,2,3]
Преобразование типов не требуется, так как оба операнда имеют один и тот же тип. Так как оператор ==
выполняет проверку на равенство ссылок на объекты (а не на то, содержат ли объекты одинаковые значения) и два массива являются двумя разными объектами, в результате будет выдано false
.
[1,2,3] == [1,2,3]
==> false
▍{}+[]+{}+[1]
Все операнды не являются примитивными значениями, поэтому оператор +
начинается с самого левого и вызывает его преобразование к числу. Метод valueOf
для типов Object
и Array
возвращают сами эти объекты, поэтому это значение игнорируется. Метод toString()
используется как запасной вариант. Хитрость тут в том, что первая пара фигурных скобок {}
не рассматривается как объектный литерал, она воспринимается как блок кода, который игнорируется. Вычисление начинается со следующего выражения, +[]
, которое преобразуется в пустую строку через метод toString()
, а затем в 0.
{}+[]+{}+[1]
==> +[]+{}+[1]
==> 0 + {} + [1]
==> 0 + '[object Object]' + [1]
==> '0[object Object]' + [1]
==> '0[object Object]' + '1'
==> '0[object Object]1'
▍!+[]+[]+![]
Этот пример лучше объяснить пошагово в соответствии с порядком выполнения операций.
!+[]+[]+![]
==> (!+[]) + [] + (![])
==> !0 + [] + false
==> true + [] + false
==> true + '' + false
==> 'truefalse'
▍new Date (0) — 0
Оператор -
вызывает преобразование в число для объекта типа Date
. Метод Date.valueOf()
возвращает число миллисекунд с начала эпохи Unix.
new Date(0) - 0
==> 0 - 0
==> 0
▍new Date (0) + 0
Оператор +
вызывает преобразование по умолчанию. Объекты типа Data
считают таким преобразованием конверсию в строку, в результате используется метод toString()
, а не valueOf()
.
new Date(0) + 0
==> 'Thu Jan 01 1970 02:00:00 GMT+0200 (EET)' + 0
==> 'Thu Jan 01 1970 02:00:00 GMT+0200 (EET)0'
Итоги
Преобразование типов — это один из базовых механизмом JavaScript, знание которого является основой продуктивной работы. Надеемся, сегодняшний материал помог тем, кто не очень хорошо разбирался в неявном преобразовании типов, расставить всё по своим местам, а тем, кто уверенно, с первого раза, никуда не подсматривая, смог решить «вступительную задачу», позволил вспомнить какой-нибудь интересный случай из их практики.
Уважаемые читатели! А в вашей практике случалось так, чтобы путаница с неявным преобразованием типов в JavaScript приводила к таинственным ошибкам?