Operatory porównania w języku JavaScript

Operatory porównania w języku JavaScript

Porównywanie wartości zmiennych to jedna z podstawowych operacji każdego języka programowania. Często przyjdzie nam sprawdzić czy w danej kolekcji jest dokładnie n-elementów, czy parametr przekazany do funkcji ma dokładnie taką wartość o jaką nam chodzi, albo czy jedna wartość jest większa od drugiej.

W języku JavaScript służą do tego operatory równości, czyli ==, ===, ich zaprzeczenia, !=, !==, oraz nierówności, >, <, >= oraz <=.

O ile operatory nierówności wydają się być całkiem naturalne i łatwe do zrozumienia (pozornie), to z tymi związanymi z równością wartości wielu programistów ma pewne problemy. Cóż to za dziwny język, w którym mogę napisać zarówno == jak i ===. Czy to nie błąd powstały na etapie projektowania?

Właśnie z tymi dwoma konstrukcjami przyjdzie nam popracować w dzisiejszym poście – postaram się wyjaśnić jaka jest między nimi różnica i skąd wiadomo jak one właściwie działają. Operatorom nierówności poświęcimy osobny odcinek w przyszłości.

Standard języka – ECMAScript

Na początku trochę szerszego kontekstu – uporządkujmy naszą wiedzę o tym skąd właściwie wziął się standard języka JavaScript i czym on tak naprawdę jest.

Standard został utworzony przez stowarzyszenie ECMA (https://www.ecma-international.org) powstałe w 1961r., zajmujące się standaryzacją systemów informatycznych. W jego skład wchodzą rozmaite firmy zajmujące się sprzedażą sprzętu, tworzeniem i rozwijaniem oprogramowania oraz innych elementów ze świata informatyki.

Warto mieć na uwadze, że nie jest to organizacja zajmująca się jedynie standardem języka JavaScript. Opracowane przez nią standardy to długa lista którą można znaleźć tutaj (https://www.ecma-international.org/publications/standards/Standard.htm). Dla nasz szczególnie interesująca jest pozycja ECMA-262:

ECMAScript® 2016 Language Specification, 7th edition (June 2016)

Link do niej znajduje się TUTAJ.

Jest to dokument standaryzujący język przeglądarek internetowych. W domyśle miało to być jego jedyne zastosowanie, jednak JavaScript – najpopularniejsza implementacja tego standardu – przeniósł go na wiele innych miejsc, takich jak chociażby serwery.

W dzisiejszym poście dotyczącym operatorów porównania będziemy starali się przejść przez poszczególne fragmenty standardu ECMAScript aby zrozumieć, jakie reguły stoją za takim a nie innym działaniem języka JavaScript.

Diabeł tkwi w szczegółach

Potocznie o operatorze === mówi się, że zwraca on true jeśli oba argumenty są tego samego typu i mają tę samą wartość. Jeśli porównujemy liczby to nie ma sprawy, wiadomo, że 5 === 5 zwróci truea 3 === 8 zwróci false.

Co jednak wydarzy się pod spodem, jeśli zechcemy porównać dwa obiekty?

Jaką wartość mają te obiekty – taką samą, czy różną?

Weźmy teraz operator który zwraca najwięcej problemów, czyli ==. Dzięki niemu porównamy dwie wartości, ale JavaScript wykona dodatkowo operacje na typach argumentów przed ich porównaniem.

Tutaj mamy podobną sytuację, jeśli wykonamy 4 == 4 to otrzymamy true, jednak co jeśli w ten sam sposób porównamy 4 jako łańcuch znaków i 4 jako liczbę? Co jeśli porównamy datę i obiekt, albo dwa obiekty?

Możesz być pewien, że nie są to sytuacje rodem z teleturniejów, gdzie wynik zależy od nastroju, pogody albo ciśnienia. Reguły działania obu operatorów są krok po kroku opisane w standardzie ECMAScript i teraz przeanalizujemy je bardziej dokładnie.

Operator === (Strict Equality Comparison)

Zaczniemy od operatora w którym zgodny musi być zarówno typ jak i wartość argumentów. Zasadę działania znajdziemy tutaj – teraz przejdziemy przez nią krok po kroku (szczególnie ciekawy jest ten ostatni).

   1. If Type(x) is different from Type(y), return false.

Pierwszym krokiem jest sprawdzenie typów argumentów – jeśli są one różne, to bez przechodzenia dalej otrzymamy wartość false. Zapis Type(x) oznacza w tym przypadku jeden z konkretnie zdefiniowanych w standardzie typów takich jak Boolean, String, Symbol czy Number.

Na tym etapie wszystkie konstrukcje pokroju 4 === ‚4’ czyli porównywania stringów i liczb zwracają false, dlatego operator ten uważany jest za zdecydowanie bardziej bezpieczny i naturalny w porównaniu do ==. Co jeśli porównujemy jednak argumenty tego samego typu?

Jeśli oba typy są zgodne, to przechodzimy dalej:

   2. If Type(x) is Number, then
         If x is NaN, return false.
         If y is NaN, return false.
         If x is the same Number value as y, return true.
         If x is +0 and y is -0, return true.
         If x is -0 and y is +0, return true.
         Return false.

Jeśli typem pierwszego argumentu jest Number (a co za tym idzie, typem drugiego również jest Number), to wykonujemy następujące sprawdzenia

  • jeśli x lub y są NaN to zwracamy false (NaN, czyli not-a-number to wartość nie spełniająca kryteriów liczby możliwej do zaprezentowania, jednak posiadająca typ Number – do sprawdzenia przez typeof(NaN) )
  • jeśli x i y mają taką samą wartość, to zwracamy true (porównanie dwóch liczb)
  • poza wartościami „typowymi” dla ludzi musimy jeszcze sprawdzić ciekawostkę ze świata liczb zmiennoprzecinkowych i ich reprezentacji w komputerach, a mianowicie +0 oraz -0 (więcej na ten temat tutaj)
  • jeśli wartości nie spełniają przewidzianych kryteriów, to zwracane jest false

Jeśli dwa argumenty są tego samego typu, jednak nie są liczbami, to musimy wykonać kolejny krok:

3. Return SameValueNonNumber(x, y).

To jeden z tych szczegółów o których nie mówi się zbyt wiele – niby wiadomo jak działa, a jednak mało kto chce to sprawdzić. Zobaczmy czym jest więc algorytm SameValueNonNumber opisany tutaj.

Zaczynamy od dwóch założeń:

   1. Assert: Type(x) is not Number.

   2. Assert: Type(x) is the same as Type(y).

Pierwszy z porównywanych argumentów nie może być liczbą oraz dwa argumenty są tego samego typu. Standard wyjaśnia, że założenia określane jako Assert są niezmienne i w danym momencie nie przewiduje się innych okoliczności.

   3. If Type(x) is Undefined, return true.

   4. If Type(x) is Null, return true.

Następnie opisane zostaje porównanie wartości undefined oraz null. Ten fragment powoduje, że konstrukcje

będą zwracały wartość true.

Jeśli wciąż nie trafiliśmy w typ argumentów, to sprawdzamy dalej:

a) String – jeśli argumenty są typu String, to muszą być takiej samej długości, a ich znaki (określane jako code units) na poszczególnych indeksach muszą się zgadzać

b) Boolean – tutaj sprawa jest prosta, true zostanie zwrócone tylko w przypadków gdy oba argumenty są true lub oba są false (przypominam, że sprawdzamy równość, a nie wartość logiczną)

