DevGang
Авторизоваться

JavaScript Coercion: за пределами основ. Осмысление неявного преобразования типов

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

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

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

Примитивная операция

Абстрактная операция, о которой мы поговорим сначала, называется ToPrimitive или примитивным процессом приведения. Операция ToPrimitive преобразует значение в примитивное значение: строку, число или значение по умолчанию. Он часто неявно вызывается JavaScript, когда объект используется в контексте, где ожидается примитивное значение.

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

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

Операция ToPrimitive принимает необязательную подсказку типа. Подсказка помогает разобраться, во что его нужно преобразовать. Скажем, если мы выполняем числовую операцию и если необходимо выполнить операцию ToPrimitive, подсказка будет примерно такой: «Я бы хотел, чтобы это было число». Это просто неявный процесс, в котором JavaScript понимает, какого типа он должен быть. Если мы делаем что-то на основе строки, оно отправляет подсказку в виде строки. Это два основных совета. И если он не может понять подсказку, он возвращает лучшее примитивное значение, которое он может вернуть в этом контексте.

Одна важная вещь заключается в том, что -

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

Операция ToPrimitive использует два метода — valueOf и toString. Они могут быть доступны для любого непримитивного значения в JavaScript, будь то объект, функция, массив или любой другой непримитивный тип. Эти два метода/функции играют решающую роль в процессе преобразования непримитивных значений в примитивные.

Помните, что операция ToPrimitive принимает необязательную подсказку.

Теперь, если подсказка является числом, ToPrimitive сначала вызывает метод valueOf и смотрит, что он возвращает. Если метод valueOf возвращает примитив, то все готово. Однако если он не дает примитива или если самого метода не существует, то он пробует второй метод — toString.

Если метод toString также не может предоставить примитив, это обычно приводит к ошибке.

JavaScript вызывает метод toString для преобразования не примитивного значения в примитивное. Нам редко приходится самостоятельно вызывать метод toString; JavaScript автоматически вызывает его при обнаружении объекта, от которого ожидается примитивное значение. MDN

Если подсказка является строкой, все равно используются методы valueOf и toString, но в обратном порядке. С точки зрения строковой подсказки, если операция ToPrimitive пытается превратить не примитивный элемент в примитивный, сначала вызывается метод toString. Затем, если он даст нам такой примитив, как строка, мы просто воспользуемся им.

Итак, независимо от подсказки, если мы пытаемся использовать что-то, что не является примитивным там, где оно должно быть примитивным, например, в какой-то математике или конкатенации, тогда оно проходит через алгоритм ToPrimitive и в конечном итоге либо вызывает метод valueOf, либо метод toString.

Углубление метода valueOf

Возьмем объект — const obj = {value: 4}.

Теперь obj + 3 возвращает нам «[object Object]3» в качестве вывода. Но разве не должно быть 7? Поскольку значение объекта дает числовую подсказку, оно уже должно быть преобразовано в число, верно? Тогда почему он так себя ведет?

Чтобы найти ответ, нам нужно понять, что под капотом JavaScript все еще выполняет операцию ToPrimitive над нашим написанным объектом, чтобы преобразовать его для работы в примитивных операциях, таких как добавление значения. И поскольку он принимает число в качестве подсказки, как мы знаем, сначала он запускает метод valueOf. Но проблема в том, что метод valueOf по умолчанию в JavaScript, который наследуется от Object.prototype, в этом случае по сути бесполезен, поскольку метод valueOf, унаследованный от Object.prototype, возвращает сам объект (this).

А поскольку алгоритм ToPrimitive является рекурсивным, он запускается снова и видит, что метод valueOf не работает, поэтому возвращается к методу toString. И когда метод toString выполняется для любого типа объекта, он возвращает — «[object Object]».

Итак, наш объект по сути сначала превращается в строку «[object Object]». Затем, когда мы используем оператор + между двумя типами строк, он просто объединяет их.

obj + 3

"[object Object]" + 3

"[object Object]3"

Многие встроенные объекты переопределяют метод valueOf, чтобы вернуть подходящее примитивное значение. Когда вы создаете пользовательский объект, вы можете переопределить valueOf для вызова пользовательского метода, чтобы при необходимости ваш пользовательский объект можно было преобразовать в примитивное значение.

Например -

const obj = {
    value: 4,
}

obj.valueOf() // {value: 4}

Он возвращает сам объект (это). Но вы можете создать функцию, которая будет вызываться вместо метода valueOf по умолчанию. Ваша функция не должна принимать аргументов, поскольку она не будет передана при вызове во время преобразования типа.

