Skip to content

Programowanie funkcyjne w JavaScript

W ostatnich czasach z każdej strony słyszymy o programowaniu funkcyjnym, sam osobiście starałem się pisać jakąś część swojegu kodu w stylu funkcyjnym. Jednak starać się można ale nie zawsze wychodzi, także postanowiłem zakasać rękawy i dowiedzieć się nieco więcej.

I should learn functional programming

Podczas programowania w JavaScript często łączymy różne style i paradygmaty programowania. Osobiście jednak po zapoznaniu się nieco bardziej z tematem programowania funkcyjnego uważam, że ten sposób pisania kodu może szybko zboostować to jak czysty i zrozumiały kod będziemy pisać.

Paradygmat, czyste funkcje, bezstanowość, funkcje wyższego rzędu, currying, niemutowalność

Oprócz samego programowania funkcyjnego słyszymy wiele słów-kluczy, które oczywiście są z nim związane, ale same w sobie nic nam nie mówią. Nawet często korzystając z gotowych rozwiązań, nie jesteśmy świadomi tego, że są one na przykład czystymi funkcjami. Jest to mój przykład jednej z rozmów rekrutacyjnych, kiedy na pytanie o to, czym jest reducer, szukałem jakichś wykwintnych odpowiedzi, a wystarczyło odpowiedzieć - _jest to czysta funkcja_.

Ale nie ma co się martwić, w tym poście postaram się opisać przynajmniej większość podstawowych pojęć związanych z programowaniem funkcyjnym.

Programowanie funkcyjne

Dobrze, wyjaśnijmy teraz, czym właściwie jest programowanie funkcyjne.

Jest to paradygmat - pewnego rodzaju styl programowania - oparty na idei tworzenia naszych aplikacji głównie za pomocą funkcji. Funkcje mogą być używane na różne sposoby: na przykład jako argumenty dla innych funkcji, jako wartości pewnych elementów (poprzez wywołanie funkcji, do której przekazane zostały argumenty), lub poprzez komponowanie funkcji (ang. "function composition").

Choć programowanie funkcyjne można stosować praktycznie w każdym języku programowania, rzeczywistość pokazuje, że najpopularniejsze języki, które szczególnie wspierają ten styl to: Scala, Elm, Clojure, F# czy JavaScript.

Czyste funkcje

Myślę, że jest to najbardziej znana koncepcja związana z programowaniem funkcyjnym. Mówimy o czystych funkcjach, czyli takich, które interesują się jedynie danymi, które otrzymują poprzez przekazane do nich argumenty, oraz tym, co zwracają jako wynik.

Czyste funkcje możemy traktować jako pewnego rodzaju czarne skrzynki – dostarczamy im dane na wejściu i oczekujemy konkretnej odpowiedzi na wyjściu, nie zależy nam na tym, co dzieje się w środku. Jest to deklaratywny sposób pisania kodu. Nie dajemy za każdym razem instrukcji programowi jak ma coś dla nas wykonać, tylko oczekujemy wyniku.

Czyste funkcje nie powodują również efektów ubocznych w reszcie naszego kodu czy aplikacji. Nie wykonują one żadnych operacji typu pobieranie/wysyłanie danych z serwera czy wyświetlanie elementów w naszej aplikacji.

Czyste funkcje

W rezultacie pisania czystych funkcji nasz kod staje się:

  • łatwiejszy do przewidzenia - wiemy, co dana funkcja ma zrobić i w jaki sposób,
  • łatwiejszy do debugowania,
  • łatwiejszy do testowania - testowanie czystych funkcji to prawdziwa przyjemność,
  • nie musimy pisać wielu scenariuszy dla jednej funkcji,
  • nie interesuje nas to, co dzieje się poza funkcją - tylko to, co przekazujemy jej w argumentach oraz co otrzymujemy w zamian.

Dobrze, przejdźmy do przykładu.

let firstName = "Mateusz";

const sayHello = () => console.log(`Hello, ${firstName}`);

sayHello();
// Hello, Mateusz

firstName = "Frank";

sayHello();
// Hello, Frank

Powyższa funkcja nie jest czysta. Jest "brudna" z kilku powodów. Pierwszym z nich jest poleganie na zmiennej, która nie jest przekazana do funkcji jako argument - jak już wiemy, czyste funkcje polegają wyłącznie na danych przekazanych jako argumenty.