c) Symbol – true zostanie zwrócone jeśli oba argumenty mają taką samą wartość symbolu (Symbolem określamy każdy nie-string który może być użyty jako klucz obiektu)

Na koniec zostaje tylko typ Object. Operator === zwróci true w momencie porównania dwóch obiektów tylko wtedy, gdy są to po prostu te same obiekty. Zobaczmy na poniższe przykłady:

No i w ten sposób doszliśmy do końca algorytmu który stoi za działaniem operatora ===. W jego działaniu nie ma żadnej niejawnej konwersji typów i pracy wykonywanej bez naszej wiedzy, dlatego jest on uznawany za podstawowy i „ten właściwy”.

Możecie być pewni, że używając go nie będziecie zmuszeni do rozwiązywania sytuacji nieprzewidzianych, kiedy ktoś uzna że porównywanie stringów i liczb jest dobrym pomysłem albo np. nie będzie chciało mu się poprawnie dopasować typów danych na styku dwóch warstw aplikacji.

Operator == (Abstract Equality Comparison)

Kojarzycie te wszystkie zabawne filmiki o języku JavaScript? Gwarantuję wam, że w każdym z nich wystąpi porównanie tym operatorem.

Zobaczmy co zwraca on dla kilku wybranych argumentów takich jak liczba zero czy pusta tablica:

Naprawdę dziwny to operator, który dla sprawdzenia równości liczby zero i znaku spacji zwraca true. Aby zrozumieć jego działanie nie pozostaje nam więc nic innego, jak wrócić do standardu ECMAScript i przeanalizować algorytm który odpowiada za takie wyniki.

Żeby nie poruszać się w czysto teoretycznych definicjach weźmiemy na tapetę właśnie wspominaną operację, czyli porównanie zera oraz spacjiGotowi?

1. If Type(x) is the same as Type(y), then
      Return the result of performing Strict Equality Comparison x === y.

