Преобразование типов в JavaScript

Know your engines!

Serj Bulavyk
10 min readFeb 4, 2018

Перевод статьи Alexey SamoshkinJavaScript type coercion explained”.

Преобразование типов это процесс конвертации значения из одного типа в другой (как например, строки в число, объекта к булевому значению и т. д.). Любой тип, будь то примитив или объект, может быть преобразован в другой тип. Для справки, примитивы это: number, string, boolean, null, undefined + Symbol (добавлен в ES6).

В качестве примера преобразования типов, можно ознакомиться с JavaScript Comparison Table, где продемонстрировано как ведёт себя оператор нестрогого сравнения == для разных типов a и b.

Из-за побочного эффекта оператора == в виде неявного приведения типа, эта матрица выглядит довольно пугающей, и запомнить все эти комбинации не представляется возможным. К счастью, вам не обязательно это делать, достаточно просто знать принципы, которые лежат в основе преобразования типов.

Эта статья подробно расскажет вам о том, как работает механизм преобразования типов в 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
[1,2,3] == [1,2,3]
{}+[]+{}+[1]
!+[]+[]+![]
new Date(0) - 0
new Date(0) + 0

Этот список скорее представляет собой те вещи, которые вам как программисту делать не стоит, а поэтому расценивайте этот список как своеобразные упражнения, для того чтобы оценить насколько хорошо вы знаете механизм приведения типов. Если вам станет скучно, вы можете найти больше примеров на wtfjs.com.

Кстати, иногда вы можете встретить подобные вопросы на собеседованиях на позицию JavaScript разработчика. Итак, поехали дальше :)

Явное и неявное преобразование

Преобразование типов может происходить явно и неявно.

Когда разработчик хочет намеренно произвести преобразование типов, написав, к примеру Number(value), это называется явным преобразованием типов (или type casting).

Так как JavaScript это слабо типизированный язык, преобразование между разными типами может происходить автоматически, и это называется неявным преобразованием типов. Чаще всего это происходит когда вы применяете операторы к значениям разных типов, таких как 1 == null, 2 / `5`, null + new Date(), может происходить в зависимости от контекста, как например, в случае с if (value) {…}, где value будет приведено к булевому значению.

Оператор строгого равенства === не приводит к неявному преобразованию типов. Оператор нестрогого равенства ==, в свою очередь, производит сравнение операндов и, если требуется, неявное преобразование типов.

Неявное преобразование типов — это палка о двух концах: с одной стороны это источник проблем и разочарований, а с другой — механизм, который позволяет нам писать меньше кода, не теряя при этом читабельности.

Три типа конвертации

Во-первых, следует знать, что в JavaScript существует всего 3 типа преобразования:

  • строковое
  • булевое
  • численное

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

Давайте сначала разберёмся с примитивами.

Приведение к строке

Для явного приведения значения к строке необходимо применить к нему функцию String(). Неявное преобразование будет вызвано бинарным оператором +, кода один из операндов является строкой:

String(123) // explicit
123 + '' // implicit

Все примитивы будут приведены к строке вполне естественно, как вы могли и ожидать:

String(123)                   // '123'
String(-12.3) // '-12.3'
String(null) // 'null'
String(undefined) // 'undefined'
String(true) // 'true'
String(false) // 'false'

Преобразование символов происходит немного сложнее, потому что они могут быть преобразованы только явным образом. Вы можете прочитать об этих правилах подробнее здесь.

String(Symbol('my symbol'))   // 'Symbol(my symbol)'
'' + Symbol('my symbol') // TypeError is thrown

Булевое преобразование

Для явного преобразования к булевому значению, нужно применить функцию Boolean(). Неявное преобразование происходит в логическом контексте if (val) { … } или при применении логических операторов (|| && !).

Boolean(2)          // explicit
if (2) { ... } // implicit due to logical context
!!2 // implicit due to logical operator
2 || 'hello' // implicit due to logical operator

На заметку, логические операторы такие как || и && производят булевое преобразование под капотом, но при этом всегда возвращают оригинальное значение операндов, даже если они не являются булевыми.

// returns number 123, instead of returning true
// 'hello' and 123 are still coerced to boolean internally to calculate the expression
let x = 'hello' && 123; // x === 123

Поскольку существует всего два возможных результата преобразования, легче просто запомнить список ложных значений:

Boolean('')           // false
Boolean(0) // false
Boolean(-0) // false
Boolean(NaN) // false
Boolean(null) // false
Boolean(undefined) // false
Boolean(false) // false

Любое значение, которое не вошло в этот список, будет преобразовано в true, включая объекты, функции, Array, Date и так далее. Символы, пустые объекты и массивы так же будут иметь значение true.

