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!
}