Zaczynamy podobnie jak w przypadku poprzedniego operatora – od sprawdzenia typów. O ile tam sprawdzaliśmy czy typy są różne (aby na starcie zwrócić false), to tutaj sprawdzamy czy operacji nie można przekierować właśnie do operatora === (w przypadku zgodności typów). W naszym przypadku porównujemy typy Number oraz String, więc pomijamy ten krok.

2. If x is null and y is undefined, return true.
3. If x is undefined and y is null, return true.

Kolejny krok również przypomina fragment poprzedniego algorytmu, jednak jak widać po dwóch powyższych krokach algorytm == traktuje wartości null oraz undefined jako równoważne, w przeciwieństwie do ===.

W naszym przypadku nie mamy ani null ani undefined, idziemy więc dalej.

   4. If Type(x) is Number and Type(y) is String, return the result of the comparison x == ToNumber(y).

Tutaj natrafiamy na pasujący przypadek – zgodnie z nim, aby porównać liczbę i pewien string, liczbę zero musimy porównać do ToNumber(y) za pomocą tego samego operatora – jako y użyjemy znaku spacji. Zobaczmy więc jak wykonać operację ToNumber(” „). Z tabelki która przedstawia różne warianty tej operacji w zależności od typu argumentu dowiadujemy się, że wykonanie ToNumber na stringu wymaga najpierw dopasowania samego stringa do odpowiedniego symbolu „gramatyki języka”.

Aby string mógł być przekonwertowany na liczbę najpierw musimy sprawdzić jakiego typu liczbę on zawiera (musimy określić co jest zawartością stringa – string z liczbą całkowitą, string z liczbą w formacie binarnym, ósemkowym itd.):

Jeśli nasz string nie zawiera liczby w przewidzianym formacie (np. jest to imię, rzeczownik albo przymiotnik) to zostanie zwrócony wynik NaN. Nie ma przewidzianego sposobu aby np. słowo „Przemek” było reprezentowane w jakiś sposób przez liczbę.

W naszym przypadku znak spacji został przewidziany jako możliwy do przekonwertowania – jego symbolem jest WhiteSpace (w którym zawiera się też znak tabulatora, tabulatora pionowego i kilka innych, w tym spacja):

Na liście symboli którą wkleiłem nieco wyżej widać, że WhiteSpace tam występuje, a więc konwersja będzie możliwa.

Teraz proces przebiega następująco:

  1. Jeśli mamy już określony tzw. String numeric literal (u nas to WhiteSpace) to musimy sprawdzić jego reprezentacje matematyczną wg standardu języka – zwaną Mathematical Value (MV)
  2. Jeśli znajdziemy MV, to musimy ją jeszcze zaokrąglić

Nas interesuje poniższy fragment z wyszukiwania MV:

Po zaokrągleniu, wg standardu, otrzymujemy liczbę – to nasza numeryczna reprezentacja znaku spacji.

Teraz nie pozostaje nam nic innego, jak dokończenie naszego sprawdzania:

  1. Skoro do porównania liczby i stringa należy wykonać operację x == ToNumber(y) to w naszym przypadku będzie to 0 == 0
  2. Wracamy do pierwszego kroku sprawdzania operatorem == i sprawdzamy, czy typ argumentów się zgadza – tak, mamy teraz dwie liczby
  3. Używając operatora === sprawdzamy wynik operacji 0 === 0
  4. W algorytmie operatora === znajdujemy fragment „If x is the same Number value as y, return true.”
  5. I w ten sposób dowiadujemy się, dlaczego 0 == ” ” w języku JavaScript to true!

Uff… tutaj dzieje się zdecydowanie więcej ale jak widać – wszystko opisane jest czarno na białym w standardzie ECMAScript i przy odrobinie chęci można dowiedzieć się dlaczego język ten działa tak a nie inaczej.

My skończyliśmy pracę stosunkowo szybko, ale w przypadku argumentów innego typu podążalibyśmy inną ścieżką – wszystko zgodnie z opisem:

Polecam wam teraz wybrać dwa inne argumenty, np. typu boolean i object, i sprawdzić dlaczego operator == zachowuje się tak a nie inaczej.

Operatory zaprzeczenia

Na koniec krótka notka na temat operatorów zaprzeczenia równości, czyli !== oraz !=.

Ich sposób działania jest prosty – najpierw wykonywane są operacje równości zgodne z typem operatora, czyli === dla !== oraz == dla !=, a następnie zwracana jest po prostu wartość przeciwna.

Operację a !== b można też zapisać jako !(a === b) – podobnie z operatorem ==, czyli a != b to !(a == b).

Do zapamiętania