const obj = {
    value: 4,
    valueOf: function() {
        return this.value;
    }
};

obj.valueOf() // 4

В данном случае он просто возвращает значение. И если мы попытаемся выполнить что-то, где наш записанный объект должен быть примитивного типа, мы увидим, что он будет вести себя так, как ожидалось —

obj + 3 вернет 7.

Почему? Потому что, поскольку операция добавления вызывает операцию ToPrimitive с числовой подсказкой, метод valueOf запускается первым. Но теперь у нашего объекта есть собственный метод valueOf, поэтому он не выполняет унаследованный метод valueOf из Object.prototype, который возвращает сам этот объект. Вместо этого для нашего специального метода valueOf он дает нам фактическую числовую форму значения, поскольку мы уже получаем примитивный тип, он не попадает в метод toString.

Операция ToString

Вот некоторые значения и соответствующие им строковые значения после выполнения над ними операции ToString:

  • null => "null"
  • undefined => "undefined"
  • true => "true"
  • false => "false"
  • 3.14 => "3.14"
  • 0 => "0"
  • -0 => "0"

Операция ToString выполняется во время абстрактных операций, включающих неявное преобразование типов.

Когда ToString применяется к типам объектов, таким как массивы, он возвращает значения, как показано в следующих примерах:

  • [] => ""
  • [1, 2, 3] => "1,2,3"
  • [null, undefined] => ","
  • [ [ [ ], [ ] ], [ ] ] => ",,"
  • [,,,] => ",,,"

Для объектов:

  • {} => "[object Object]"
  • {a: 2} => "[object Object]"

Мы можем изменить поведение toString по умолчанию, реализовав в нашем объекте собственный метод toString. Он будет вызываться при необходимости:

{toString() { return "A"; }} => "A"

Операция ToNumber

С ToNumber связано множество нестандартных ситуаций.

Если нам нужно сделать что-то числовое, а числа у нас нет, JS вызывает абстрактную операцию ToNumber. Некоторые преобразования довольно просты, а другие противоречат здравому смыслу.

"" => 0

  • Пустая строка возвращает 0. Пустая строка должна быть NaN, верно? Пусто означает, что ничего нет. Как ничто не может быть 0? Это странно. Это не только странный случай в JavaScript; это создает множество других проблем с точки зрения [[Coercion]] в JS.

" 003 " => 3

  • Если есть пустые места, JS удаляет их и возвращает соответствующее число.

Другие довольно просты:

  • "0" => 0
  • "-0" => -0
  • "3.14" => 3.14
  • "0.0" => 0
  • "." => NaN
  • "." превращается в NaN, как и должно быть.
  • "0xaf" => 175
  • Шестнадцатеричная строка превращается в соответствующие числа.
  • false=> 0
  • true => 1
  • В контексте программирования, возможно, разумно превратить ложь и истину в 0 и 1, поскольку именно так с ними обращаются компьютеры. Но это создает проблему. false и true должны превратиться в NaN. Мы увидим причину позже, когда будем обсуждать крайние случаи.
  • null => 0
  • undefined => NaN
  • И значение Null, и неопределенное значение должны быть NaN, но одно превращается в 0, а другое — в NaN. Это интересно. Для тех, кто думает, что преобразование null использует метод valueOf объекта Object, поскольку null является объектом, интересна одна вещь: null не наследует методы Object.prototype.

Операция ToNumber над непримитивным

Когда мы запускаем ToNumber для не примитивного объекта или объекта, он вызывает операцию ToPrimitive с числовой подсказкой. В операции ToPrimitive сначала выполняется метод valueOf, который по умолчанию унаследован от Object.prototype, и возвращает сам объект (this). JS игнорирует это и попадает в метод toString.

Поскольку оно попадает в tpString, независимо от того, является ли наша подсказка числом или нет, значение превращается в строку. Итак, мы можем думать о нумерации объекта как о его стрингификации.

Бывают случаи, когда мы хотим, чтобы что-то было примитивным числом, и в итоге мы можем получить примитивную строку для внутреннего процесса valueOf. Нам нужно это осознавать.

Если он пуст, например [] или {}, происходит то же самое. Он вызывает ToPrimitive, а valueOf возвращает пустую скобку — [] или {}, а затем, как обычно, попадает в toString.

Еще несколько операций ToNumber с массивами:

  • [""] => 0
  • ["0"] => 0
  • ["-0"] => -0
  • [null] => 0
  • [undefined] => 0
  • [1,2,3] => NaN
  • [[[]]] => 0

