[1] + [2] — [3] === 9!? Исследование внутренних механизмов приведения типов в JavaScript +34

JavaScript позволяет выполнять преобразование типов. Если это делают намеренно, то перед нами — явное приведение типов (type casting или explicit coercion). В том случае, когда это производится автоматически, при попытке выполнения каких-либо операций над значениями различных типов, это называют неявным приведением типов (coercion или implicit coercion).
Автор материала, перевод которого мы сегодня публикуем, предлагает взглянуть на то, как выглядит явное и неявное приведение типов на низком уровне. Это позволит всем желающим лучше понять процессы, скрытые в недрах JavaScript и поможет дать аргументированный ответ на вопрос о том, почему [1] + [2] — [3] === 9.


Явное приведение типов


?Объектные обёртки примитивных типов


Практически все примитивные типы в JavaScript (исключение составляют nullи undefined) имеют объектные обёртки, включающие в себя их значения. Подробнее об этом можно почитать здесь. У разработчика есть доступ к конструкторам таких объектов. Данный факт можно использовать для преобразования значений одного типа в значения другого типа.

String(123); // '123'
Boolean(123); // true
Number('123'); // 123
Number(true); // 1

В показанном здесь примере обёртки переменных примитивных типов существуют недолго: после того, как дело сделано, система от них избавляется.

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

const bool = new Boolean(false);
bool.propertyName = 'propertyValue';
bool.valueOf(); // false

if (bool) {
 console.log(bool.propertyName); // 'propertyValue'
}

Так как в данном случае bool— это новый объект (а не примитивное значение), он, в выражении if, преобразуется к true.

Более того, можно говорить о равнозначности следующих двух конструкций. Вот этой:

if (1) {
 console.log(true);
}

И этой:

if ( Boolean(1) ) {
 console.log(true);
}

Можете убедиться в этом сами, проведя следующий эксперимент, в котором используется оболочка Bash. Поместим первый фрагмент кода в файл if1.js, второй — в файл if2.js. Теперь выполним следующее:

1. Скомпилируем код на JavaScript, преобразовав его в код на ассемблере, воспользовавшись Node.js.

$ node --print-code ./if1.js >> ./if1.asm
$ node --print-code ./if2.js >> ./if2.asm

2. Подготовим скрипт для сравнения четвёртой колонки (тут находятся команды на ассемблере) получившихся файлов. Здесь намеренно не производится сравнение адресов памяти, так как они могут различаться.

#!/bin/bash

file1=$(awk '{ print $4 }' ./if1.asm)
file2=$(awk '{ print $4 }' ./if2.asm)

[ "$file1" == "$file2" ] && echo "The files match"

3. Запустим этот скрипт. Он выведет следующую строку, что подтверждает идентичность файлов.

"The files match"

?Функция parseFloat


Функция parseFloatработает практически так же, как и конструктор Number, но она свободнее относится к передаваемым ей аргументам. Если ей встречается символ, который не может быть частью числа, то она возвращает значение, являющееся числом, собранным из цифр, находящихся до этого символа и игнорирует остаток переданной ей строки.

Number('123a45'); // NaN
parseFloat('123a45'); // 123

?Функция parseInt


Функция parseInt, после разбора переданного ей аргумента, округляет полученные числа. Она может работать со значениями, представленными в разных системах счисления.

parseInt('1111', 2); // 15
parseInt('0xF'); // 15

parseFloat('0xF'); // 0

Функция parseIntможет либо «догадаться» о том, какая система счисления применяется для записи переданного ей аргумента, либо воспользуется «подсказкой» в виде второго аргумента. О правилах, применяемых при использовании этой функции, можно почитать на MDN.

Эта функция неправильно работает с очень большими числами, поэтому её не следует рассматривать в качестве альтернативы функции Math.floor (она, кстати, тоже выполняет приведение типов).

parseInt('1.261e7'); // 1
Number('1.261e7'); // 12610000
Math.floor('1.261e7') // 12610000

Math.floor(true) // 1

?Функция toString


С помощью функции toStringможно конвертировать в строки значения других типов. При этом надо отметить, что реализация этой функции в прототипах объектов разных типов различается. Если вы чувствуете, что вам нужно лучше разобраться с концепцией прототипов в JavaScript, взгляните на этот материал.