Drugą rzeczą jest wykorzystanie metody console.log - czyste funkcje nie powinny wpływać na to, co dzieje się poza nimi.

Ostatnim elementem jest to, że funkcja, polegając na danych zewnętrznych (nieprzekazanych do niej jako argumenty), może zwrócić inny wynik, niż się spodziewaliśmy. Na przykład, ktoś mógł dodać linię kodu, która zmieniła wartość zmiennej, przez co rezultat funkcji także się zmienił.

Więc jak wygląda czysta funkcja?

const helloWorld = (name) => `Hello, ${name}`;

const firstName = "Frank";

helloWorld(firstName);

console.log(helloWorld(firstName));
// Hello, Frank

Czysta funkcja polega tylko na przekazanym jej argumencie. Zwraca łańcuch znaków, który następnie możemy wyświetlić w konsoli za pomocą console.log.

Warto pamiętać, że czyste funkcje zawsze zwracają ten sam rezultat dla tych samych argumentów.

Programowanie deklaratywne

Kolejne określenie, które może pomóc nam lepiej zrozumieć to, czym jest programowanie funkcyjne.

Przeciwieństwem programowania deklaratywnego jest programowanie imperatywne. Programowanie imperatywne określa sekwencję działań i warunków, które musi spełnić dany fragment kodu, abyśmy otrzymali wymagany rezultat.

Programowanie deklaratywne polega na zdefiniowaniu operacji, którą chcemy wykonać, bez szczegółowego opisu, jak ma być ona zaimplementowana. Innymi słowy, interesuje nas wynik, a nie sposób jego osiągnięcia.

Wspominałem już o czarnej skrzynce, którą możemy porównać do funkcji tworzonych w paradygmacie funkcyjnym. Właśnie tak tworzymy deklaratywny kod. Oczywiście, często wiemy, jak nasza funkcja generuje pewien rezultat, jednak osoby korzystające z naszych metod nie muszą się tym w ogóle interesować - dopóki spełniają one ich oczekiwania.

let firstName = "Ron";
let welcome = "Siemanko";
let endMark;

const helloWorld = () => {
	if (endMark) {
		return console.log(`${welcome}, ${firstName}${endMark}`);
	}

	return console.log(`${welcome}, ${firstName}`);
};

helloWorld();
// Siemanko, Ron

endMark = "!";

helloWorld();
// Siemanko, Ron!

Powyższa funkcja jest imperatywna. Musimy wewnątrz sprawdzić, czy istnieje zmienna endMark, dla której funkcja ma inny scenariusz w przypadku gdy jest do niej coś przypisane (pomijam fakt sprawdzania tego, jaki ta zmienna ma typ).

const helloWorld = (welcome, name, endMark = '') => {
	return `${welcome} ${name}${endMark}`;
};

console.log(helloWorld("Witamy w Kolonii", "Bezimienny", "!"));

Ta funkcja jest deklaratywna. Wiemy, że musimy jej przekazać powitanie, imię oraz opcjonalny znak końcowy. To, w jaki sposób tworzony jest rezultat, nas nie obchodzi.

Oczywiście, w tym przypadku powinniśmy poinformować osobę korzystającą z tej funkcji, że wszystkie argumenty muszą zostać przekazane (choć argument endMark jest opcjonalny), lub skorzystać z wartości domyślnych. Ale to nie jest tematem tego posta.

Side effects

Wspominałem już o tym, że pisząc kod funkcyjnie unikamy generowania niepożądanych działań w naszej aplikacji. Jednak w tym momencie chce się skupić na niepożądanej edycji danych.

W JavaScript stałe deklarowane przy użyciu słowa kluczowego const są nieedytowalne. Jednak w przypadku obiektów czy tablic mamy dostęp do referencji, dzięki czemu możemy zmieniać ich zawartość.

const functionalProgrammer = {
	name: "Frank",
	technologies: ["JS", "React"],
	experienceLevel: "Senior"
};

const setExperienceLevel = (newLevel) => {
	functionalProgrammer.experienceLevel = newLevel;
};

setExperienceLevel("Junior");
console.log(functionalProgrammer.experienceLevel);
// 'Junior'

Powyższa funkcja wpływa na zmienne znajdujące się poza nią. W tym konkretnym przykładzie odnosimy się do referencji obiektu functionalProgrammer i zmieniamy jedną z jego wartości.

