Operator + jako cichy nosiciel bugów

W języku JavaScript jest kolejna zaskakująca operacja, która mogłaby nam się wydawać matematyczna, logiczna i spójna - mowa o operatorze +

Oto nasz faworyt!

1
1 + '1' // "11"

JavaScript miał być językiem prostym i szybkim dla ówczesnych projektantów stron. Ale czy to zwolnienie programisty ze statycznego typowania zawsze miało swoje plusy?

Cofnijmy się 28 lat do wczesnych wersji JavaScript-u zaimplementowanego w silniku SpiderMonkey, w wersji JavaScript 1.3, która trafiła do Netscape Navigator 4.06 w 1998 roku.

1
2
js> help()
JavaScript-C 1.3 1998 06 30

Naszym przedmiotem zainteresowania będzie interpreter, który obsługuje to zachowanie.

Całe JSOP_ADD to jakieś 60 linii, ale decyzja “liczba czy string” zapada w pięciu. Reszta to zabezpieczanie wartości przed GC i ręczne sklejanie bufora znaków:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
case JSOP_ADD:
    rval = rtmp = POP();
    lval = ltmp = POP();

    VALUE_TO_PRIMITIVE(cx, lval, JSTYPE_VOID, &lval);   /* lewy  → prymityw */
    cond = JSVAL_IS_STRING(lval);
    VALUE_TO_PRIMITIVE(cx, rval, JSTYPE_VOID, &rval);   /* prawy → prymityw */

    if (cond || JSVAL_IS_STRING(rval)) {
        /* którakolwiek strona jest stringiem → konkatenacja
           (js_ValueToString na drugiej, JS_malloc, js_strncpy) */
        PUSH_OPND(STRING_TO_JSVAL(str3));
    } else {
        /* żadna nie jest → obie lecą na liczby i dodawanie */
        VALUE_TO_NUMBER(cx, ltmp, d);
        VALUE_TO_NUMBER(cx, rtmp, d2);
        d += d2;
        PUSH_NUMBER(cx, d);
    }
    break;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
	  case JSOP_ADD:
	    rval = rtmp = POP();
	    lval = ltmp = POP();
	    VALUE_TO_PRIMITIVE(cx, lval, JSTYPE_VOID, &lval);
	    if ((cond = JSVAL_IS_STRING(lval)) != 0) {
		/*
		 * Keep lval on the stack so it isn't GC'd during either the
		 * next VALUE_TO_PRIMITIVE or the js_ValueToString(cx, rval).
		 */
		sp[0] = lval;
	    }
	    VALUE_TO_PRIMITIVE(cx, rval, JSTYPE_VOID, &rval);
	    if (cond || JSVAL_IS_STRING(rval)) {
		if (cond) {
		    str = JSVAL_TO_STRING(lval);
		    SAVE_SP(fp);
		    ok = (str2 = js_ValueToString(cx, rval)) != NULL;
		} else {
		    /*
		     * Keep rval on the stack so it isn't GC'd during the next
		     * js_ValueToString.
		     */
		    sp[1] = rval;
		    str2 = JSVAL_TO_STRING(rval);
		    SAVE_SP(fp);
		    ok = (str = js_ValueToString(cx, lval)) != NULL;
		}
		if (!ok)
		    goto out;
		if ((length = str->length) == 0) {
		    str3 = str2;
		} else if ((length2 = str2->length) == 0) {
		    str3 = str;
		} else {
		    length3 = length + length2;
		    chars = JS_malloc(cx, (length3 + 1) * sizeof(jschar));
		    if (!chars) {
			ok = JS_FALSE;
			goto out;
		    }
		    js_strncpy(chars, str->chars, length);
		    js_strncpy(chars + length, str2->chars, length2);
		    chars[length3] = 0;
		    str3 = js_NewString(cx, chars, length3, 0);
		    if (!str3) {
			JS_free(cx, chars);
			ok = JS_FALSE;
			goto out;
		    }
		}
		PUSH_OPND(STRING_TO_JSVAL(str3));
	    } else {
		VALUE_TO_NUMBER(cx, ltmp, d);
		VALUE_TO_NUMBER(cx, rtmp, d2);
		d += d2;
		PUSH_NUMBER(cx, d);
	    }
	    break;