Функция String.prototype.toString


Эта функция возвращает значение, представленное в виде строки.

const dogName = 'Fluffy';

dogName.toString() // 'Fluffy'
String.prototype.toString.call('Fluffy') // 'Fluffy'

String.prototype.toString.call({}) // Uncaught TypeError: String.prototype.toString requires that 'this' be a String

Функция Number.prototype.toString


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

(15).toString(); // "15"
(15).toString(2); // "1111"
(-15).toString(2); // "-1111"

Функция Symbol.prototype.toString


Эта функция возвращает строковое представление объекта типа Symbol. Выглядит это так: `Symbol(${description})`. Здесь, для того, чтобы продемонстрировать работу данной функции, используется концепция шаблонных строк.

Функция Boolean.prototype.toString


Эта функция возвращает trueили false.

Функция Object.prototype.toString


У объектов имеется внутренне значение [[Class]]. Оно является тегом, представляющим тип объекта. Функция Object.prototype.toStringвозвращает строку следующего вида: `[object ${tag}]`. Тут, в качестве тега, используются либо стандартные значения (например — «Array», «String», «Object», «Date»), либо значения, заданные разработчиком.

const dogName = 'Fluffy';

dogName.toString(); // 'Fluffy' (здесь вызывается String.prototype.toString)
Object.prototype.toString.call(dogName); // '[object String]'

С появлением ES6 теги задают с использованием объектов типа Symbol. Приведём пару примеров. Вот первый.

const dog = { name: 'Fluffy' }
console.log( dog.toString() ) // '[object Object]'

dog[Symbol.toStringTag] = 'Dog';
console.log( dog.toString() ) // '[object Dog]'

Вот второй.

const Dog = function(name) {
 this.name = name;
}
Dog.prototype[Symbol.toStringTag] = 'Dog';

const dog = new Dog('Fluffy');
dog.toString(); // '[object Dog]'

Тут также можно использовать классы ES6 с геттерами.

class Dog {
 constructor(name) {
   this.name = name;
 }
 get [Symbol.toStringTag]() {
   return 'Dog';
 }
}

const dog = new Dog('Fluffy');
dog.toString(); // '[object Dog]'

Функция Array.prototype.toString


Эта функция, при вызове её у объекта типа Array, выполняет вызов toStringдля каждого элемента массива, собирает полученные результаты в строку, элементы которой разделены запятыми, и возвращает эту строку.

const arr = [
 {},
 2,
 3
]

arr.toString() // "[object Object],2,3"

Неявное приведение типов


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

?Математические операторы


Знак «плюс»


Выражения с двумя операндами, между которыми стоит знак +, и один из которых является строкой, выдают строку.

'2' + 2 // 22
15 + '' // '15'

Если воспользоваться знаком +в выражении с одним строковым операндом, его можно преобразовать в число:

+'12' // 12

Другие математические операторы


При применении других математических операторов, таких, как -или /, операнды всегда преобразуются к числам.

new Date('04-02-2018') - '1' // 1522619999999
'12' / '6' // 2
-'1' // -1

При преобразовании дат в числа получают Unix-время, соответствующее датам.

?Восклицательный знак


Использование в выражениях восклицательного знака приводит к выводу trueесли исходное значение воспринимается как ложное, и false— для значений, воспринимаемых системой как истинные. В результате, восклицательный знак, применённый дважды, можно использовать для преобразования различных значений к соответствующим им логическим значениям.

!1 // false
!!({}) // true

?Функция ToInt32 и побитовый оператор OR


Тут стоит сказать о функции ToInt32, хотя это — абстрактная операция (внутренний механизм, вызвать который в обычном коде нельзя). ToInt32преобразует значения в 32-битные целые числа со знаком.

0 | true          // 1
0 | '123'         // 123
0 | '2147483647'  // 2147483647
0 | '2147483648'  // -2147483648 (слишком большое)
0 | '-2147483648' // -2147483648
0 | '-2147483649' // 2147483647 (слишком маленькое)
0 | Infinity      // 0

