Работа нестрогого равенства в JS на примере задачки
- вторник, 18 июля 2023 г. в 00:00:15
Недавно столкнулся с одной из "типовых" задач на собеседованиях. Просто увидел такое выражение где-то в интернете и не совсем понял как оно работает. А именно не понимал почему пустой массив дает 0.
![] == []
Такое выражение вернет true.
Решил конкретно рассмотреть данный пример по спецификации, и понять что происходит шаг за шагом.
![] - возвращаетfalse, потому что объект в Boolean всегда true и противоположность true - это false
При нестрогом сравнении где операнды имеют разный тип, операнды приводятся к числу
false преобразуется в число 0
Пустой массив преобразуется в число 0, потому что при приведении объектов к примитиву будет вызван сначала valueOf, потом toString (иногда порядок вызовов будет разный) и возьмется первый примитивный результат вызова. У нас возьмется результат вызова toString, потому что он вернет примитив (valueOf на объекте, вернет тот же объект). toString нам вернет примитив, потому что вызов toString на массиве вызывает его метод join, а метод join с пустым массивом вернет пустую строку '', а пустая строка '' превратится в 0.
Сравнение 0 и 0 дадут true
В данном случае у нас имеется 4 выражения (Expressions).
Порядок выполнения можно посмотреть в разделе 13 (https://262.ecma-international.org/14.0/#sec-ecmascript-language-expressions). Только там не будет красивой таблички. Придется идти по экспрешенам и смотреть что в каком порядке выполнится.
![] == []
^^ -> 1
![] == []
^^^ -> 2
![] == []
^^ -> 3
![] == []
^^^^^^^^^^ -> 4Создание массива
Вызов логического НЕ c массивом
Создание массива
Нестрогое равенство
Давайте на примере разберем запись спецификации относительно 4 пункта.
Видим вот такую запись: ([?In, ?Yield, ?Await] - специально удалил, что бы не путалось под глазами, данная запись нас сейчас не интересует)
EqualityExpression :
RelationalExpression
EqualityExpression == RelationalExpression
EqualityExpression != RelationalExpression
EqualityExpression === RelationalExpression
EqualityExpression !== RelationalExpressionДанная запись означает что EqualityExpression будет равна чему то из списка.
Возьмем на пример выражение 0 == 0 == 0
Его мы можем записать как:
EqualityExpression == RelationalExpression
^^^^^^^^^^^^^^^^^^ -> EqualityExpression == RelationalExpression
EqualityExpression == RelationalExpression == RelationalExpression
^^^^^^^^^^^^^^^^^^ -> RelationalExpression
RelationalExpression == RelationalExpression == RelationalExpressionЗдесь мы не стали раскладывать RelationalExpression далее по цепочки, но ниже мы это сделаем.
Все выражения можно опять же представить деревом, в котором главный узел - это выражение с самым низким приоритетом, а его листья - выражения с более высоким приоритетом.
Далее мы спускаемся по цепочки определений.
RelationalExpression :
RelationalExpression :
ShiftExpression
RelationalExpression < ShiftExpression
RelationalExpression > ShiftExpression
RelationalExpression <= ShiftExpression
RelationalExpression >= ShiftExpression
RelationalExpression instanceof ShiftExpression
RelationalExpression in ShiftExpression
PrivateIdentifier in ShiftExpressionТо есть у нас в каждом верхнем определении есть еще набор экспрешенов, который его определяет, и так далее вниз, пока не дойдем до самых приоритетных выражений. В данном случае выражение RelationalExpression может в себе содержать любое из списка, например RelationalExpression < ShiftExpression. Поэтому мы можем сказать, что верхние выражения содержат в себе весь набор нижних выражений. Например, взяв выражение RelationalExpression мы можем этим описать любое выражение, которые входит в него, от UpdateExpression ** ExponentiationExpression до простого Literal, но не выше цепи, то есть оно уже не будет являться EqualityExpression, а наоборот, будет его потомком.
Вся цепочка с нашими операндами будет выглядеть так:
EqualityExpression
RelationalExpression
ShiftExpression
AdditiveExpression
MultiplicativeExpression
ExponentiationExpression
UnaryExpression
UpdateExpression
LeftHandSideExpression
NewExpression
MemberExpression
PrimaryExpression
Literal
Все. Далее уже идет определение литерала:
NumericLiteral
DecimalLiteral
DecimalIntegerLiteral
0
Мы можем заменить каждый наш операнд на его представление ниже по дереву (из каких выражений он может состоять)
В конце концов у нас будет цепочка выглядеть так:
1) EqualityExpression == RelationalExpression
2) (EqualityExpression == RelationalExpression) == RelationalExpression
3) ((RelationalExpression) == RelationalExpression) == RelationalExpression
Далее мы можем заменить RelationalExpression на его представление ниже по дереву, пока не дойдем до нужного выражения.
...
и так далее, до:
Literal == Literal == Literal
что станет:
0 == 0 == 0Что бы закрепить, давайте рассмотрим начало цепочки 1 + 1 == 0 == 0.
Буду заменять только те выражение, которое внутри себя содержит 1 + 1
1) EqualityExpression == RelationalExpression
2) (EqualityExpression == RelationalExpression) == RelationalExpression
3) ((RelationalExpression) == RelationalExpression) == RelationalExpression
4) (((ShiftExpression)) == RelationalExpression) == RelationalExpression
5) ((((AdditiveExpression))) == RelationalExpression) == RelationalExpression
6) (((((AdditiveExpression + MultiplicativeExpression)))) == RelationalExpression) == RelationalExpression
...
n) (((((Literal + Literal)))) == Literal) == Literal
-----
(((((1 + 1)))) == 0) == 0Если хотите сами попробовать опуститься с самого начала, вот вам самый менее приоритетный оператор: (https://262.ecma-international.org/14.0/#sec-comma-operator)
Может показаться, что как будто мы начинаем выполнять выражения с самого низко приоритетного оператора. Так оно и есть! Но как так?
Все просто. Во время разрешения экспрешенов, у вас идет выполнения левых и правых операндов. И пока они не выполнились, наше выражение ждет(если можно так выразиться) их выполнения.
Простыми словами - самый низко приоритетный оператор запускается самым первым, но заканчивает свою работу самым последним. Можете представить это как стек операций, где в самом верху выполнится самая приоритетная операция, а в самом низу - самая низко приоритетная операция.
Вот алгоритм выражения сравнения из спецификации:(https://262.ecma-international.org/14.0/#sec-equality-operators-runtime-semantics-evaluation)
EqualityExpression == RelationalExpression
1. Let lref be ? Evaluation of EqualityExpression.
2. Let lval be ? GetValue(lref).
3. Let rref be ? Evaluation of RelationalExpression.
4. Let rval be ? GetValue(rref).
5. Return ? IsLooselyEqual(rval, lval).Видно что на 1 шаге выполняется Evaluation нашего левого операнда(EqualityExpression), а на 3 шаге правого(RelationalExpression). Это означает, что пока наши операнды не выполнятся, шаг 5 не наступит. Можно заменить, что выполнение идет слева направо. Сначала будут выполняться все левые операнды (экспрешены), потом правые.
Как пример в выражении 1 + 2 * 3 == 4 операнды закончат выполнение по порядку - умножение, потом сложение и потом сравнение, но начнут выполнятся в порядке - сравнение, сложение, умножение. А точнее:
┌── Началось выполнение экспрешена нестрогого равенства ==
│ ├── Выполняется левый операнд
│ │ ├── Началось выполнение экспрешена сложения
│ │ │ ├── Выполняется левый операнд
│ │ │ │ ├── Началось выполнение экспрешена получение числа 1
│ │ │ │ └── Закончилось выполнение экспрешена получения числа, вернулось число 1
│ │ │ └── Выполняется правый операнд
│ │ │ ├── Началось выполнение экспрешена умножения *
│ │ │ │ ├── Выполняется левый операнд
│ │ │ │ │ ├── Началось выполнение экспрешена получения числа 2
│ │ │ │ │ └── Закончилось выполнение экспрешена получения числа, вернулось число 2
│ │ │ │ └── Выполняется правый операнд
│ │ │ │ ├── Началось выполнение экспрешена получения числа 3
│ │ │ │ └── Закончилось выполнение экспрешена получения числа, вернулось число 3
│ │ │ └── Закончилось выполнение экспрешена умножения, вернулось число 6
│ │ └── Закончилось выполнение экспрешена сложения, вернулось число 7
│ └── Выполняется правый операнд
│ ├── Началось выполнение экспрешена получения числа 4
│ └── Закончилось выполнение экспрешена получения числа, вернулось число 4
└── Закончилось выполнение выражение нестрогого равенства, вернулось значение falseОперанды сравнения - (1 + 2 * 3) и (4)
Операнды сложения - (1) и (2 * 3)
Операнды умножения - (2) и (3)
И это логичное поведение парсера. Вы строите дерево. Где на самой верхушке будет то выражение, что выполниться самым последним с его листьями - операндами, но запустится самым первым(если можно так выразиться).
1 + 2 * 3 == 4
/ \
/ \
/ 4
1 + 2 * 3
/ \
/ \
1 \
2 * 3
/ \
/ \
2 3Правильнее было бы это дерево нарисовать все же так:
==
/ \
/ \
/ 4
+
/ \
/ \
1 \
*
/ \
/ \
2 3Как вы видите, здесь 7 экспрешенов (получение числа это тоже expression). Вообще можно просто прочитать про то, как работают те же абстрактные синтаксические деревья (AST).
Вернемся к нашему изначальному примеру.
![] == []
/ \
/ \
![] \
\ []
[] Давайте тоже его отобразим в нормальном виде
=
/ \
/ \
! \
\ []
[] Давайте по шагам получи результаты наших операндов.
1)Получаем массив - тут все просто.
2)Логическое НЕ к массиву. Давайте смотреть, что там. Понятное дело все знаю что это выражение вернет false, но почему? Ответ как и всегда, да потому что так написано в спецификации. Вот и давайте посмотрим, что там написано:
Logical NOT Operator ( ! ): (https://262.ecma-international.org/14.0/#sec-logical-not-operator)
1. Let expr be ? Evaluation of UnaryExpression.
2. Let oldValue be ToBoolean(? GetValue(expr)).
3. If oldValue is true, return false.
4. Return true.3, 4 пункт просто возвращают противоположный примитивное булеан значение, мы же идем смотреть 2 пункт. (пока что на GetValue(expr) не надо обращать внимание, оно возвращает специальный тип спецификации, в нашем случае мы можем воспринимать это как просто то, что возвращает нам само значение - наш массив). Видим операцию ToBoolean
ToBoolean: (https://262.ecma-international.org/14.0/#sec-toboolean)
1. If argument is a Boolean, return argument.
2. If argument is one of undefined, null, +0𝔽, -0𝔽, NaN, 0ℤ, or the empty String, return false.
3. NOTE: This step is replaced in section B.3.6.1.
4. Return true.Во 2 пункте данного алгоритма как раз наблюдаем все так называемые в народе falsy значения.
Пункт 3 к нашей ситуации не относится, его можно пропустить (кому интересно, могут почитать, там описывается поведение того, что Object может вернуть false). Вот ссылка на данный раздел. (https://262.ecma-international.org/14.0/#sec-IsHTMLDDA-internal-slot)
Как мы видим, если наше значение не находится в списке в пункте 2, мы возвращаем true.
3)Получаем массив - тут все просто.
4)Нестрогое равенство.
EqualityExpression == RelationalExpression
1. Let lref be ? Evaluation of EqualityExpression.
2. Let lval be ? GetValue(lref).
3. Let rref be ? Evaluation of RelationalExpression.
4. Let rval be ? GetValue(rref).
5. Return ? IsLooselyEqual(rval, lval).Тут остановимся по подробнее и так же посмотрим алгоритм. А точнее 5 пункт данного алгоритма Return ? IsLooselyEqual(rval, lval).
IsLooselyEqual ( x, y ): (https://262.ecma-international.org/14.0/#sec-islooselyequal)
1. If Type(x) is Type(y), then
a. Return IsStrictlyEqual(x, y).
2. If x is null and y is undefined, return true.
3. If x is undefined and y is null, return true.
4. NOTE: This step is replaced in section B.3.6.2.
5. If x is a Number and y is a String, return ! IsLooselyEqual(x, ! ToNumber(y)).
6. If x is a String and y is a Number, return ! IsLooselyEqual(! ToNumber(x), y).
7. If x is a BigInt and y is a String, then
a. Let n be StringToBigInt(y).
b. If n is undefined, return false.
c. Return ! IsLooselyEqual(x, n).
8. If x is a String and y is a BigInt, return ! IsLooselyEqual(y, x).
9. If x is a Boolean, return ! IsLooselyEqual(! ToNumber(x), y).
10. If y is a Boolean, return ! IsLooselyEqual(x, ! ToNumber(y)).
11. If x is either a String, a Number, a BigInt, or a Symbol and y is an Object, return ! IsLooselyEqual(x, ? ToPrimitive(y)).
12. If x is an Object and y is either a String, a Number, a BigInt, or a Symbol, return ! IsLooselyEqual(? ToPrimitive(x), y).
13. If x is a BigInt and y is a Number, or if x is a Number and y is a BigInt, then
a. If x is not finite or y is not finite, return false.
b. If ℝ(x) = ℝ(y), return true; otherwise return false.
14. Return false.Написано всего много, но нас интересуют только некоторые пункты.
Обратите внимание на пункт 1. Если типы операндов одинаковые, то вызывается алгоритм строгого равенства (===).
У нас х - это true, y - это []
Посмотрим на наш случай, это пункт 9. Вызывается рекурсивно этот же алгоритм, только приводит наш x к числу. Алгоритм ToNumber можете глянуть сами(https://262.ecma-international.org/14.0/#sec-tonumber), просто приведу выдержку данного алгоритма для нашего аргумента x - (4. If argument is either null or false, return +0𝔽.) То есть алгоритм нам вернет 0 для false.
У нас х - это 0, y - это []
Теперь же наш алгоритм остановится на пункте 11. Вызовется опять рекурсивно, и теперь сработает алгоритм ToPrimitive на нашем y.
Тут стоить остановиться и посмотреть на данный алгоритм.
ToPrimitive: (https://262.ecma-international.org/14.0/#sec-toprimitive)
1. If input is an Object, then
a. Let exoticToPrim be ? GetMethod(input, @@toPrimitive).
b. If exoticToPrim is not undefined, then
i. If preferredType is not present, let hint be "default".
ii. Else if preferredType is string, let hint be "string".
iii. Else,
1. Assert: preferredType is number.
2. Let hint be "number".
iv. Let result be ? Call(exoticToPrim, input, « hint »).
v. If result is not an Object, return result.
vi. Throw a TypeError exception.
c. If preferredType is not present, let preferredType be number.
d. Return ? OrdinaryToPrimitive(input, preferredType).
2. Return input.Пункт a вернет нам undefined, потому что у нашего массива (и его прототипов) нет метода Symbol.toPrimitive. Соответственно пункт b не сработает. Значит переходим к пункту c и видим, что preferredType стал равен number, далее в следующем алгоритме нам понадобиться эта информация. Идем в пункт d и проваливаемся в алгоритм OrdinaryToPrimitive(input, preferredType)
OrdinaryToPrimitive ( O, hint ): (https://262.ecma-international.org/14.0/#sec-ordinarytoprimitive)
1. If hint is string, then
a. Let methodNames be « "toString", "valueOf" ».
2. Else,
a. Let methodNames be « "valueOf", "toString" ».
3. For each element name of methodNames, do
a. Let method be ? Get(O, name).
b. If IsCallable(method) is true, then
i. Let result be ? Call(method, O).
ii. If result is not an Object, return result.
4. Throw a TypeError exception.Вкратце говоря, алгоритм смотрит на hint (preferredType), и в зависимости от его значения формирует вызовы valueOf и toString в определенном порядке. Так как у нас hint равен "number", то первым вызовется valueOf, а потом уже toString. Когда какой либо метод вернет значение не являющимся объектом, то алгоритм завершиться, иначе будет TypeError, можете сами попробовать переопределить методы в прототипе на те, что будут возвращать всегда объект и попробовать.
Так как метод valueOf в прототипе массива нет, то идем в прототип объекта и смотрим, что там valueOf делает - это Return ? ToObject(this value) он возвращает этот же массив, а значит выполняется метод toString. А вот этот метод уже есть в Array.prototype, давайте на него взглянем.
Array.prototype.toString ( ): (https://262.ecma-international.org/14.0/#sec-array.prototype.tostring)
1. Let array be ? ToObject(this value).
2. Let func be ? Get(array, "join").
3. If IsCallable(func) is false, set func to the intrinsic function %Object.prototype.toString%.
4. Return ? Call(func, array).В общем говоря, вызывается метод join на нашем массиве. Данный метод при пустом массиве, вернет пустую строку (https://262.ecma-international.org/14.0/#sec-array.prototype.join) (пункт 5 алгоритма Array.prototype.join).
У нас х - это 0, y - это ""
Вызывается 5 пункт алгоритма IsLooselyEqual то есть - IsLooselyEqual(x, ! ToNumber(y)) вызывается рекурсивно этот же алгоритм , только теперь наша y кидается в алгоритм ToNumber. Внутри него вызовется алгоритм StringToNumber (https://262.ecma-international.org/14.0/#sec-stringtonumber), внутри которого вызовется алгоритм StringNumericValue (https://262.ecma-international.org/14.0/#sec-runtime-semantics-stringnumericvalue) который вернет нам 0, и нет, не только потому что строка пустая, строка с пробельными символами тоже вернет 0, не путайте с алгоритмом ToBoolean. Можете сами проверить выражение ![] == [' \n '] вернет вам все тот же true. Я не стал рассписывать конкретные алгоритмы привода строки к числу, что бы не сильно углубляться, можете посмотреть на них лично или поверить мне, что нам вернется 0.
У нас х - это 0, y - это 0
Выполнится 1 пункт алгоритма IsLooselyEqual. Который означает перейти в алгоритм IsStrictlyEqual.
IsStrictlyEqual: (https://262.ecma-international.org/14.0/#sec-isstrictlyequal)
1. If Type(x) is not Type(y), return false.
2. If x is a Number, then
a. Return Number::equal(x, y).
3. Return SameValueNonNumber(x, y).Пункт a:
1. If x is NaN, return false.
2. If y is NaN, return false.
3. If x is y, return true.
4. If x is +0𝔽 and y is -0𝔽, return true.
5. If x is -0𝔽 and y is +0𝔽, return true.
6. Return false.У нас выполнится пункт 3 и вернется true.
Вот и весь путь данного выражения. Да, я просто прошелся по спецификации и тупо написал ее шаги, вы и сами можете это сделать. Возможно кому то это будет интересно и полезно. И не забывайте самого главного, что у вас есть 1 источник истины - это спецификация, как раз таки на ее основе и создаются (по крайне мере должны) учебники и книги, а так же рантаймы, которые и выполняют ваш JS(тут надо уточнить, что рантаймы не всегда жестко следуют шагам спецификации и могут некоторые алгоритмы оптимизировать, но это уже совсем другая история).