Как стать автором
Обновить

Работа нестрогого равенства в JS на примере задачки

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров6K

Недавно столкнулся с одной из "типовых" задач на собеседованиях. Просто увидел такое выражение где-то в интернете и не совсем понял как оно работает. А именно не понимал почему пустой массив дает 0.

![] == []

Такое выражение вернет true.

Решил конкретно рассмотреть данный пример по спецификации, и понять что происходит шаг за шагом.

TL;DR

  1. ![] - возвращаетfalse, потому что объект в Boolean всегда true и противоположность true - это false

  2. При нестрогом сравнении где операнды имеют разный тип, операнды приводятся к числу

  3. false преобразуется в число 0

  4. Пустой массив преобразуется в число 0, потому что при приведении объектов к примитиву будет вызван сначала valueOf, потом toString (иногда порядок вызовов будет разный) и возьмется первый примитивный результат вызова. У нас возьмется результат вызова toString, потому что он вернет примитив (valueOf на объекте, вернет тот же объект). toString нам вернет примитив, потому что вызов toString на массиве вызывает его метод join, а метод join с пустым массивом вернет пустую строку '', а пустая строка '' превратится в 0.

  5. Сравнение 0 и 0 дадут true

Подсчет количества выражений

В данном случае у нас имеется 4 выражения (Expressions).

Порядок выполнения можно посмотреть в разделе 13 (https://262.ecma-international.org/14.0/#sec-ecmascript-language-expressions). Только там не будет красивой таблички. Придется идти по экспрешенам и смотреть что в каком порядке выполнится.

![] == []
 ^^ -> 1

![] == []
^^^ -> 2

![] == []
       ^^ -> 3

![] == []
^^^^^^^^^^ -> 4
  1. Создание массива

  2. Вызов логического НЕ c массивом

  3. Создание массива

  4. Нестрогое равенство

Давайте на примере разберем запись спецификации относительно 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, а наоборот, будет его потомком.

Вся цепочка с нашими операндами будет выглядеть так:

  1. EqualityExpression

  2. RelationalExpression

  3. ShiftExpression

  4. AdditiveExpression

  5. MultiplicativeExpression

  6. ExponentiationExpression

  7. UnaryExpression

  8. UpdateExpression

  9. LeftHandSideExpression

  10. NewExpression

  11. MemberExpression

  12. PrimaryExpression

  13. Literal

Все. Далее уже идет определение литерала:

  1. NumericLiteral

  2. DecimalLiteral

  3. DecimalIntegerLiteral

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

Теги:
Хабы:
Всего голосов 10: ↑9 и ↓1+8
Комментарии8

Публикации

Истории

Работа

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область