JS/TS nie jest językiem funkcyjnym: techniczne ograniczenia i ich konsekwencje
Wielu programistów błędnie uważa JavaScript i TypeScript za języki funkcyjne ze względu na obecność metod takich jak .map() i .filter(). Jednak te języki nie wspierają kluczowych zasad programowania funkcyjnego, co prowadzi do ukrytych problemów w kodzie produkcyjnym. Przeanalizujemy cztery techniczne powody, dla których JS/TS nie jest językiem funkcyjnym, oraz jak to wpływa na rozwój oprogramowania.
Domyślna mutowalność: iluzja niemutowalności
W językach funkcyjnych, takich jak Haskell czy Clojure, niemutowalność jest wbudowana na poziomie języka. Na przykład w Clojure wszystkie podstawowe struktury danych są niemutowalne od razu. W JavaScript mutowalność to standardowe zachowanie. Nawet deklaracja za pomocą const nie gwarantuje niemutowalności danych:
const user = { name: "Alice" };
user.name = "Bob"; // Rabotaet bez oshibok
Tutaj const chroni tylko przed ponownym przypisaniem zmiennej, ale nie przed zmianą zawartości obiektu. To narusza przezroczystość referencyjną — kluczową cechę programowania funkcyjnego, która pozwala zastępować wyrażenie jego wartością bez zmiany zachowania programu.
Próby emulowania niemutowalności za pomocą Object.freeze() lub bibliotek typu Immutable.js to jedynie protezy. Nie są zintegrowane z językiem i wymagają jawnego wdrożenia przez zespół, co zmniejsza ich adopcję. Powód takiego projektu ma podłoże historyczne: JavaScript powstał w 10 dni w 1995 roku jako prosty język skryptowy dla przeglądarki, gdzie mutowalność ułatwiała implementację i odpowiadała nawykom programistów z C/Java. Przeprojektowanie modelu pamięci bez zniszczenia całego ekosystemu webowego jest dziś niemożliwe.
Brak optymalizacji rekurencji ogonowej: cena dynamiczności
Optymalizacja rekurencji ogonowej (TCO) wprowadzona w standardzie ES2015 nie doczekała się wsparcia w głównych silnikach, poza Safari. W Chrome i Firefox próba głębokiej rekurencji doprowadzi do przepełnienia stosu:
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc);
}
factorial(100000); // RangeError: Maximum call stack size exceeded
W Scala ta sama funkcja z adnotacją @tailrec jest gwarantowanie optymalizowana przez kompilator do pętli iteracyjnej, unikając przepełnienia stosu. Kompilator sprawdza możliwość optymalizacji na etapie kompilacji i zgłasza błąd, jeśli wywołanie ogonowe nie jest możliwe.
Dlaczego V8 nie wspiera TCO? Główne powody:
- Utrata stosów wywołań. TCO zastępuje bieżącą ramkę stosu, co utrudnia debugowanie. W Scala kompilator przekształca rekurencję w pętlę, zachowując stos wywołań.
- Kompatybilność ze starszymi API. Metody takie jak
Function.callerpolegają na historii wywołań, którą TCO usuwa. - Złożoność w JIT-kompilacji. Wielowarstwowa architektura V8 (Ignition → TurboFan) wymagałaby przeprojektowania generowania i unieważniania ramek.
Inżynierowie V8 otwarcie stwierdzają, że koszt wdrożenia TCO przewyższa korzyści dla ekosystemu. Ta decyzja odzwierciedla priorytety JS: wsparcie dla dynamicznych możliwości i kompatybilność wsteczna są ważniejsze niż ścisłe gwarancje programowania funkcyjnego.
Leniwe kolekcje i współdzielenie strukturalne: nieoptymalność dla dużych danych
W JavaScript operacje na tablicach (filter, map) tworzą pośrednie kopie, co prowadzi do szczytowego zużycia pamięci:
const result = hugeArray
.filter(x => x > 0) // [1] sozdaetsya array A
.map(x => x * 2) // [2] sozdaetsya array B
.filter(x => x < 100) // [3] sozdaetsya array C
.slice(0, 10); // [4] sozdaetsya result
W pamięci jednocześnie mogą znajdować się hugeArray, A, B, C, nawet jeśli wynik końcowy jest mały. Collector śmieci działa asynchronicznie, więc szczytowe obciążenie jest realne.
W Scala leniwe widoki (.view) przekształcają łańcuch w strumień element po elemencie bez pośrednich kolekcji. Co więcej, trwałe struktury (jak Vector) wykorzystują współdzielenie strukturalne — przy „zmianie” kopiowana jest tylko ścieżka od korzenia do liścia (O(log n)), reszta jest ponownie wykorzystywana.
JS nie wspiera takich struktur natywnie. Generatory i nowe Iterator Helpers (ES2025) pozwalają tworzyć leniwe łańcuchy, ale wymaga to kodu imperatywnego i nie jest zintegrowane ze standardowym API. Powód — optymalizacja pod mainstreamowe scenariusze:
- Lokalność danych. Ciągłe tablice dobrze współpracują z pamięcią podręczną CPU, podczas gdy leniwe łańcuchy generują drobne wywołania.
- Optymalizacje JIT. V8 agresywnie inline'uje metody
Array.prototype, czego nie da się zrobić dla operacji leniwych. - Wydajność UI. Do renderowania interfejsów kopiowanie tablic może być szybsze niż dereferencjonowanie wskaźników w drzewach.
Obsługa błędów: wyjątki kontra typizowane wyniki
W JavaScript błędy to wyjątki, ukryte przed systemem typów. Na przykład JSON.parse ma sygnaturę (string) => any, ale może rzucić wyjątek:
function getUser(id: string): User {
// Maybe brosit isklyuchenie — kompilyator not predupredit
}
Wyjątki naruszają przezroczystość referencyjną i zmieniają funkcję z całkowitej (total) na częściową (partial). W językach funkcyjnych błędy są kodowane w typie zwracanej wartości:
val result: Try[Json] = Try(parse(userInput))
Tutaj Try jawnie wskazuje, że operacja może się nie powieść, a kompilator wymaga obsługi obu przypadków. W JS try/catch to instrukcja, a nie wyrażenie, co przerywa przepływ danych i komplikuje kompozycję.
Biblioteki takie jak fp-ts oferują obejścia, ale pozostają rozwiązaniami zewnętrznymi, nie wbudowanymi w język. To tworzy „martwe punkty” w systemie typów, gdzie błędy pojawiają się niespodziewanie.
Co ważne: kluczowe wnioski dla programistów
- JS/TS nie gwarantuje niemutowalności. Nawet
constnie chroni przed mutacją obiektów. Do prawdziwej niemutowalności potrzebne są protezy (Immutable.js), co zwiększa złożoność kodu. - Rekurencja jest niebezpieczna w JS. Brak TCO czyni algorytmy rekurencyjne niepraktycznymi dla dużych danych. Zawsze sprawdzaj głębokość rekursii.
- Operacje leniwe nie są natywne. Do przetwarzania dużych tablic bez szczytowego zużycia pamięci trzeba pisać kod imperatywny lub używać bibliotek zewnętrznych.
- Błędy to ukryte ryzyko. Sygnatury funkcji nie odzwierciedlają możliwości wyjątków. Zawsze opakowuj niepewne operacje w
try/catch, nawet jeśli TS tego nie wymaga.
— Editorial Team
Brak komentarzy.