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!
| |
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.
| |
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:
| |
| |
Sama logika jest łatwa do odczytania, przekładając ją na nasze dane wejściowe, jak również analizując instrukcje warunkowe.
| |
Jeśli lval lub rval jest stringiem, to całe wyrażenie jest traktowane jako string, w przeciwnym wypadku jest konwertowane do liczby, czyli:
| |
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
| |
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:
| |
+ 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 ;-)
| |
| |
| |
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:
| |
| |
| |
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?
| |
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.
| |
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.
| |
| |
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)iNumber.isFinite
A jak silne jest to dziedzictwo, widać po BigInt, wprowadzonym do języka w 2020 roku.
| |
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!



