Более глубокое понимание основных концепций программирования
JavaScript — это язык программирования со слабой типизацией, что означает, что он не применяет типы данных для переменных. В то время как языки со строгой типизацией будут вызывать ошибки во время компиляции, когда определенные переменные преобразуются в разные типы данных, JavaScript, как правило, этого не делает и даже иногда выполняет приведение типов в фоновом режиме, что может привести к ошибкам и непредвиденным проблемам. TypeScript решает эту проблему, но для тех, кто разрабатывает на JavaScript, эта статья — хорошее место, чтобы понять, как избежать ловушек языка со слабой типизацией.
Мы рассмотрим пять концепций: приоритет оператора, ассоциативность, короткое замыкание, примитивы и (неявное) приведение типов в JavaScript.
Многие из этих концепций не зависят от языка, единственным изменением будет то, что правила могут различаться для каждого языка программирования. Являетесь ли вы опытным разработчиком или только начинаете, вы обязательно узнаете что-то новое!
Операторы, приоритет и ассоциативность
Операторы — это зарезервированные символы в языках программирования, которые используются для выполнения математических вычислений, присваиваний, сравнений и логических операций над переменными и значениями (называемыми операндами). Есть:
- унарныеоператоры — действуют на один операнд (т. е. оператор
!
negation) - бинарные операторы — действуют на два операнда (например, оператор
%
по модулю) - тройные операторы — действуют на три операнда (т. е.
?
тернарный оператор). - n-арные операторы — действуют на n операндов (т. е.
()
оператор вызова функции)
Подавляющее большинство операторов либо бинарные, либо унарные (иногда символ может быть и тем, и другим, в зависимости от контекста).
Порядок выполнения оператора
Приоритет относится к порядку выполнения операторов. Оператор с большим значением приоритета выполняется перед операторами с меньшим значением приоритета. Без приоритета такие выражения, как 3 + 4 * 5
, могут привести к неожиданным результатам и путанице.
Ниже приведен фрагмент таблицы Mozilla JS Operator Precedence.
Учитывая выражение 3 + 4 * 5
, поскольку оператор умножения имеет более высокий приоритет, чем оператор сложения, то умножение выполняется сначала на 3, а затем добавляется к этому результату, что дает окончательный результат 23.
Таким образом, 3+4*5
становится 3 + (4*5)
Учитывая, что **
имеет приоритет 13, *
имеет приоритет 12, а +
имеет приоритет 11, можете ли вы найти 3 + 4 ** 2 * 5
?
Что делать, если приоритет тот же?
Ассоциативность определяет порядок выполнения операций. Это наиболее полезно знать в тех случаях, когда в выражении используется несколько операций с одинаковым приоритетом. На предыдущем изображении мы видели, что операторы +
и -
имеют одинаковый приоритет.
Рассмотрим выражение 1 + 2 - 3 + 4
:
Если операторы *
и -
имеют ассоциативность слева направо, то выражение оценивается как (1 + 2) -3) + 4
, что равно 0 + 4 или 4.
Если операторы +
и -
имеют ассоциативность справа налево, то выражение оценивается как 1 + (2 -(3 + 4)
, что равно 1 - 5 или -4.
Или рассмотрим выражение 3 ** 3 ** 3
, где **
— возведение в степень:
Если **
имеет ассоциативность слева направо, то у нас есть (3**3)**3
, что равно 19683.
Но с ассоциативностью справа налево у нас есть 3**(3**3)
, дающее 7 625 597 484 987.
Поэтому ассоциативность играет решающую роль в определении ожидаемых результатов!
В JavaScript, хотя большинство арифметических операторов имеют левую ассоциативность, оператор возведения в степень является правоассоциативным.
Ссылаясь на приведенную выше таблицу, теперь мы можем проверить наши предыдущие результаты в Node.
Оператор присваивания — еще один правоассоциативный оператор.
Следовательно, a=b=c
переводится как a=(b=c)
, а a=b+=c
переводится как a=(b+=c)
.
Хотя некоторые правила могут быть похожи и справедливы для большинства языков программирования, это не означает, что все языки программирования имеют одни и те же правила.
В общем, как приоритет, так и ассоциативность операторов определяются правилами языка программирования, и, поскольку разные языки могут иметь разные правила, важно прочитать документацию каждого языка в отношении того, как работают их операторы.
В ситуациях, когда операторы неассоциативны (n/a), рекомендуется использовать круглые скобки для группировки выражений.
Примечание. Я ссылаюсь здесь на документы MDN, так как в руководстве по спецификации ECMAScript не было таблицы приоритета/ассоциативности операторов.
Хотя выражения с более высоким приоритетом оцениваются первыми, есть сценарии, в которых это не так. При коротком замыкании операнд может вообще не оцениваться!
Короткое замыкание (и логические операторы)
Некоторые логические операторы имеют функцию, называемую коротким замыканием,когда интерпретатор прекращает вычисление выражения, учитывая результирующее значение операнда.
Например, если задано x && (y || z)
, если x
является ложным (оценивается как false
), то (y || z)
никогда не оценивается, даже если ()
имеет более высокий приоритет, чем &&
.
Короткое замыкание обычно используется, когда одни условия в выражении зависят от других, и поэтому не всегда необходимо вычислять все выражение.
В JS есть три бинарныхлогических оператора, все с ассоциативностью слева направо.
let user = { name : "George" } const getUserName = (userObj) => { if (user) { if (user.name) { console.log(user.name); } else { console.log("User object has no attribute called name"); } } else { console.log("User object does not exist"); } }; const getUserNameWithShortCircuit = (userObj) => { if (user && user.name) { console.log(user.name); } else { console.log(`${user ? "User object has no attribute called name" : "User object does not exist"}`); } } getUserName(user); // George getUserNameWithShortCircuit(user); // George user = {}; getUserName(user); // User object has no attribute called name getUserNameWithShortCircuit(user); // User object has no attribute called name user = null; getUserName(user); // User object does not exist getUserNameWithShortCircuit(user); // User object does not exist
В приведенном выше фрагменте кода функция getUserName
имеет вложенные условия if для проверки того, определен ли user
и, если да, существует ли его атрибут name
. Если они оба существуют, мы выводим атрибут name
.
Функция getUserNameWithShortCircuit
использует логический оператор &&
(И) и объединяет оба условия в одно выражение. Разница в том, что если первый операнд оценивается как false
, то второе выражение не оценивается и интерпретатор переходит к блоку else
. Это помогает сжать логику, а также сгруппировать зависимости, которые условия/операнды имеют друг с другом.
Другой вариант использования коротких замыканий — указать значения по умолчанию.
let userName = someNameVar || "default"
Если someNameVar
является истинным (оценивается как true
), то оператор ИЛИ не оценивает правильный операнд и присваивает someNameVar
значение userName
. В противном случае, если someNameVar
ложно, мы устанавливаем userName
в строку default
.
Примечание:если левый операнд неверен, ||
возвращает второй операнд независимо от того, true
или false
let userName = "" || false; //returns false
Нулевой оператор слияния ??
подобен логическому ||
ИЛИ в том смысле, что если левый операнд истинен, они оба замыкаются накоротко. Разница в том, что ??
оценивает правый операнд, только если левый операнд равен null
или undefined
.
console.log("" ?? false); // "" console.log("" || false); // false console.log(0 ?? false); // 0 console.log(0 || false); // false console.log(null ?? false); // false console.log(null || false); // false console.log(undefined ?? false); // false console.log(undefined || false); // false console.log(true ?? false); // true console.log(true || false); // true
Следовательно, с ||
любое ложное значение оценивается как правильный операнд.
Ниже приведена документация по этим операторам:
Короткое замыкание (Подробнее)
Аналоги присваивания вышеперечисленных операторов (&&=
, ||=
, ??=
) также закорачивают, так что присваивания вообще не происходит.
Необязательный оператор цепочки ?.
также закорачивает, если левый операнд равен null
или undefined
. Поскольку это не логический оператор, а используемый для доступа к свойствам объекта, я не стал вникать в него, но подумал, что о нем стоит упомянуть.
Теперь, когда мы рассмотрели операторы, пришло время узнать об их операндах!
примитивы
Примитивы считаются строительными блоками большинства языков программирования. В JavaScript это типы данных, которые не являются объектами; следовательно, не имеют методов или свойств. Примитивы также неизменяемы, и поэтому их значение не может быть изменено.
JavaScript имеет семь примитивных типов данных:
- неопределенный
- нулевой
- число
- большойинт
- символ
- нить
- логический
Я не буду останавливаться на типах данных string
или boolean
, так как они, как правило, хорошо понятны.
неопределенный
Переменная имеет значение undefined
, если ее значение или свойство не были объявлены или присвоены.
Переменная a
имеет тип undefined
при инициализации без значения. Также, если a
— пустой объект и мы пытаемся получить доступ к несуществующему свойству, вместо ошибки получаем, что свойство undefined
Наоборот, при попытке доступа к переменной, которая не существует/не объявлена, выдается ReferenceError
Функции, которые не возвращают значения, возвращают undefined
Доступ к индексу массива, который не существует, также возвращает undefined
нулевой
В отличие от undefined
, которое указывает на переменную, лишенную какого-либо значения, null
— это значение, присвоенное переменной и означающее «отсутствие значения». Следовательно, null
рассматривается как значение объекта, и хотя typeof null
оценивается как object
, это не объект, поскольку он не имеет свойств или методов и не может быть изменен.
let newObject; console.log(newObject); // undefined newObject = null; console.log(newObject); // null console.log(newObject === null); // null newObject = { name: "John Done", age: 30, email: "[email protected]" } //Clear object's value by assigning null newObject = null;
Обычный вариант использования null
— очистить, сбросить или заменить инициализированные переменные.
Поскольку typeof null
оценивается как object
, для проверки того, является ли объект нулевым, рекомендуется использовать оператор ===
строгого равенства. Все остальные примитивы можно проверить с помощью оператора typeof
.
символ
Тип данных symbol
— это новая функция JS, появившаяся в ES6. Символы гарантированно уникальны, и поскольку они неизменяемы, их можно использовать как constants
.
Символы создаются с помощью встроенного объекта Symbol
, конструктор которого принимает один необязательный аргумент (устанавливающий атрибут description
).
const firstName = Symbol("First name"); const lastName = Symbol("Last name"); let person = { [firstName]: "George", [lastName]: "Anton", age: "Forever Young" }; console.log(person[firstName]); // "George" console.log(person[lastName]); // "Anton" const new_symbol = Symbol("This is a new symbol"); const new_symbol2 = Symbol(); console.log(new_symbol.description) // "This is a new symbol" console.log(new_symbol2.description) // undefined
Символы заключаются в []
квадратных скобок, чтобы их можно было установить в качестве ключей для свойств объекта.
Атрибут описания равен undefined
, если он не указан.
const firstName = Symbol.for("First name"); const lastName = Symbol.for("Last name"); let person = { [firstName]: "George", [lastName]: "Anton", age: "Forever Young" }; console.log(person[firstName]); // "George" console.log(person[lastName]); // "Anton"
Символы также можно создавать с помощью Symbol.for
, с той разницей, что эти символы теперь находятся в глобальной области действия программы.
Как показано ниже, символы не могут быть доступны через оператор Member Access
(точка) и не могут быть раскрыты методами Object.getOwnPropertyNames
и Object.keys
.
Они также не могут быть преобразованы в string
, поэтому метод JSON.stringify
пропускает их (и они также не могут быть преобразованы в тип number
).
Хотя это не означает, что символы являются частными.
Чтобы получить доступ к их значению, используйте Computed Member Access
или Reflect.ownKeys
Обычный вариант использования symbols
— это ключи для свойств object
, поскольку их уникальность позволяет избежать конфликтов имен. До символов нам приходилось использовать числа или библиотеки, такие как uuid
, для создания уникальных строк для ключей объектов, что не всегда идеально.
число
Тип данных number
является типом по умолчанию для числовых значений в JavaScript. Он включает целые числа, значения с плавающей запятой и специальные значения, такие как Infinity
, -Infinity
, NaN
(что означает не число) и оба +/- 0
. Поскольку числа представлены с использованием поплавка двойной точности, это означает, что они имеют ограниченную точность и, следовательно, имеют конечное представление.
Целочисленные значения ограничены интервалом [-2⁵³, 2⁵³]
Number.MIN_SAFE_INT
, Number.MAX_SAFE_INTEGER
возвращают максимальное/минимальное значение int
Максимальные/минимальные значения с плавающей запятой можно найти с помощью атрибутов Number.MAX_VALUE
и Number.MIN_VALUE
, значения за пределами этого диапазона не гарантируют точность.
Поплавки вне диапазона [-2¹⁰²⁴,2¹⁰²⁴] представлены +/- Infinity
Согласно ECMAScript существует 2⁵³-2 различных значения NaN
, каждое из которых неотличимо от другого (это единственное значение в JavaScript, которое не равно самому себе).
Значения NaN
возникают при выполнении арифметических операций над нечисловыми значениями или при использовании Number
для преобразования нечисловых значений/строк.
большойинт
bigint
— это новейший примитив, представленный в JavaScript, который позволяет нам представлять целые числа любого размера без потери точности. Используя арифметику произвольной точности, способность bigint представлять числовые цифры ограничена только доступной памятью хост-системы.
Обозначение bigint
осуществляется добавлением n
в конец целочисленного литерала.
Журналы в строках 7,10 показывают, что number
литералы ломаются во время арифметических операций для (2⁵³-1) + 2
, тогда как bigint
нет.
Приведение числового значения к bigint
также можно выполнить с помощью глобальной функции BigInt()
.
Где литералы number
используют Infinity
, BigInt
может обрабатывать значение
Операции между типами bigint
и number
вызовут ошибку TypeError
.
TypeError
будет выброшено во избежание потери точности при вычислениях. Таким образом, неявное приведение операторов к разным типам не будет работать для BigInt
. В качестве наилучшей практики рекомендуется работать только с типами bigint
или number
в программе, а не с обоими одновременно.
Потеря точности может возникнуть при приведении значений больше Number.MAX_SAFE_INTEGER
с использованием функции BigInt
, как показано выше с преобразованием типа number
. Рекомендуется приводить большие значения, когда они имеют тип string
, чтобы избежать такого поведения.
BigInt
позволяет выполнять целочисленные операции без риска переполнения и потери точности и может в конечном итоге стать основой для реализации aBigDecimal
в JavaScript!
Мы видели это при выполнении арифметических операций над типами bigint
и number
, интерпретатор выдает ошибку TypeError
. Хотя операторы нередко выполняют вычисления над разными примитивами (практически так мы получаем NaN
значений!). Такие вычисления могут привести к неожиданным результатам и даже к ошибкам благодаря тому, что известно как неявное приведение типов (или принуждение).
Неявное приведение типов (приведение)
Приведение типов — это процесс преобразования значения из одного типа данных в другой. Существует 2 способа выполнения приведения типов: неявный и явный.
Когда мы использовали глобальную функцию BigInt()
для преобразования значения string
в bigint
, это форма явного приведения типов. Number()
, String()
и Boolean()
— это три другие функции, глобально доступные для явного приведения типов.
С другой стороны, неявное приведение типов — это когда типы данных преобразуются самим языком без вашего ведома. Единственный способ узнать это — знать правила, которые реализует язык для выполнения этих преобразований.
Примечание: тип BigInt
не имеет неявных преобразований в JavaScript.
Выше мы видим пример неявного приведения типов в действии. Оператор +
может выполнять как конкатенацию строк, так и числовое сложение. Хотя, когда операнды имеют разные типы вместо того, чтобы выбрасывать TypeError
, кажется, что JavaScript неявно преобразует тип number
в string
, а затем возвращает результат конкатенации строк.
Следовательно, вместо ожидаемого значения 3
мы получаем 12
.
Давайте пройдемся по оценке:
- Шаги 2 и 4 извлекают значение выражения, в данном случае это наши операнды
x
иy
. - Шаги 5 и 6 игнорируются, так как
ToPrimitive()
действует на объекты, а значения наших операндов имеют примитивные типыnumber
иstring
- Шаг 7 проверяет условие того, является ли какое-либо из значений
string
. В этом случаеy
равно и, следовательно,7a
преобразует наше примитивное значение дляx
вstring
«2», а7c
возвращает нам"1" + "2"
, что равно"12"
.
Следовательно, исправлением нашего предыдущего сценария будет Number(x) + Number(y)
, чтобы получить 3.
Что нам следует ожидать, если x
является boolean
, null
или undefined
?
Как мы видим, во всех приведенных выше случаях x
преобразуется в тип string
и объединяется с y
. Это просто потому, что y
имеет тип string
(шаг 7).
Что, если y
не относится к типу string
?
Давайте еще раз пройдемся по оценке:
- Шаги 2 и 4 извлекают значение для каждого операнда.
- Шаги 5 и 6 игнорируются, как и во всех случаях, когда мы имеем дело с Примитивами.
- Шаг 7 игнорируется, поскольку ни
x
, ниy
не относятся к типуstring
. - Шаги 8 и 9 преобразуют значения
x
иy
вnumber
. - Шаг 10 добавляет проверку, чтобы убедиться, что оба типа
number
одинаковы, и что предыдущее преобразованиеToNumeric()
было успешным. - Шаг 12 возвращает результат
Number::add
, который представляет собой числовую арифметику значений castednumber
.
Теперь, чтобы объяснить результаты:
- Поскольку
Number(undefined)
равноNaN
, аtypeof NaN
равноnumber
, значение двух неопределенных переменных, ставшихNaN
, оценивается, как и ожидалось.
2. NaN
+ любое выражение number
(включая Infinity
) всегда приводит к NaN
3. null
преобразуется в число 0
, которое можно проверить через Number(null)
, следовательно, 0 + 0 = 0
.
4/5. boolean
true и false преобразуются в 1
и 0
соответственно, что дает ожидаемые результаты.
Поэтому знание того, как операторы обрабатывают разные типы данных, помогает предотвратить ошибки. Неявное приведение не является уникальным для оператора +
!
Теперь мы пропустили один случай: как +
взаимодействует с objects
?
ToPrimitive() и примитивное принуждение
Если мы помним, шаги 5 и 6 вызывают ToPrimitive()
для значений операнда.
В JavaScript абстрактные операции — это спецификации алгоритма или поведения, которые используются внутри языка для выполнения определенных задач, таких как принуждение.
ToPrimitive()
— это абстрактная операция, недоступная для разработчиков.
ToPrimitive
используется для преобразования объектов в примитивы и принимает 2 аргумента.
Его первый аргумент, input
, — это выражение или object
для преобразования, а второй, необязательный preferredType
, — одно из значений «по умолчанию», «строка» или «число». preferredType
также называется подсказкой.
Шаг 1 проверяет, относится ли ввод к типу object
,если нет, то ввод уже является примитивным и никаких действий не выполняется (шаг 2).
Операция ToPrimitive
работает в следующем порядке:
- Просматривает метод
valueOf
объекта и, если он существует, вызывает его. ЕслиvalueOf
возвращает примитив, то используется это значение примитива. - Если
valueOf
не возвращает примитив, то он просматривает методtoString
объекта и, если существует, вызывает его. ЕслиtoString
возвращает примитив, то используется это значение. - Если
toString
не возвращает примитив, то смотрим встроенные методыObject.prototype
дляvalueOf
иtoString
(повторяя шаги 1 и 2 до тех пор, пока не будет возвращен примитив). - Если примитив не возвращается, выдается
TypeError
.
В приведенном выше коде мы видим, что и obj + "World"
, и obj + 8
имеют значение «obj» как 2
. Это связано с тем, что метод valueOf
выполняется первым, а поскольку 2
является типом number
, то он является примитивом и именно он возвращается.
Теперь вспомните, это Шаг 5 в приведении +
operator. Следовательно, примитив 2
преобразуется в string
, если вторым операндом является строка, и, следовательно, у нас есть конкатенация строк. В противном случае остается number
и мы добавляем.
Один из способов переопределить предыдущее поведение операции ToPrimitive
— предоставить подсказку. Ранее подсказка была установлена как «по умолчанию», и чтобы предоставить подсказку, нам нужно использовать явное приведение типов.
При явном приведении «obj» с String
мы сообщаем ToPrimitive
, что сначала хотим использовать toString()
(это наш preferredType
), и в обеих ситуациях возвращается Hello
(а затем мы возвращаемся к шагу 7 руководства +
).
Шаги, которые мы только что описали, на самом деле определяются абстрактной операцией OrdinaryToPrimitive
, которая на самом деле является шагом 1d в ToPrimitive
. Мы по сути пропустили 1а — 1с. Теперь я расскажу, когда используется 1a и что означает exoticToPrim
.
Внутренний метод [@@toPrimitive]()
Мы видели, что когда мы передаем подсказку абстрактной операции ToPrimitive
, такой как string
, то внутри мы вызываем соответствующие методы (в данном случае toString()
) объекта (если такие методы существуют).
(Внутренняя) функция, которая выполняет эти задачи, представляет собой общеизвестный символ с именем [@@toPrimitive]()
("общеизвестные" символы обозначаются @@
и являются предопределенными символами в JavaScript. ).
Хотя этот метод symbol
является внутренним для JavaScript, его можно переопределить с помощью Symbol.toPrimitive
. JavaScript просматривает объекты с помощью этого метода как экзотические, и свойство exoticToPrim
становится определенным (шаги 1a/b).
Выше приведен пример экзотического объекта. Хотя valueOf
и toString
определены, метод [Symbol.toPrimitive]()
переопределяет эти варианты поведения.
Поскольку у нас нет явного hint === "default"
или просто return
за рамками блока if/else-if
, тогда obj + " World"
вернет undefined
. Это связано с тем, что мы переопределили поведение «по умолчанию», и [@@toPrimitive]()
ожидает значение из подсказки для «по умолчанию», но не имеет назначенного значения.
Подведение итогов
Чтобы подвести итог, давайте рассмотрим, почему {} + []
возвращает [object Object]
:
- Шаги 2, 4 операции
+
,GetValue({}) = {}
иGetValue([]) = []
- Поскольку они не являются примитивными, шаги 5, 6 операции
+
вызываютToPrimitive
- Шаг 1 операции
ToPrimitive
подтверждает, что{}
и[]
относятся к типуobject
. - Мы пропускаем шаги 1a, 1b, поскольку они пусты и, следовательно, не являются экзотичными.
- Шаг 1c использует
OrdinaryToPrimitive
. Поскольку они пусты, нет пользовательских методовvalueOf
иtoString
. - Сначала рассмотрим левый операнд.
{}
является членомObject.prototype
и, следовательно, наследует методы.valueOf()
и.toString()
. По умолчаниюOrdinaryToPrimitive
вызывает{}.valueOf()
, который возвращаетobject
. - Поскольку
object
не является примитивом, он затем вызывает{}.toString()
, это возвращает типstring
[object Object]
. - Рассмотрим правый операнд.
[]
является членомArray.prototype
, а также наследует методы.valueOf()
и.toString()
. Сначала вызывается[].valueOf()
, который возвращает типobject
- Поскольку
object
не является примитивом, он затем вызывает[].toString()
, это возвращает типstring
"”
. - Шаг 7 операции
+
, так как оба операндаstring
, мы выполняем конкатенацию строк, и поэтому[object Object] + ""
оценивается как строка[object Object]
.
Вдохновение
Если вы зашли так далеко, я хотел бы поделиться вдохновением для этой статьи:
Посмотрите, сможете ли вы разобраться в выводах, учитывая то, что мы теперь знаем!
Я надеюсь, что вы найдете эту статью информативной и, прежде всего, спасибо за чтение!
Connect with me on LinkedIn.