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:
typeof 100 //number
typeof "string" //string
typeof {} //object
typeof Symbol //function
typeof undefinedVariable // undefined
I oto nasz faworyt, który jest dzisiejszym królem tego artykułu:
typeof null // object
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:
const flagTrue = true;
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:
js> help()
JavaScript-C 1.3 1998 06 30
A sam kod implementujący typeof
tak:
JS_TypeOfValue(JSContext *cx, jsval v)
{
JSType type;
JSObject *obj;
JSObjectOps *ops;
JSClass *clasp;
CHECK_REQUEST(cx);
if (JSVAL_IS_VOID(v)) {
type = JSTYPE_VOID;
} else if (JSVAL_IS_OBJECT(v)) {
obj = JSVAL_TO_OBJECT(v);
if (obj &&
(ops = obj->map->ops,
ops == &js_ObjectOps
? (clasp = OBJ_GET_CLASS(cx, obj),
clasp->call || clasp == &js_FunctionClass)
: ops->call != 0)) {
type = JSTYPE_FUNCTION;
} else {
type = JSTYPE_OBJECT;
}
} else if (JSVAL_IS_NUMBER(v)) {
type = JSTYPE_NUMBER;
} else if (JSVAL_IS_STRING(v)) {
type = JSTYPE_STRING;
} else if (JSVAL_IS_BOOLEAN(v)) {
type = JSTYPE_BOOLEAN;
}
return type;
}
Makra definiujące typy danych w Netscape 1.3 wyglądały następująco:
#define JSVAL_OBJECT 0x0 /* untagged reference to object */
#define JSVAL_INT 0x1 /* tagged 31-bit integer value */
#define JSVAL_DOUBLE 0x2 /* tagged reference to double */
#define JSVAL_STRING 0x4 /* tagged reference to string */
#define JSVAL_BOOLEAN 0x6 /* tagged boolean value */
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):
#include <stdlib.h>
#include <stdio.h>
typedef unsigned long pruword;
typedef long prword;
typedef prword jsval;
#define PR_BIT(n) ((pruword)1 << (n))
#define PR_BITMASK(n) (PR_BIT(n) - 1)
#define JSVAL_OBJECT 0x0 /* untagged reference to object */
#define OBJECT_TO_JSVAL(obj) ((jsval)(obj))
#define JSVAL_NULL OBJECT_TO_JSVAL(0)
#define JSVAL_TAGMASK PR_BITMASK(JSVAL_TAGBITS)
#define JSVAL_TAG(v) ((v) & JSVAL_TAGMASK)
#define JSVAL_IS_OBJECT(v) (JSVAL_TAG(v) == JSVAL_OBJECT)
#define JSVAL_TAGBITS 3
struct JSObject {
struct JSObjectMap *map;
};
struct JSObjectMap {
};
// Funkcja pomocnicza do wyświetlania binarnej reprezentacji
void print_binary(unsigned long n) {
for (int i = 31; i >= 0; i--) {
printf("%d", (n >> i) & 1);
}
printf("\n");
}
int main() {
struct JSObject* obj = malloc(sizeof(struct JSObject));
jsval objectValue = OBJECT_TO_JSVAL(obj);
jsval null = JSVAL_NULL;
printf("Is object %d\n", JSVAL_IS_OBJECT(objectValue));
printf("Is null an object %d\n", JSVAL_IS_OBJECT(null));
printf("Binary representation of object: ");
print_binary(objectValue);
printf("Binary representation of null: ");
print_binary(null);
}
Wynik tego programu to:
Is object 1
Is null an object 1
Binary representation of object: 01011000000010100011000111100000
Binary representation of null: 00000000000000000000000000000000
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
!
#define JSVAL_IS_NULL(v) ((v) == JSVAL_NULL)
A więc funkcja typeof
powinna wyglądać tak:
JS_TypeOfValue(JSContext *cx, jsval v)
{
JSType type;
JSObject *obj;
JSObjectOps *ops;
JSClass *clasp;
CHECK_REQUEST(cx);
if (JSVAL_IS_NULL(v)) { //sprawdzamy czy value jest nullem!
type = JSTYPE_NULL;
} else if (JSVAL_IS_VOID(v)) {
type = JSTYPE_VOID;
} else if (JSVAL_IS_OBJECT(v)) {
obj = JSVAL_TO_OBJECT(v);
if (obj &&
(ops = obj->map->ops,
ops == &js_ObjectOps
? (clasp = OBJ_GET_CLASS(cx, obj),
clasp->call || clasp == &js_FunctionClass)
: ops->call != 0)) {
type = JSTYPE_FUNCTION;
} else {
type = JSTYPE_OBJECT;
}
} else if (JSVAL_IS_NUMBER(v)) {
type = JSTYPE_NUMBER;
} else if (JSVAL_IS_STRING(v)) {
type = JSTYPE_STRING;
} else if (JSVAL_IS_BOOLEAN(v)) {
type = JSTYPE_BOOLEAN;
}
return type;
}
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:
if (value !== null && typeof value === 'object') {
//to jest prawdziwy obiekt!
}