Zdaję sobie sprawę, że dla sporej liczby programistów rozłożenie operatorów na czynniki pierwsze może się wydawać czymś przesadzonym, na co nie warto poświęcać czasu, jednak w przypadku specjalizowania się w języku JavaScript taka wiedza na pewno nie zaszkodzi. Świadomość tego, co dzieje się pod spodem może przydać się w najmniej spodziewanym momencie.

Jeśli chcielibyście natomiast zapamiętać kluczowe wskazówki odnośnie używania operatorów równości w języku JavaScript to są one następujące

  • Istnieją dwa podstawowe operatory równości == oraz ===
  • Operator === zaczyna swoją pracę od sprawdzenia typu argumentów i nie wykonuje ich konwersji, dlatego przy różnych typach argumentów natychmiast zwracana jest wartość false
  • Operator == dla dwóch argumentów różnych typów spróbuje niejawnie przekonwertować typy do pasujących wartości, a następnie przekieruje pracę do operatora ===
  • Operatory zaprzeczenia równości zwracają przeciwną wartość do wyniku operacji równości z odpowiednim operatorem
  • Dokumentacja standardu ECMAScript to twój najlepszy przyjaciel, który opowie ci ze szczegółami dlaczego JavaScript działa tak a nie inaczej

Rada ode mnie – jeśli całkowicie zrezygnujesz z używania operatora == na rzecz === to właściwie możesz tylko zyskać. Wielu programistów uważa takie podejście za programowanie defensywne (lolwut), ale dla mnie to sposób na pozbycie się z kodu niespodzianek wszelkiego typu. Jeśli chciałbyś wymusić używanie operatora === to możesz to zrobić używając narzędzi takich jak np. ESLint z jego regułą eqeqeq.

Na koniec pozostaje mi tylko zachęcenie do komentowania i udostępniania tego posta – czy chcielibyście zobaczyć więcej tego typu treści na PoznajProgramowanie.pl?

Do następnego razu!

  • Artykuł świetnie przygotowany od strony merytorycznej.
    Widać, że dużo pracy zostało włożone w jego przygotowanie.

    Wiele developerów śmieje się z tego pytania czym się różni == od ===,
    ale ja zgadzam się z autorem, że trzeba znać zasadę działania języka,
    jeśli chce się wejść na kolejny poziom wtajemniczenia programistycznego.

    Ciekawi mnie dlatego w specyfikacji jest zapisane, że dla spacji, tabulacji itd
    odpowiednim znakiem jest 0, a nie np. NaN albo -1?

    Od swojej strony dodałbym informację, że istnieje jeden przypadek wykorzystania
    porównania „miękkiego”. Otóż jeśli istnieje potrzeba weryfikacji,
    czy wartość jest null albo undefined. Przykład:

    Drogi autorze, serdecznie dziękuję Ci za poświęcony czas na napisanie takiego artykułu,
    i szczerze zachęcam, abyś częściej pisał takie artykuły.

    • Przemek Smyrdek

      Dzięki Piotrek za komentarz!

      Faktycznie, przykład przytoczony przez ciebie to jeden ze skrótów na który pozwala ==, ale osobiście uważałbym z takimi wyjątkami. Jeśli piszesz projekt samemu i rozumiesz co się dzieje, to pewnie nic się nie stanie, ale w pracy zespołowej często takie wyjątki kończą się kopiowaniem / powielaniem czegoś bez zastanowienia.

      A dlaczego spacja to 0? Być może biorąc ciąg ‚ ‚, ‚1’, ‚2’, ‚3’, itd.. 0 wydaje się naturalnym zastąpieniem spacji. To tylko moje przypuszczenia 😉

    • przykład ciekawy, ale ostrożnie traktowałbym null na równi z undefined. Przykładem mogą być chociażby bazy danych (np.
      MySQL) gdzie null to pełnoprawna wartość pola i co więcej czasami jest to wartość jak najbardziej sensowna (np. załóżmy, że jakieś pole ma przechowywać datę ważności produktu, ale jednocześnie chcemy mieć możliwość utworzenia produktu bez terminu ważności – w tym przypadku właśnie z pomocą przychodzi chociażby null, co w bazie MySQL oznacza „brak wartości” i null można przypisać do pola typu datowego).
      Reasumując chciałem tylko zwrócić uwagę, że null to pełnoprawna „wartość” dla pola bazodanowego, natomiast undefined nie. Czy ma to znaczenie? To zależy czy ograniczamy się w naszym kodzie wyłącznie do samych operacji w JS czy może przekazujemy jakieś dane (np. Ajax) do serwerka celem zapisania do bazy danych.

      • Przemek Smyrdek

        Akurat w tym poście skupiam się wyłącznie na JavaScript – inną kwestią jest to, co dzieje się na styku „klient/serwer” – tutaj wchodzą już kwestie o których ty mówisz, czy np. różne interpretacje undefined w zależności od języka, itd.