Применение побитового оператора ORв том случае, если один из операндов является нулём, а второй — строкой, приведёт к тому, что значение другого операнда не изменится, но будет преобразовано в число.

?Другие случаи неявного приведения типов


В процессе работы программисты могут сталкиваться и с другими ситуациями, в которых производится неявное приведение типов. Рассмотрим следующий пример.

const foo = {};
const bar = {};
const x = {};

x[foo] = 'foo';
x[bar] = 'bar';

console.log(x[foo]); // "bar"

Это происходит из-за того, что и foo, и bar, при приведении их к строке, превращаются в "[object Object]". Вот что на самом деле происходит в этом фрагменте кода.

x[bar.toString()] = 'bar';
x["[object Object]"]; // "bar"

Неявное преобразование типов так же происходит с шаблонными строками. Попытаемся в следующем примере переопределить функцию toString.

const Dog = function(name) {
 this.name = name;
}
Dog.prototype.toString = function() {
 return this.name;
}

const dog = new Dog('Fluffy');
console.log(`${dog} is a good dog!`); // "Fluffy is a good dog!"

Стоит отметить, что причиной, по которой не рекомендуется пользоваться оператором нестрогого равенства (==), является тот факт, что этот оператор, при несовпадении типов операндов, производит неявное преобразование типов. Рассмотрим следующий пример.

const foo = new String('foo');
const foo2 = new String('foo');

foo === foo2 // false
foo >= foo2 // true

Так как здесь использовано ключевое слово new, fooи foo2представляют собой обёртки вокруг примитивных значений (а это — строка 'foo'). Так как соответствующие переменные ссылаются на разные объекты, то в результате сравнения вида foo === foo2получается false. Оператор >=выполняет неявное преобразование типов, вызывая функцию valueOfдля обоих операндов. Из-за этого тут производится сравнение примитивных значений, и в результате вычисления значения выражения foo >= foo2получается true.

[1] + [2] – [3] === 9


Полагаем, теперь вам ясно, почему истинно выражение [1] + [2] – [3] === 9. Однако, всё же, предлагаем его разобрать.

1. В выражении [1] + [2]производится преобразование операндов к строкам, с применением Array.prototype.toString, после чего выполняется конкатенация того, что получилось. Как результат, тут мы имеем строку "12".

  • Надо отметить, что, например, выражение [1,2] + [3,4]даст строку "1,23,4";

2. При вычислении выражения 12 - [3]будет выполнено вычитание "3"из 12, что даст 9.

  • Тут тоже рассмотрим дополнительный пример. Так, результатом вычисления выражения 12 - [3,4]будет NaN, так как система не может неявно привести "3,4"к числу.

Итоги


Можно встретить множество рекомендаций, авторы которых советуют попросту избегать неявного приведения типов в JavaScript. Однако автор этого материала полагает, что важно разбираться в особенностях работы этого механизма. Вероятно, не стоит стремиться намеренно пользоваться им, но знание о том, как он устроен, несомненно, окажется полезным при отладке кода и поможет избежать ошибок.