Boolean({})             // true
Boolean([]) // true
Boolean(Symbol()) // true
!!Symbol() // true
Boolean(function() {}) // true

Численное преобразование

Для явного преобразования к числу нужно применить функцию Number(), точно так же, как мы делали с Boolean() и String().

Неявное преобразование несколько запутаннее, так как оно вызывается в большем количестве случаев.

  • операторы сравнения (>, <, <=,>=)
  • бинарные операторы (| & ^ ~)
  • арифметические операторы (- + * / % ). Обратите внимание, что бинарный оператор + не вызывает численного преобразования, если один из операндов является строкой
  • унарный оператор +
  • оператор нестрогого равенства == (включая !=). Обратите внимание, что данный оператор не вызывает численное преобразование, если оба операнда являются строками
Number('123')   // explicit
+'123' // implicit
123 != '456' // implicit
4 > '5' // implicit
5/null // implicit
true | 0 // implicit

Примеры того, как примитивы будут преобразованы в числа:

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.

Численное преобразование, как явное так и неявное, не работает для символов. Более того, движок бросает ошибку TypeError, вместо того, чтобы по-тихому преобразовать Symbol в NaN, как это происходит с undefined. Подробнее о правилах преобразования символов можно посмотреть на MDN.

Number(Symbol('my symbol'))    // TypeError is thrown
+Symbol('123') // TypeError is thrown

Существует два специальных правила которые следует запомнить:

  1. При применении == к null или undefined, численное преобразование не происходит, так как null может равняться только null или undefined, и ничему другому.
null == 0               // false, null is not converted to 0
null == null // true
undefined == undefined // true
null == undefined // true

2. NaN не равен ничему, даже самому себе.

if (value !== value) { console.log("we're dealing with NaN here") }

Преобразование типов для объектов

До текущего момента мы рассматривали преобразования для примитивов, что является достаточно банальным.

Когда дело доходит до объектов, и движок встречает выражение вроде [1] + [2, 3], ему сначала необходимо привести объекты к примитивным значениям, а уже потом выполнить финальное преобразование. Как и в случае с примитивами, объект может быть преобразован всего тремя способами: численным, строковым, булевым.

Самый простой пример это булевое преобразование — любое не примитивное значение всегда приводится к true, включая пустые объекты и массивы.

Объекты приводятся к примитивам посредством вызова внутреннего метода [[ToPrimitive]], который отвечает как за численное, так и за строковое преобразование.

Вот псевдокод реализации метода [[ToPrimitive ]]:

Методу [[ToPrimitive]] передаётся два аргумента: входящее значение и предпочтительный тип для преобразования: Number или String. Второй аргумент является опциональным.

Как для строкового так и для численного преобразования используются два метода объекта: valueOf и toString. Оба метода объявлены в Object.prototype, а значит доступны для всех производных типов, таких как Date, Array и т.д.

В общих чертах алгоритм выглядит следующим образом:

  1. Если входящее значение уже является примитивом, ничего не делать и просто вернуть его.
  2. Вызвать input.toString(), если результат примитив — вернуть его.
  3. Вызвать input.valueOf(), если результат примитив — вернуть его.
  4. Если ни один из методов не вернул примитив — бросить ошибку TypeError.

При численном преобразовании сначала вызывается метод valueOf(), а уже затем toString(). При строковом преобразовании наоборот — сначала происходит вызов toString(), а уже потом valueOf().

Большинство встроенных типов не имеют метода valueOf или же имеют valueOf, который возвращает свой собственный объект this, который игнорируется, так как this не является примитивом. Вот почему численное и строковое преобразование в большинстве случаев работает одинаково — оба в конечном итоге вызывают метод toString().

Разные операторы могут вызывают строковое или численное преобразование при помощи параметра preferredType. Но существует два исключения: нестрогое равенство == и бинарный оператор +, которые вызывают режим преобразования по умолчанию (preferredType не указан или равен default). В таком случае, большинство встроенных типов подразумевают численное преобразование, за исключением Date, который предпочитает строковое преобразование.

Вот пример преобразования Date:

Вы можете переопределить методы toString() и valueOf() для того, чтобы повлиять на логику преобразования объектов в примитив.

Обратите внимание, как obj + ‘’ возвращает строку 101. Оператор + вызывает преобразование в режиме по умолчанию, и как упоминалось выше, Object подразумевает численное преобразование в таком случае, используя сначала метод valueOf(), а затем уже toString().

Метод ES6 Symbol.toPrimitive

В ES5 вы можете повлиять на логику преобразования объекта в примитив, переопределив методы toString() и valueOf().

В ES6 вы можете пойти дальше и полностью заменить внутреннюю процедуру метода [[ToPrimitive]], реализовав метод [Symbol.toPrimitive]() у объекта.

Примеры

Вооружившись теорией, давайте вернёмся к нашим примерам:

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'
[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
==> 1 + 0
==> 1

Оператор деления / вызывает численное преобразование строки 6:

12 / '6'
==> 12 / 6
==>> 2

Оператор + выполняется слева направо, поэтому сначала выполнится выражение “number” + 15. Поскольку один из операндов это строка, оператор + вызовет строковое преобразование числа 15 и последующую конкатенацию двух строк. На следующем этапе выражение “number15” + 3 выполнится таким же образом.

“number” + 15 + 3 
==> "number15" + 3
==> "number153"

Сначала выполняется сложение чисел 15 + 3. На данном этапе никакого преобразования не нужно, так как оба операнда являются числами. Затем выполняется выражение 18 + ‘number’, и так как один из операндов является строкой, то вызывается строковое преобразование для числа 18, и последующая конкатенация двух строк.

15 + 3 + "number" 
==> 18 + "number"
==> "18number"

Оператор сравнения > вызывает численное преобразование для [1] и null

[1] > null
==> '1' > 0
==> 1 > 0
==> true

Унарный оператор + имеет более высокий приоритет чем бинарный оператор +. Поэтому выражение + 'bar' выполняется первым. Унарный плюс вызывает численное преобразования строки bar. Так как эта строка не представляет собой корректное число, результатом будет NaN. Следующим шагом выполнится выражение 'foo' + NaN.

"foo" + + "bar" 
==> "foo" + (+"bar")
==> "foo" + NaN
==> "fooNaN"

Оператор сравнения == вызывает численное преобразование, поэтому строка true конвертируется в NaN, а правый операнд true станет 1.

'true' == true
==> NaN == 1
==> false
false == 'false'
==> 0 == NaN
==> false

Оператор == обычно вызывает численное преобразование, но не в случае с null. Действует исключение из правил: null равен только null или undefined и ничему другому.

null == ''
==> false

Оператор !! конвертирует строки true и false в булевое значение true, так как это не пустые строки. А дальше оператор == просто сравнивает два булевых значения безо всяких преобразований.

!!"false" == !!"true"  
==> true == true
==> true

Оператор == вызывает численное преобразование для массива. Метод массива valueOf() возвращает сам массив, а значит результат игнорируется, так как не является примитивом. Далее, вызывается метод массива toString(), который конвертирует [‘x’] в строку ‘x’.

['x'] == 'x'  
==> 'x' == 'x'
==> true

Оператор + вызывает численное преобразование массива. Метод массива valueOf() вернёт сам массив, поэтому результат игнорируется, поскольку не является примитивом.

Далее выполняется выражение ’’ + null + 1.

[] + null + 1  
==> '' + null + 1
==> 'null' + 1
==> 'null1'

Логические операторы || и && преобразовывают операнды к булевому значению, но всегда возвращают оригинальное значение операнда (не булевое). 0 станет false, а поскольку 0 является не пустой строкой, то конвертируется в true. {} пустой объект тоже становится true.

0 || "0" && {}  
==> (0 || "0") && {}
==> (false || true) && true // internally
==> "0" && {}
==> true && true // internally
==> {}

В данном примере никакого преобразования не требуется, потому что оба операнда одного типа. Так как оператор == сравнивает объекты по ссылке, а не по значению, а данные массивы являются двумя разными экземплярами, результатом будет false.

[1,2,3] == [1,2,3]
==> false

Все операнды являются не примитивами, поэтому + вызывает численное преобразование. Методы Object.valueOf() и Array.valueOf() возвращают самих себя, соответственно будут проигнорированы. В качестве запасного варианта, вызывается метод 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'

Оператор - вызывает численное преобразование для объекта Date. Date.valueOf() возвращает количество миллисекунд прошедших с начала Unix эпохи (в данном случае 0).

new Date(0) - 0
==> 0 - 0
==> 0

Оператор + вызывает преобразование по умолчанию. Date, как исключение, подразумевает строковое преобразование, поэтому используется метод 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'

Источники

Я бы хотел порекомендовать отличную книгу “Understanding ES6” написанную Nicholas C. Zakascholas. Книга является отличным ресурсом для изучения ES6 с достаточным уровнем освещения: не слишком поверхностно, и в тоже время не погружается в механику работы движка черезчур.

Ещё одна отличная книга, на этот раз по ES5 — SpeakingJS написанная Axel Rauschmayer.

Современный учебник Javascript — https://learn.javascript.ru/,

в особенности эти две страницы, посвящённые преобразованию типов.

JavaScript Comparison Table — https://dorey.github.io/JavaScript-Equality-Table/

wtfjs — небольшой блог о языке, который мы все так любим, не смотря на то что он дает много поводов его ненавидеть — https://wtfjs.com/

--

--