Powrót do strony głównej

JS/TS nie jest językiem funkcyjnym: analiza techniczna

Artykuł wyjaśnia, dlaczego JavaScript i TypeScript nie są językami programowania funkcyjnymi. Omówiono cztery kluczowe techniczne ograniczenia: mutowalność domyślna, brak optymalizacji rekurencji ogonowej, brak leniwych kolekcji i dekompozycji strukturalnej, a także obsługa błędów poprzez wyjątki. Podano przykłady i konsekwencje dla rozwoju.

JS/TS nie jest językiem funkcyjnym: jak techniczne ograniczenia wpływają na Twój kod
Advertisement 728x90

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.

Google AdInline article slot

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.

Google AdInline article slot

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.caller polegają 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:

Google AdInline article slot
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 const nie 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

Advertisement 728x90

Czytaj dalej