#define BINARY_OP(OP) {                                                       \
    POP_NUMBER(cx, d2);                                                       \
    POP_NUMBER(cx, d);                                                        \
    d = d OP d2;                                                              \
    PUSH_NUMBER(cx, d);                                                       \
}

	  case JSOP_SUB:
	    BINARY_OP(-);
	    break;

	  case JSOP_MUL:
	    BINARY_OP(*);
	    break;

Sama logika jest łatwa do odczytania, przekładając ją na nasze dane wejściowe, jak również analizując instrukcje warunkowe.

1
2
lval  = 1
rval  = '1'

Jeśli lval lub rval jest stringiem, to całe wyrażenie jest traktowane jako string, w przeciwnym wypadku jest konwertowane do liczby, czyli:

1
2
1 + '1' // "11"
1 + 1 // 2

Patrząc na ten kod sprzed 28 lat można zadać sobie pytanie, czy to był bug jak w przypadku typeof null === "object", czy ficzer?

Już samo zerknięcie na ten kod wystarczy - ktoś, kto sprawdza lval i rval, a następnie podejmuje decyzję, czy są liczbą, czy stringiem, nie robi tego przypadkowo.

Żeby było zabawniej, wykonajmy inne operacje matematyczne

1
2
3
1 * '1' // 1
1 - '1' // 0
1 / '1' // 1

A dlaczego -, * i / zachowują się normalnie?

Bo w tym pliku, kilkadziesiąt linii niżej, odejmowanie i mnożenie są generowane jednym makrem, bez żadnej logiki konkatenacji:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#define BINARY_OP(OP) {            \
    POP_NUMBER(cx, d2);            \
    POP_NUMBER(cx, d);             \
    d = d OP d2;                   \
    PUSH_NUMBER(cx, d);            \
}

	  case JSOP_SUB:
	    BINARY_OP(-);
	    break;

	  case JSOP_MUL:
	    BINARY_OP(*);
	    break;

+ jako operacja arytmetyczna i konkatenacja stringów

Wiemy już, że operator + spełnia dwa zadania - operacje arytmetyczne i konkatenację stringów. Przeanalizujmy zatem, jak to robi ówczesny sąsiad z tej samej strony, czyli Java ;-)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import java.util.Scanner;

public class Bug {
    public static void main(String[] args) {
        String input = new Scanner(System.in).nextLine();

        System.out.println(input + 5);

        System.out.println(Integer.parseInt(input) + 5);
    }
}
1
echo 10 | java Bug.java
1
2
105
15

Ten sam błąd! - czy to oznacza, że statycznie typowany język nie jest w stanie uchronić się przed tym błędem?

Zmodyfikujmy nasz program:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import java.util.Scanner;

public class Bug {
    public static void main(String[] args) {
        String input = new Scanner(System.in).nextLine();

        System.out.println(input + 5);

        int total = input + 5;

        System.out.println(Integer.parseInt(input) + 5);
    }
}
1
echo 10 | java Bug.java
1
2
3
4
5
Bug.java:9: error: incompatible types: String cannot be converted to int
        int total = input + 5;
                          ^
1 error
error: compilation failed

I to jest cała różnica! - pomysł skopiowany zacny, ale… w Javie bug jest reprodukowalny jedynie wtedy, kiedy wynik nie ląduje w kontekście, który wymaga liczby.

Czyli praktycznie tylko wtedy, gdy go od razu wypisujesz albo doklejasz do innego stringa.

Przestaje przechodzić w momencie, w którym cokolwiek z tą wartością chcesz zrobić. Przypisać do int, przekazać do metody, zapisać w polu encji itd.