Уважаемые читатели! Как вы относитесь к неявному приведению типов в JavaScript?

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (19):

  1. alex6636
    /#10756912 / +1

    Весь язык как та картинка

  2. Zenitchik
    /#10756978

    А что из этого нельзя было прочитать в спецификации языка?

    • virtyaluk
      /#10757004 / +1

      Никогда не понимал людей, который плодят подобную писанину, в то время, как спецификация исчерпывающе все объясняет. Мало того, все то-же самое очень детально описано в трудах авторов типа Николаса Закаса и Кайла Симсона.

      • yokotoka
        /#10757392 / -1

        А я не понимаю тех, кто тыкает в спецификацию. Вы когда машину покупаете — читаете всю 1500-страничную спецификацию? Или садитесь и едете? Вы уверены, что авторы всех пакетов в npm по всей цепочке зависимостей, которые вы используете, читали спецификации? Вы читаете код каждого пакета и зависимостей, которые используете? Язык, который не бьёт по рукам, когда программист пытается сделать говно в итоге приводит к тому, что дятел влетает в форточку и все взрывается. Можно же сделать интерпретатор со строгой динамической типизацией, который запретит неявное приведение типов. И массив со строкой не даст сложить и покажет, насколько глубоко все испорчено уже.

        • Gennadii_M
          /#10757660

          Я читаю всю книжку после покупки машины, просматриваю большинство npm модулей, которые ставлю себе в зависимости, но не могу не согласиться, что спеки читает далеко не каждый. Совсем далеко. И прочитать такую статью о самом главном по топику легче, чем закопаться в спеку. Поэтому статья очень даже хороша и к месту.

    • Suvitruf
      /#10757140 / -1

      Если для понимаю таких вещей нужно лезть в спецификацию каждый раз, то язык спроектирован не очень хорошо.

      • Gennadii_M
        /#10757662

        Почему же каждый раз? Достаточно 1 раз понять, как это работает и жить счастливо. Что в статье есть из правил?
        1. Бинарный оператор "+" с не строками пытается выполнить операцию сложения
        2. Если хотя бы 1 операнд строка — конкатенцаия
        3. При неявном приведении разных типов всё сводится к строкам.
        Вот как бы и всё кажись? Там реально не всё так сложно, если понять основы.

  3. masai
    /#10756980

    Как вы относитесь к неявному приведению типов в JavaScript?

    Всё это, конечно, прикольно и, наверное, кому-то удобно. Но лучше бы в языке было поменьше вещей, способных удивить в самый неожиданный момент.

    • Zenitchik
      /#10756998

      Как ни странно — согласен. Неявное приведение типов в большинстве случаев работает логично, но есть
      "" == 0
      и оно портит картину в случаях, когда a.toString() == ""
      Если бы было isNaN("") — сюрпризов было бы в разы меньше.

      • Aquahawk
        /#10757018

        поэтому вместо isNaN(x) я пользуюсь конструкцией x !== x. Точно также проверяет на nan, но не приводит типы.

      • TheShock
        /#10757024

        Если бы было isNaN("") — сюрпризов было бы в разы меньше.

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

        • Zenitchik
          /#10757074

          Вы топите за убирание неявного приведения

          Простите, в каком месте?
          Я ничего не имею против неявного приведения типов, мне не нравится, что пустая строка при приведении к числовому типу даёт 0, а не NaN.

        • siziyman
          /#10757156

          Пустая строка — это число?

          • TheShock
            /#10757160

            Это не число, но это и не NaN. Поймите, что NaN — это особое значение, результат математической операции, а не любое не-число.

            • siziyman
              /#10757166

              NaN — «Not a Number». И NaN не является результатом корректной математической операции.

              • Zenitchik
                /#10757176 / +1

                NaN — это спецзначение типа данных IEEE754.

              • TheShock
                /#10757178

                Как вам выше сказал Zenitchik — это не любое «не число». Если его заодно подтянуть под кое-какие нюансы ЖС — вы еще больше запутаете ситуацию.

                • siziyman
                  /#10757182 / +1

                  Про то, что это спецзначение стандартизированного числового типа данных, принятое для обозначения не-числа, я знаю.
                  Про то, что множество NaN'ов не обозначает (в совокупности, т.е. хотя бы один NaN не обозначает) любое конкретное «не число» — это спорное замечание.

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

                  It is the returned value when Math functions fail (Math.sqrt(-1)) or when a function trying to parse a number fails (parseInt(«blabla»)).

                  Ещё прекраснее описанное там же отличие между поведением isNaN() и Number.isNaN().
                  Хоть убейте, это нельзя назвать логичным и последовательным дизайном языка.

  4. apapacy
    /#10757146 / +1

    Функция parseInt, после разбора переданного ей аргумента, округляет полученные числа.

    Эта функция неправильно работает с очень большими числами, поэтому её не следует рассматривать в качестве альтернативы функции Math.floor (она, кстати, тоже выполняет приведение типов).


    Оба утвержедния не соответсвует истине. Фнукция отбрасывает все что не соответсвует формату целых чисел то есть например начиная с десятичной точки. То есть фактически отрасывает дробную часть.

    Аналогичено и с большими числами. 1е2 можно спорить о том насколько большое это число. но точно 1 получится потому что там с «е» начинается нарушение формата целого числа