Obiekt obiektowi nierówny

W tym poście zajmiemy się czymś, co jest często spotykane w językach programowania - operatorami porównania.

W JavaScript, jak i w innych językach programowania, mamy różne typy danych, które możemy do siebie przyrównywać:

"test" === "test"  // true
true === true      // true
1 === 1            // true

Ale nie wszystko jest takie oczywiste, co podpowiadałaby nam logika:

{} === {}  // false 🤔
[] === []  // false 🤔

Dlaczego tak się dzieje?


Typy w JavaScript

JavaScript jest językiem dynamicznie typowanym, w związku z tym typy są “otagowywane” w pamięci - no bo skądś musi być wiadomo co to jest. To niejako zwalnia programistę JavaScript z myślenia, jaki typ aktualnie jest alokowany.

Wyróżniane są dwa główne typy:

  • Prymitywny - niemutowalny, porównywany przez wartość
  • Referencyjny - mutowalny, porównywany przez referencję
| Typ                | Immutable? | Przechowywanie    | Porównanie        | Przykład                    |
|--------------------|------------|-------------------|-------------------|-----------------------------|
| Number (Int32)     | ✅ TAK     | Stack (inline)    | Przez wartość     | 42 === 42 → true            |
| Number (Float)     | ✅ TAK     | Heap (HeapNumber) | Przez wartość     | 3.14 → niemożliwa mutacja   |
| String             | ✅ TAK     | Heap (JSString)   | Przez wartość*    | "hello" === "hello" → true  |
| Boolean            | ✅ TAK     | Stack (inline)    | Przez wartość     | true === true → true        |
| undefined          | ✅ TAK     | Stack (inline)    | Przez wartość     | undefined === undefined     |
| null               | ✅ TAK     | Stack (inline)    | Przez wartość     | null === null → true        |
| Symbol             | ✅ TAK     | Heap (Symbol)     | Przez referencję  | Symbol() !== Symbol()       |
| BigInt             | ✅ TAK     | Heap              | Przez wartość*    | 10n === 10n → true          |
|--------------------|------------|-------------------|-------------------|-----------------------------|
| Object             | ❌ NIE     | Heap (JSObject)   | Przez referencję  | {} !== {}                   |
| Array              | ❌ NIE     | Heap (JSObject)   | Przez referencję  | [] !== []                   |
| Function           | ❌ NIE     | Heap (JSFunction) | Przez referencję  | (() => {}) !== (() => {})   |
| Date               | ❌ NIE     | Heap (JSObject)   | Przez referencję  | new Date() !== new Date()   |
| RegExp             | ❌ NIE     | Heap (JSObject)   | Przez referencję  | /a/ !== /a/                 |
| Map/Set            | ❌ NIE     | Heap (JSObject)   | Przez referencję  | new Map() !== new Map()     |

Wniosek: Typy prymitywne to typy, których nie można zmienić (immutable).


Ale chwila… co z toUpperCase()?

Ktoś dociekliwy może zadać pytanie - skoro typy prymitywne są niezmienne, to jak to się ma do:

"test".toUpperCase()  // "TEST"

Możemy łatwo to zbadać używając makra %DebugPrint dostępnego w Node.js z flagą --allow-natives-syntax:

const str = "test";

%DebugPrint(str);
// DebugPrint: 0x36c70031c8c5: [String] in ReadOnlySpace: #test
// type: INTERNALIZED_ONE_BYTE_STRING_TYPE

%DebugPrint(str.toUpperCase());
// DebugPrint: 0x36c70084d251: [String]: "TEST"
// type: SEQ_ONE_BYTE_STRING_TYPE

Zauważ różnicę w adresach:

  • "test"0x36c70031c8c5
  • "TEST"0x36c70084d251

Wniosek: Wartość została skopiowana do nowego miejsca w pamięci - zasada immutability została zachowana!


Semantyka vs Implementacja

Skoro string "test" === "test" zwraca true, to jak to działa pod spodem?

Spójrzmy na implementację porównania stringów w silniku SpiderMonkey (Firefox):

bool js::EqualStrings(JSContext* cx, JSString* str1, JSString* str2,
                      bool* result) {
  if (str1 == str2) {
    *result = true;
    return true;
  }
  // ... dalsza część funkcji porównuje zawartość gdy wskaźniki są różne
  return EqualStringsPure(str1, str2, result);
}

Powyższy kod pokazuje optymalizację (fast path):

// Przypadek 1: Porównanie wskaźników - O(1)
if (str1 == str2) {  // jedna instrukcja CPU!
    return true;
}

// Przypadek 2: Porównanie zawartości - O(n)
for (size_t i = 0; i < length; i++) {
    if (str1->chars[i] != str2->chars[i]) {
        return false;
    }
}

To pokazuje różnicę między semantyką języka a implementacją:

PoziomStringObject
SemantykaPorównanie przez wartośćPorównanie przez referencję
ImplementacjaOptymalizacja: najpierw wskaźniki, potem zawartośćTylko wskaźniki

String Interning (Atom Table)

Dlaczego "test" === "test" działa szybko?

Silniki JS stosują internalizację (interning) - literały stringowe są przechowywane w specjalnej tablicy (Atom Table w SpiderMonkey). Dzięki temu identyczne literały wskazują na ten sam obiekt w pamięci:

Stack:                          Heap (Atom Table):
┌──────────────────────┐        ┌──────────────────────┐
│ s1 → 0x26a432b2c140  │ ──────→│ 0x26a432b2c140:      │
│ s2 → 0x26a432b2c140  │ ──────→│ JSString "test"      │
└──────────────────────┘        │   length: 4          │
                                │   chars: "test"      │
                                └──────────────────────┘

Uwaga: Nie wszystkie stringi są internalizowane! Dynamicznie tworzone stringi (np. "te" + "st") mogą mieć różne adresy - wtedy silnik musi porównać zawartość O(n).


Wracając do obiektów…

Obiekty i tablice to skomplikowane struktury, które są mutable - w każdym momencie można je zmodyfikować.

Przy każdej definicji (zdawałoby się pustego) obiektu:

{} === {}  // false - ZAWSZE!

silnik zawsze przydziela mu nowe miejsce w pamięci:

{}  // alokacja pod adresem 0x7ffff66f7090
{}  // alokacja pod adresem 0x7ffff66f7098 (inny adres!)

Kluczowy wniosek: Porównując obiekty/tablice, nie porównujemy ich wartości - tylko adres w pamięci, który nawet dla identycznie wyglądających obiektów będzie różny!


Co mówi standard ECMA-262?

Standard ECMA-262 definiuje to zachowanie w operacji IsStrictlyEqual (używanej przez operator ===):

7.2.16 IsStrictlyEqual ( x, y )

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).

Gdzie SameValueNonNumber dla obiektów sprowadza się do:

7.2.11 SameValueNonNumber ( x, y )

...
6. NOTE: All other ECMAScript language values are compared by identity.
7. If x is y, return true; otherwise return false.

Czyli dla obiektów sprawdzane jest, czy x i y to ten sam obiekt (ta sama referencja w pamięci), a nie czy mają taką samą zawartość.


Podsumowanie

TypPorównanie ===Dlaczego?
PrymitywyPrzez wartośćSą immutable, mogą być internalizowane
ObiektyPrzez referencjęSą mutable, każda definicja = nowa alokacja

Dlatego {} !== {} - to dwa różne obiekty w pamięci, nawet jeśli wyglądają identycznie! :)