Statyczne typowanie nie eliminuje pomyłki. Ogranicza jej zasięg do jednej linii i zamienia cichą złą wartość w głośną odmowę uruchomienia.

Dlaczego tego nie naprawiono

Można powiedzieć, że to był świadomy kompromis, a nie przeoczenie. Kod, który oglądaliśmy, jest z 1998, ale sama reguła jest o trzy lata starsza. W 1995 nie było dev toolsów ani debuggerów. Autor strony nie miał jak szybko poprawić takiego błędu, a strona i tak się renderowała i dało się kliknąć.

Przy dwudziestu linijkach podmieniających obrazek na hover to był dobry deal. Przy dwustu tysiącach linii obsługujących płatności jest fatalny.

Ceną jest konkretna kombinacja trzech decyzji naraz: ten sam operator obsługuje konkatenację i dodawanie, rozstrzygnięcie zapada dopiero w runtime, a kierunek konwersji zależy od operatora - + ciągnie w stronę stringa, - w stronę liczby.

TypeScript - obiecuje, że będzie number!

TypeScript - można by powiedzieć taki strażnik JavaScript-u, ale czy rozwiązuje problem?

1
const raw = JSON.parse('{"price":"10"}') as { price: number };

Składamy piękną obietnicę - niestety jedynie na etapie kompilacji. A co przyjdzie w runtime? Tego nie wiemy, bo JSON.parse zwraca typ any, więc TypeScript przestaje cokolwiek wiedzieć o tej wartości oprócz naszej deklaracji.

1
const sum = raw.price + 5 //"105"

To już zależy, co zostanie wysłane ;-)

Niewątpliwie jest to i tak duża pomoc, bo zapewnia spójność w obrębie Twojego kodu - nikt nie przekaże tam stringa przez przypadek. Ale as nie generuje żadnego kodu wynikowego: po kompilacji zostaje czyste JSON.parse. To nie jest sprawdzenie, tylko obietnica złożona kompilatorowi, a w runtime nikt jej nie weryfikuje.

Walidacja na styku

Jak się przed tym zabezpieczać?

Walidować na styku aplikacji, aby mieć pewność, czego się spodziewamy, a co dostajemy.

W poniższym przykładzie użyjemy zod.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { z } from "zod";

const Product = z.object({ price: z.number() });

const json = '{"price":"10"}';
const data = JSON.parse(json);

console.log(data.price + 5);

Product.parse(data);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
105
Product.parse(data);
        ^

ZodError: [
  {
    "expected": "number",
    "code": "invalid_type",
    "path": [
      "price"
    ],
    "message": "Invalid input: expected number, received string"
  }
]

Czego nie wykrył kompilator, zod waliduje w runtime :)

Podsumowanie

Reguła + jest z nami od przeszło 30 lat i jest nietykalna ze względu na kompatybilność wsteczną. To, co możemy zrobić, to ograniczyć jej zasięg, używając narzędzi takich jak:

  • @typescript-eslint/restrict-plus-operands - reguła ESLinta, która łapie mieszany +
  • TypeScript - ale tylko wewnątrz własnego kodu, nie na granicy systemu
  • walidacja ze schematem (zod, valibot, arktype)
  • Number(x) i Number.isFinite

A jak silne jest to dziedzictwo, widać po BigInt, wprowadzonym do języka w 2020 roku.

1
2
1n + 1     // TypeError: Cannot mix BigInt and other types
1n + "1"   // "11"

Pierwsza linia rzuca błędem, bo tej kombinacji nikt wcześniej nie napisał - nie ma czego zepsuć. Druga nadal skleja, bo reguła konkatenacji jest o ćwierć wieku starsza.

Nowe typy dostają błąd zamiast cichej konwersji. Ale przy zderzeniu z regułą z początku istnienia JavaScript-u stara reguła wygrywa bez dyskusji!