const functionalProgrammer = {
	name: "Frank",
	technologies: ["JS", "React"],
	experienceLevel: "Senior"
};

const setExperienceLevel = (programmer, newLevel) => {
	return {
		...programmer,
		experienceLevel: newLevel
	};
};

const newFunctionalProgrammer = setExperienceLevel(functionalProgrammer, "Junior");
console.log(newFunctionalProgrammer.experienceLevel);
// 'Junior'

W tym przypadku z funkcji setExperienceLevel zwracamy nowy obiekt z wszystkimi wartościami, które posiadała wejściowa zmienna programmer oraz zmieniamy jej wartość experienceLevel na tę podaną w drugim argumencie funkcji.

Rekurencja

Rekurencja sprawia, że nasz kod jest mniej skomplikowany, chociaż na pierwszy rzut oka może wydawać się niezrozumiała.

Okej, ale najpierw czym jest rekurencja? Rekurencja to wywołanie funkcji samej w sobie.

Nieco bardziej rozwijając, jest to technika iterowania po zbiorze danych w celu wykonania na nich jakiegoś rodzaju akcji - najczęściej wywołania tej samej funkcji, w której ta funkcja jest zdefiniowana. Na przykład wykonania funkcji, odrzucenia niechcianych elementów lub przefiltrowania elementów w celu otrzymania nowego zbioru, który będzie zawierał tylko wybrane pozycje.

Weźmy sobie najpierw na przykład funkcję nerekurencyjną. Oczywiście najpopularniejszy przykład to funkcja obliczająca ciąg Fibonacciego. Nie chcę się tu zagłębiać w to czym ten ciąg jest i jakie problemy rozwiązuje.

Skupmy się na implementacji. Zauważmy, że na początku są dwa ify, oba sprawdzają bazowy przypadek, w tym wypadku są dwa 0 oraz 1. Dla tych przypadków wynik będzie taki sam jak wejściowa liczba więc nie trzeba tu nic obliczać.

Kolejne przypadki, na przykład dla liczby 14, trafią już do pętli for, gdzie zostanie obliczony ciąg Fibonacciego, a raczej jego konkretny element. Wszystkie przypadki, które nie są bazowymi (base case), są przypadkami rekurencyjnymi (recursive case).

const fibonacci = (n) => {
	if (n === 0) return 0;
	if (n === 1) return 1;

	let previous = 0;
	let current = 1;

	for (let i = n; i > 1; i--) {
		let next = previous + current;
		previous = current;
		current = next;
	}

	return current;
};

fibonacci(14);
// 377

W wersji rekurencyjnej czyli funkcji, która wywołuje siebie sama powyższa funkcja będzie wyglądała w ten sposób.

function recursiveFibonacci(n) {
	if (n === 0) return 0;
	if (n === 1) return 1;

	return recursiveFibonacci(n - 2) + recursiveFibonacci(n - 1);
}

I to w zasadzie jest cała tajemnica rekurencji. Podobnie jak w funkcji nerekurencyjnej, mamy dwa przypadki bazowe, a reszta jest obliczana poprzez rekurencyjne wywoływanie funkcji. W rekurencji kryje się pewnego rodzaju magia, ale to temat na osobne, głębsze studia.

Warto jednak zauważyć, że ta konkretna implementacja rekurencyjna ciągu Fibonacciego jest nieefektywna, ponieważ wielokrotnie oblicza te same wartości. Rekurencja jest potężnym narzędziem, ale wymaga starannego stosowania.

Higher-order functions & First-class functions

Funkcja wyższego rzędu (Higher-order function) to taka funkcja, która przyjmuje jako argument inną funkcję (First-class function) lub zwraca funkcję. Podstawowymi funkcjami wyższego rzędu, które zapewne znamy, są .map(), .reduce() oraz .filter(), czyli wbudowane metody JavaScriptu.

High-order functions JavaScript

Tak jest, do argumentów funkcji możemy przekazać inne funkcje. Bardzo często takie działanie uskuteczniamy pisząc w bibliotece React, nawet nie zawsze będąc tego do końca świadomymi.

Na pewno wiele razy spotkaliśmy się ze stwierdzeniem o konieczności wywołania lub przekazania callbacka. To właśnie first-class functions są naszymi callbackami.