Здесь следует отметить несколько важных вещей:

  • [""] попадает в ToString, а затем возвращает пустую строку. Если ToNumber пытается преобразовать пустую строку, он преобразует ее в 0, что, как мы видели ранее, является нашей старой проблемой.
  • Аналогичные вещи происходят и с [[[]]] — сначала он превращается в "" с помощью toString, затем преобразуется в число, равное 0. В этом случае пустота не должна быть равна 0.
  • Мы видим, что [null] и [undefined] возвращают 0 в операции ToNumber, что не имеет смысла. Мы видели другое поведение в их случае, когда пытались преобразовать примитивные значения null и undefined. Но в данном случае они возвращают идентичное значение, равное 0. Почему? В данном случае это не примитивная вещь в обоих случаях, и, поскольку она не примитивна, вызывается ToPrimitive, и первым запускается метод valueOf. Он возвращает это, затем попадает в метод toString, и помните, что toString делает с не примитивным значением с нулевым или неопределенным значением? Он превращается в пустую строку. И затем эта пустая строка превращается в 0. И снова наша основная проблема.
  • {..} => NaN
  • Если это объект {}, происходит то же самое. ToPrimitive вызывает, затем попадает в toString. ToString возвращает «[object Object]», который определенно не является представлением числа, поэтому возвращает NaN.
  • Но если мы добавим наш собственный метод valueOf, он не попадет в toString и вернет то, что мы хотим получить.

Oперация ToBoolean (Логическая операция)

Это довольно просто. Нам просто нужно запомнить все ложные значения, которые всегда будут возвращать false, если мы попытаемся выполнить над ними операцию ToBoolean. Все остальное возвращает true.

Ложные значения: «», 0, -0, ноль, NaN, ложь, неопределенное Истинные значения: все остальное.

Здесь важно отметить одну вещь:

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

Вот почему, если мы преобразуем [ ] в логическое значение, оно возвращает true, а если мы преобразуем «» в логическое значение, оно возвращает false. Поскольку пустая строка является ложным значением, [ ] — нет.

Если бы он вызвал внутренний метод toString, это привело бы к преобразованию [] в пустую строку, а затем мы получили бы false из-за этой пустой строки. Но в данном случае этого не происходит.

Corner cases

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

Number("") // 0  (!!!)
Number("   \t\n") // 0  (!!!)
Number(null) // 0  (!!!) should be NaN!
Number(undefined) // 0 
Number([]) // 0
Number([1,2,3]) // NaN


Number([null]) // 0  -> !!!
Number([undefined]) // 0  -> !!!

Number({})  // NaN


String(-0)  // "0"   -> what! should be "-0"!
String(null)  // "null"  -> good!
String(undefined)  // "undefined"  -> good!
String([null])  // ""  -> !!!
String([undefined])  // ""  -> !!!

Boolean(new Boolean(false)); // true  -> what!!!

Одной из основных причин крайних случаев, возникающих при приведении в JS, является преобразование "" в 0. Мы уже видели, какие проблемы это может вызвать. Многие приведения просто исчезли бы, если бы пустая строка не превращалась в 0 при преобразовании чисел; если бы это было NaN. В любом случае, сейчас мы ничего не можем с этим поделать из-за обратной совместимости.

Другой важный угловой случай — истинное значение становится 1, а ложное — 0 с точки зрения преобразования в числовую операцию. Number(false)>0   Number(true) -> 1

Хотя для нас это интуитивно понятная вещь. Давайте посмотрим, как это создает проблему. 1 < 2 -> true; Хорошо. 2 <3 -> true; Это тоже нормально. 1 <2 <3 -> верно. Хорошо. Вы думаете – всё работает именно так, как и должно быть.

Но это не тот случай; что происходит под капотом, так это то, что сначала возвращается true для первой части. Затем true преобразуется в 1, поскольку мы выполняем числовую операцию. Тогда 1 < 3, очевидно, возвращает true.

(1 < 2) < 3
(true) < 3
1 < 3

Мы видим, как это может создать проблему.

7 > 5 // true

5 > 3 // true

7 > 5 > 3 // false --> !!!

Давайте посмотрим, что на самом деле происходит под капотом.

(7 > 5) > 3
(true) > 3
1 > 3 // false

Поскольку 1 не больше 3, он возвращает false. Теперь мы понимаем, как преобразование true и false в 1 и 0 создает проблемы. Это было бы решено, если бы это было просто NaN.

Некоторые из нас ненавидят принуждение из-за его странных крайностей. Но мы не можем игнорировать принуждение. Мы постоянно используем принуждение.

Практические примеры использования принуждения

