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ą:
| Poziom | String | Object |
|---|---|---|
| Semantyka | Porównanie przez wartość | Porównanie przez referencję |
| Implementacja | Optymalizacja: 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
| Typ | Porównanie === | Dlaczego? |
|---|---|---|
| Prymitywy | Przez wartość | Są immutable, mogą być internalizowane |
| Obiekty | Przez referencję | Są mutable, każda definicja = nowa alokacja |
Dlatego {} !== {} - to dwa różne obiekty w pamięci, nawet jeśli wyglądają identycznie! :)