Na podstawowy przykład użycia first class function, spojrzyjmy na funkcję sumTwoValues. Przekazujemy do niej inną funkcję jako argument, a ta funkcja zwraca wynik, który jest następnie zwracany przez sumTwoValues.

const sumTwoValues = (fn, a, b) => fn(a, b);

const firstClassFunction = (a, b) => a + b;

sumTwoValues(firstClassFunction, 5, 10); // 15

Higher-order function to, jak wspomniałem wcześniej, funkcja wywołująca inną funkcję jako callback. Świetnymi przykładami wykorzystywania callbacka są wcześniej wspomniane metody wbudowane w język JavaScript.

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const multiplyNumbers = (numbersList, multiplier) => {
	return numbersList.map((num) => num * multiplier);
};

multiplyNumbers(numbers, 2);
// [ 2, 4, 6, 8, 10, 12, 14, 16, 18, 20 ]
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const filterOutOddNumbers = (numbersList) => {
	return numbersList.filter((num) => num % 2 === 0);
};

filterOutOddNumbers(numbers);
// [2, 4, 6, 8, 10]

W ten sposób możemy zrobić przykłady funkcji wyższego rzędu przy pomocy większości metod wbudowanych w JavaScript. Warto jednak zapamiętać, czym charakteryzują się poszczególne funkcje według ich nazewnictwa.

Closure (Domknięcie)

W momencie, gdy z funkcji zwracamy inną funkcję, ta zwracana funkcja pamięta zakres funkcji nadrzędnej, w której została zdefiniowana.

Może to wydawać się enigmatyczne (mam nadzieję, że dla osób piszących w JS - nie jest), ale jest to także pewnego rodzaju narzędzie, które możemy wykorzystywać. Najczęściej takie rozwiązanie spotykamy, gdy z funkcji zwracana jest funkcja anonimowa.

const helloWorld = (welcomeMessage) => {
	return function (name) {
		return `${welcomeMessage}, ${name}`;
	};
};

const sayWelcomeToFPStudent = helloWorld("Welcome in FP world");

sayWelcomeToFPStudent("Frank");
// Welcome in FP world, Frank

sayWelcomeToFPStudent("John");
// Welcome in FP world, John

Przy wywołaniu funkcji sayWelcomeToFPStudent, zapamiętywany jest zakres tego wywołania oraz powitanie, które przekazaliśmy do funkcji helloWorld. W ten sposób nie musimy za każdym razem przekazywać tego samego powitania, tylko korzystamy z jednej definicji.

Domknięcie (closure) to temat, który warto poznać szerzej jako programista JavaScript. Dobrym początkiem, oprócz lepszego zrozumienia podstawowego przykładu, który tu przedstawiłem, będzie zapoznanie się z dokumentacją.

Currying

Ostatni z podstawowych elementów naszego arsenału to currying, czyli technika rozbijania funkcji z wieloma argumentami na serię funkcji jednoargumentowych.

Przyjrzyjmy się ponownie funkcji, którą przedstawiłem wcześniej. Zobaczmy, jak wyglądałaby ta funkcja, gdybyśmy nie zastosowali techniki closure, a zamiast tego skorzystali z curryingu.

const helloWorld = (welcomeMessage, name) => {
	return `${welcomeMessage}, ${name}`;
};

helloWorld("Witamy w Kolonii", "Bezimienny");

// ---------------

const helloWorld = (welcomeMessage) => {
	return function (name) {
		return `${welcomeMessage}, ${name}`;
	};
};

const sayWelcomeToFPStudent = helloWorld("Welcome in FP world");

sayWelcomeToFPStudent("Frank");

Natomiast bardziej rozbudowana funkcja z wykorzystaniem curryingu może wyglądać następująco.

const helloWorld = (message, name, enderMark) => {
	return `${message}, ${name}${enderMark}`;
};

const curriedHelloWorld = (message) => (name) => (enderMark) =>
	helloWorld(message, name, enderMark);

curriedHelloWorld("Witamy na pokładzie")("Frank")("!");

Powyższa wersja jest poprawna gramatycznie i jasno tłumaczy technikę curryingu.

Function composition (Kompozycja funkcji)

Wspomniałem we wstępie o komponowaniu funkcji. W najprostszych słowach, kompozycję funkcji można zdefiniować jako:

