Работа нестрогого равенства в 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(тут надо уточнить, что рантаймы не всегда жестко следуют шагам спецификации и могут некоторые алгоритмы оптимизировать, но это уже совсем другая история).