Obiekt, który nie jest obiektem
Język JavaScript ma swoje urokliwe zachowania, a jednym z nich jest operator typeof, który określa nam typ wartości.
Modelowe zachowanie:
| |
I oto nasz faworyt, który jest dzisiejszym królem tego artykułu:
| |
JavaScript, jak i inne języki programowania, posiada typy, które możemy podzielić na “prymitywne” - czyli te, które zwrócą jedną wartość (null, undefined, boolean, symbol, bigint, string) oraz typy “obiektowe”, które mają złożoną strukturę.
Najprościej sprawę ujmując - przykładowo boolean w JavaScript jest czymś, co nie jest strukturą bardzo skomplikowaną, bo zwraca tylko jedną wartość: true albo false.
Przykładowo we współczesnej implementacji Firefox używana jest technika zwana “pointer tagging”, gdzie 64-bitowa wartość enkoduje typ i wartość lub adres na stercie (heap). Przyjrzyjmy się, jak są przykładowo obsługiwane booleany w tej implementacji:
| |
| Keyword | Tag | Payload |
|---|---|---|
false | JSVAL_TAG_BOOLEAN (0xFFFE*) | 0x000000000000 |
true | JSVAL_TAG_BOOLEAN (0xFFFE*) | 0x000000000001 |
Można zauważyć, że wysokie bity odpowiedzialne są za definicję typu danych, a niskie za payload lub adres zaalokowanego obiektu na stercie (heap).
Czyli w tym przypadku nasz true/false binarnie jest reprezentowany jako 1/0.
Pewnie się zastanawiasz, co to ma wspólnego z tym, że typeof null zwraca object zamiast null.
Aby to zrozumieć, musimy cofnąć się o 30 lat wstecz do oryginalnej implementacji JavaScript w Netscape, która używała 32-bitowego schematu tagowania - zupełnie innego niż współczesne silniki.
Brendan Eich, który został zatrudniony w firmie Netscape, która w tamtym czasie była większościowym uczestnikiem rynku przeglądarkowego, z racji sporych wymagań rynkowych i depczącej po piętach konkurencji, takiej jak Microsoft czy Sun Microsystems, dostał zadanie stworzenia prototypu języka programowania, który miał spełniać kluczowe kryteria:
- być łatwym dla szerokiej grupy osób (bez statycznego typowania, instalowania kompilatorów)
- pozwalać użytkownikowi w podstawowym zakresie manipulować DOM-em
Tak więc po 10 dniach powstał język programowania, który nosił nazwy: “Mocha”, “LiveScript”, a w końcu JavaScript ze względu na presję marketingową, aby trochę wykorzystać popularność w tamtym czasie Javy.
Po 10 dniach powstał prototyp języka programowania, który pomimo faktu późniejszego upadku przeglądarki Netscape ze względu na konkurencję ze strony Microsoftu i domyślnej instalacji Internet Explorera na Windowsie - przetrwał do dzisiaj i się rozwinął.
Przeglądarka Netscape była napisana w języku C, jak i sama implementacja JavaScript.
Tak więc przejdźmy do implementacji typeof w wersji przeglądarki Netscape Navigator 1.3, która witała ówczesnych programistów poleceniem help takim oto komunikatem:
| |
A sam kod implementujący typeof tak:
| |
Makra definiujące typy danych w Netscape 1.3 wyglądały następująco:
| |
Co też przekładało się na taką reprezentację w pamięci (system 32-bitowy):
| Typ | Tag (Niskie 3 bity) | Pamięć (32 bity) | Wartość |
|---|---|---|---|
| Object | 000 (0x0) | [29-bit pointer][000] | 0x12345000 |
| Integer | 001 (0x1) | [29-bit int value][001] | 0x00006401 (42) |
| Double | 010 (0x2) | [29-bit pointer][010] | 0xABCDE002 → heap |
| String | 100 (0x4) | [29-bit pointer][100] | 0x78901004 → “hello” |
| Boolean | 110 (0x6) | [29-bit value][110] | 0x00000006 (true) |
Bazując na tych informacjach, możemy stworzyć uproszczony program przenosząc kilka makr z Netscape, aby zbadać ten problem (kod jest uproszczeniem dla celów edukacyjnych):
| |
Wynik tego programu to:
| |
Jak widać, null i object zwracają to samo dla badanej wartości w makrze JSVAL_IS_OBJECT.
Pewnie się zastanawiasz, dlaczego więc null i object są nierozróżnialne dla tego sprawdzenia.
Wyjaśnieniem tego jest powyższy model tagowania i bazowania na pamięci jako identyfikatorze typów obiektów w JavaScript. Skoro JavaScript jest językiem dynamicznie typowanym, w związku z tym deklaracje typów gdzieś musiały rezydować, więc w tym przypadku twórca podjął decyzję, aby wygospodarować 3 niskie bity na identyfikację typu.
Ustawienie 000 jako identyfikatora obiektu wynika z mechanizmu działania architektury 32-bitowej i wymagań sprzętowych związanych z wyrównaniem pamięci (memory alignment). Obiekty czy tablice to struktury, które są bardziej skomplikowane niż typy prymitywne, w związku z tym są alokowane na stercie.
W architekturze 32-bitowej CPU ładuje dane w porcjach 32-bitowych (4 bajty), a system zarządzania pamięcią wymusza wyrównanie adresów obiektów do granic 4-bajtowych. To oznacza, że każdy adres wskaźnika do obiektu jest podzielny przez 4, co w reprezentacji binarnej skutkuje tym, że adresy obiektów zawsze kończą się dwoma zerami w zapisie bitowym (ponieważ 4 = 100 w systemie binarnym). W praktyce jednak użyto trzech najniższych bitów jako tagi, więc adresy miały wyrównanie do 8 bajtów, co gwarantowało trzy końcowe zera.
W przypadku reprezentacji null możemy zobaczyć, że jest to wartość 0 (same zera), która odnosi się do null pointera w języku C, który w większości architektur jest definiowany jako ((void*)0), czyli nieistniejące miejsce w pamięci. Ponieważ null jest reprezentowany jako 0x00000000, a trzy najniższe bity to 000, makro JSVAL_IS_OBJECT uznaje null za obiekt!
Czy była możliwość tego naprawienia? - oczywiście!
Jak możemy zauważyć, reprezentacja null to po prostu 0, czyli nieistniejące miejsce w pamięci, a obiekt to coś, co istnieje i o zgrozo losu makro, które prawidłowo sprawdzało null, było w kodzie, ale nie zostało użyte w funkcji typeof!
| |
A więc funkcja typeof powinna wyglądać tak:
| |
Przykładową implementację z wyciętym kodem, który możesz sobie skompilować, znajdziesz tutaj:
https://gist.github.com/piotrzarycki/a3713de4e63fd275216900a74c8521e2
Skoro błąd był tak trywialny do naprawy, dlaczego nie zdecydowano się go naprawić?
Otóż miliony stron już zaczęły używać JavaScript z tym oto błędem, miały jego świadomość i obsługiwały go w ten sposób.
Co więcej, w 2013 roku była oficjalna propozycja naprawienia tego zachowania w standardzie ECMAScript, ale została odrzucona właśnie ze względu na kompatybilność wsteczną - zbyt wiele istniejącego kodu mogłoby przestać działać.
Dlatego też pomimo upływu 30 lat to zachowanie przypomina nam o kontekście powstawania JavaScript i historycznych decyzjach projektowych. Aby sprawdzić realnie, czy wartość jest obiektem, a nie nullem, musimy to obsługiwać np. w taki sposób:
| |