Приведение — странная тема в JavaScript. Но мы тоже не можем этого избежать. Оказывается, мы все постоянно сталкиваемся с принуждением! Давайте посмотрим несколько примеров.

var numPeople = 19;

console.log(`There are ${numPeople} people out there.`);

// There are 19 people out there.
var firstPart = "There are ";
var numPeople = 19;
var secondPart = " people out there.";

console.log(firstPart + numPeople + secondPart);

// There are 19 people out there.

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

var numPeople = 19;

console.log(`There are ${numPeople + ""} people out there.`);

// There are 19 people out there.

или

var numPeople = 19;

console.log(`There are ${[numPeople].join("")} people out there.`);

// There are 19 people out there.

или

var numPeople = 19;

console.log(`There are ${[numPeople].join("")} people out there.`);

// There are 19 people out there.

или

var numPeople = 19;

console.log(`There are ${numPeople.toString()} people out there.`);

// There are 19 people out there.

Написание явного кода не всегда является хорошим решением.

Некоторые могут спросить, а как насчет преобразования строки в число? Мы тоже это делаем. А как насчет данных формы? Нам всем, как разработчику JavaScript, приходится работать с данными форм.

function addOnePeople(numPeople) {
    return numPeople + 1;
}

addOnePeople(peopleInputElement.value); // get people number from form

// "191" - WHAT!!!!

Итак, опять же, сначала нам нужно преобразовать его в число! Есть два пути. Первый -

function addOnePeople(numPeople) {
    return numPeople + 1;
}

addOnePeople(+peopleInputElement.value);

// 20

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

function addOnePeople(numPeople) {
    return numPeople + 1;
}

addOnePeople(Number(peopleInputElement.value));

// 20

Как насчет уменьшения входного значения?

function ignoreOnePeople(numPeople) {
    return numPeople - 1;
}

ignoreOnePeople(peopleInputElement.value);

// 18

Поскольку numPeople является строкой и мы пытаемся выполнить операцию, в которой требуется число, запускается процесс ToPrimitive, и numPeople автоматически преобразуется в число. Вот почему нам не нужно прямо упоминать об этом. Нам даже нужно постоянно проверять истинные и ложные значения.

if (peopleInputElement.value) {
    numPeople = Number(peopleInputElement.value);
}

Упаковка (boxing)

Мы видели, что непримитивные значения не содержат методов. Если это так, то как мы можем получить доступ к методам в строке типа somestring.length?

Это называется Бокс. Это форма неявного принуждения, хотя она не происходит в том же процессе, что и абстрактные операции.

Это похоже на то, что когда JavaScript видит, что вы пытаетесь запустить метод с непримитивным значением, JavaScript оказывает вам услугу. Он думает: «Хорошо, этот человек пытается выполнить операцию, как если бы это было примитивное значение (объект); давайте превратим его в это и облегчим ему жизнь!» А затем он преобразует его в соответствующее объектное представление строки.

Возможно, отсюда и возникает ложное представление: в JavaScript все является объектом. Нет, то, что под капотом он преобразуется в объектоподобную структуру, не означает, что это объект. Вещи могут вести себя одинаково, но это не делает их похожими.

Две вещи совершенно разные

Значительная часть моей мотивации к более глубокому изучению JavaScript возникла под влиянием Кайла Симпсона. В заключение я расскажу о некоторых его философских взглядах на "coercion.".

  • Вы не имеете дело с крайними случаями, избегая принуждения.
  • Вы должны принять стиль кодирования, который делает типы значений простыми и очевидными.
  • Качественная JS-программа предполагает приведение типов, гарантируя ясность типов, участвующих в каждой операции. Таким образом, угловые случаи решаются безопасно.
  • Динамическая типизация JavaScript — это не слабость, а одно из его сильных качеств.
  • Неявное не означает волшебство. Это означает абстракцию. Так, чтобы он мог скрыть ненужные детали, перефокусировав читателя и повысив ясность.
  • Мы можем просто полагаться на неявное приведение типов вместо того, чтобы каждый раз явно описывать преобразование типов, что ухудшит читаемость.

"Useful", когда читатель сосредоточен на важном. Это "Dangerous", когда читатель не может предсказать, что произойдет. "Better", когда читатель понимает код.

"Irresponsible" сознательно избегать использования функции, которая может улучшить читаемость кода.

И всегда лучше узнать, как все работает под капотом!

Приятного обучения!

Если вам понравилось это читать, вы можете связаться со мной в Твиттере или просмотреть другие мои статьи.

Источник:

#JavaScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу