[Перевод] Неявное преобразование типов в JavaScript. Сколько будет !+[]+[]+![]?

Приведение типов — это процесс преобразования значений из одного типа в другой (например — строки в число, объекта — в логическое значение, и так далее). Любой тип в JavaScript, идёт ли речь о примитивном типе, или об объекте, может быть преобразован в другой тип. Напомним, что примитивными типами данных в JS являются Number, String, Boolean, Null, Undefined. К этому списку в ES6 добавился тип Symbol, который ведёт себя совсем не так, как другие типы. Явное приведение типов — процесс простой и понятный, но всё меняется, когда дело доходит до неявного приведения типов. Тут то, что происходит в JavaScript, некоторые считают странным или нелогичным, хотя, конечно, если заглянуть в стандарты, становится понятно, что все эти «странности» являются особенностями языка. Как бы там ни было, любому JS-разработчику периодически приходится сталкиваться с неявным приведением типов, к тому же, каверзные вопросы о приведении типов вполне могут встретиться на собеседовании.

image

Эта статья посвящена особенностям работы механизмов приведения типов в 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.

794f5bb005d99f701487b1fe3202f3a0.png
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, и так далее.

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

  1. Если входное значение является примитивом — не делать ничего и вернуть его.
  2. Вызвать input.toString(), если результат является значением примитивного типа — вернуть его.
  3. Вызвать input.valueOf(), если результат является значением примитивного типа — вернуть его.
  4. Если ни 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 приводила к таинственным ошибкам?

© Habrahabr.ru