Przepływ danych przez serię funkcji, gdzie wynik jednej funkcji staje się argumentem kolejnej.

Kompozycja funkcji pozwala tworzyć skomplikowane struktury z prostych, przejrzystych funkcji.

Function composition JavaScript

Poniższy przykład przedstawia, jak działa kompozycja funkcji. Pierwsza funkcja, multiplyBy, przyjmuje wartość oraz mnożnik, a następnie zwraca wynik ich mnożenia. Druga funkcja, addTen, dodaje 10 do wyniku funkcji multiplyBy. Ostatnia funkcja, addDescription, przyjmuje opis i wartość, czyli w naszym przypadku wynik dwóch poprzednich funkcji, i zwraca sformatowany ciąg znaków.

const multiplyBy = (value, multiplier) => value * multiplier;
const addTen = (value) => value + 10;
const addDescription = (desc, value) => `${desc} ${value}`;

const myValue = addDescription("My value is", addTen(multiplyBy(54, 2)));

console.log(myValue);
// "My value is 118"

To jest dość prosty przykład, ale jestem przekonany, że pomógł Ci zrozumieć, czym jest kompozycja funkcji.

Niezmienność danych (Immutability)

Niezmienność, znana również jako immutability, to kolejny kluczowy element programowania funkcyjnego. Mówiąc najprościej, niezmienność oznacza, że raz utworzone dane nie mogą być później zmienione. To może brzmieć jak coś dziwnego, zwłaszcza jeśli jesteś przyzwyczajony do modyfikowania danych na bieżąco w programowaniu imperatywnym.

Dlaczego niezmienność jest ważna? Odpowiedź na to pytanie jest prosta: pomaga nam to utrzymać czystość naszych funkcji, unikając nieprzewidywalnych efektów ubocznych. Ponadto, jest to niezbędne dla niektórych technik optymalizacyjnych, takich jak Persistent Data Structures, które są powszechnie stosowane w niektórych bibliotekach JavaScript, takich jak React i Redux.

Spójrzmy na przykład. Załóżmy, że masz tablicę liczb i chcesz do każdej z nich dodać 1. Imperatywny sposób mógłby wyglądać tak:

let numbers = [1, 2, 3, 4, 5];

for (let i = 0; i < numbers.length; i++) {
	numbers[i] = numbers[i] + 1;
}

console.log(numbers);
// [2, 3, 4, 5, 6]

W powyższym kodzie bezpośrednio modyfikujesz oryginalną tablicę, co nie jest zgodne z zasadą niezmienności. Zamiast tego, w podejściu funkcyjnym, tworzylibyśmy nową tablicę z wymaganymi zmianami, pozostawiając oryginalną tablicę nietkniętą.

const numbers = [1, 2, 3, 4, 5];

const incrementedNumbers = numbers.map(number => number + 1);

console.log(incrementedNumbers);
// [2, 3, 4, 5, 6]

console.log(numbers);
// [1, 2, 3, 4, 5]

Jak widać, pomimo wykonania operacji na tablicy numbers, oryginalna tablica pozostaje nietknięta. Ta technika jest nazywana "niezmiennością" i jest kluczowym aspektem programowania funkcyjnego.

Jest wiele funkcji wbudowanych w JavaScript, które pomagają w utrzymaniu niezmienności, takich jak map(), filter(), reduce(), concat() i slice(). Nauczenie się ich użycia jest kluczowym krokiem do pisania kodu w stylu funkcyjnym.

Ważne jest jednak, aby pamiętać, że niezmienność może być bardziej kosztowna pod względem wydajności, ponieważ często wymaga tworzenia nowych kopii danych zamiast ich modyfikacji. Dlatego też istotne jest zrozumienie, kiedy i gdzie najlepiej ją stosować.

Podsumowanie

Uff, to na tyle jeżeli chodzi o wstęp do programowanie funkcyjnego. Zapewne pominąłem tutaj jeszcze kilka mniej lub bardziej znanych pojęć z tego zakresu. Myślę jednak, że to co zawarłem w tym artykule pomoże wam z wejściem w świat programowania funkcyjnego.

Tak naprawdę każdy powyższy temat można rozciągnąć na kilkanaście tego typu wpisów, wliczając w to korzystanie z bibliotek ułatwiających pisanie kodu funkcyjnego.

Zachęcam do tego oczywiście, jest wiele materiałów z tego temat.