Более глубокое понимание основных концепций программирования

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.

Теперь, чтобы объяснить результаты:

  1. Поскольку 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 работает в следующем порядке:

  1. Просматривает метод valueOf объекта и, если он существует, вызывает его. Если valueOf возвращает примитив, то используется это значение примитива.
  2. Если valueOf не возвращает примитив, то он просматривает метод toString объекта и, если существует, вызывает его. Если toString возвращает примитив, то используется это значение.
  3. Если toString не возвращает примитив, то смотрим встроенные методы Object.prototype для valueOf и toString (повторяя шаги 1 и 2 до тех пор, пока не будет возвращен примитив).
  4. Если примитив не возвращается, выдается 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]:

  1. Шаги 2, 4 операции +, GetValue({}) = {}и GetValue([]) = []
  2. Поскольку они не являются примитивными, шаги 5, 6 операции + вызывают ToPrimitive
  3. Шаг 1 операции ToPrimitive подтверждает, что {} и [] относятся к типу object.
  4. Мы пропускаем шаги 1a, 1b, поскольку они пусты и, следовательно, не являются экзотичными.
  5. Шаг 1c использует OrdinaryToPrimitive. Поскольку они пусты, нет пользовательских методов valueOf и toString.
  6. Сначала рассмотрим левый операнд. {} является членом Object.prototype и, следовательно, наследует методы .valueOf() и .toString(). По умолчанию OrdinaryToPrimitive вызывает {}.valueOf(), который возвращает object.
  7. Поскольку object не является примитивом, он затем вызывает {}.toString(), это возвращает тип string [object Object].
  8. Рассмотрим правый операнд. [] является членом Array.prototype, а также наследует методы .valueOf() и .toString(). Сначала вызывается [].valueOf(), который возвращает тип object
  9. Поскольку object не является примитивом, он затем вызывает [].toString(), это возвращает тип string "”.
  10. Шаг 7 операции +, так как оба операнда string, мы выполняем конкатенацию строк, и поэтому [object Object] + "" оценивается как строка [object Object].

Вдохновение

Если вы зашли так далеко, я хотел бы поделиться вдохновением для этой статьи:

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

Я надеюсь, что вы найдете эту статью информативной и, прежде всего, спасибо за чтение!

Connect with me on LinkedIn.

Ресурсы