All Articles

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, pure functions, stateless, high-order, first-class, currying

Oprócz samego programowania funkcyjnego słyszymy wiele buzz-wordów, które oczywiście są z nim związane, ale same one 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 czystą funkcją. 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ć - 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.

Functional programming

Okej, wytłumaczmy teraz czym, właściwie jest programowanie funkcyjne.

To paradygmat, pewnego rodzaju styl programowania, za którego ideą nasze aplikacje są tworzone głównie przy pomocy funkcji. Funkcje są aplikowane na przykład jako argument dla innej funkcji, używane jako wartość jakiegoś elementu (poprzez wywołanie funkcji, do której przekazane zostały argumenty) lub poprzez komponowanie funkcji (function composition).

Funkcyjnie możemy pisać praktycznie w każdym języku programowania, w rzeczywistości jednak najpopularniejsze języki wspierające taki styl to: Scala, Elm, Clojure, F# czy JavaScript.

Pure functions

Myślę, że najbardziej znana idea idąca za tematem programowania funkcyjnego. Czyste funkcje, czyli funkcje, które interesują się tylko danymi, które otrzymują poprzez przekazane do nich argumenty oraz to, co przekazują nam w odpowiedzi.

Czyste funkcje możemy traktować jako pewnego rodzaju czarne skrzynki, dajemy im coś na wejściu i oczekujemy pewnego rodzaju odpowiedzi na wyjściu - to co dzieje się pośrodku nas nie interesuje. 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 mają także efektów ubocznych na resztę naszego kodu czy też aplikacji. Czyste funkcje nie wykonują żadnych operacji typu pobieranie/wysyłanie danych z serwera czy też wyświetlanie elementów w naszej aplikacji.

Czyste funkcje

W konsekwencji pisania czystych funkcji nasz kod staje się:

  • łatwiejszy do przewidzenia - co dana funkcja ma zrobić i w jaki sposób,
  • debugowanie staje się prostsze,
  • testowanie funkcji czystych to coś pięknego,
  • nie musimy pisać kilku scenariuszy dla jednej funkcji,
  • nie obchodzi nas to, co dzieje się poza funkcją - tylko to, co przekazujemy jej w argumentach oraz co otrzmujemy wzamian.

Okej, 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 to brudna funkcja z kilku powodów. Pierwszy z nich to poleganie na zmiennej nieprzekazanej do funkcji - jak już wiemy funkcje czyste, polegają tylko elementy przekazane poprzez argumenty.

Drugą rzeczą jest natomiast wykorzystana metoda console.log - czyste funkcje nie powinny mieć wpływu, na co dzieje się poza nimi.

Ostatnim elementem jest to, że funkcja poprzez poleganie na zewnętrznych danych (nieprzekazanych do niej) może nam, odpowiedzieć czymś innym niż się spodziewaliśmy. Na przykład ktoś doda linijkę, która zmieniła wartość zmiennej, przez co rezultat funkcji także się zmienił.

Więc jak wygląda funkcja czysta?

const helloWorld = (name) => `Hello, ${name}` const firstName = 'Frank' helloWorld(firstName) console.log(helloWorld(firstName)) // Hello, Frank

Funkcja czysta polega tylko na przekazanym jej argumencie. Zwraca string template, który następnie możemy wyświetlić w konsoli poprzez użycie console.log.

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

Programowanie deklaratywne

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

Przeciwieństwem programowanie deklaratywnego jest programowanie imperatywne. Imperatywne programowanie określa sekwencje działań i warunków, jakie musi kawałek kodu, abyśmy otrzymali wymagany rezultat

Programowanie deklaratywne to polecenie, które ma zostać wykonane przez program, ale sama jego implementacja w środku nas nie obchodzi.

Wspominałem już o czarnej skrzynce, która możemy określić funkcje w tworzone w paradygmacie funkcyjnym. W taki sposób właśnie tworzymy deklaratywny kod. Oczywiście my 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}${enderMark}`) } return console.log(`${welcome}, ${firstName}`) } helloWorld() // Siemanko, Ron endMark = '!' helloWorld() // Siemanko, Ron!

Powyższa funkcja jest imperatywna. Musimy wewnątrz sprawdzić, czy istnieje zmienna enderMark, 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 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 lub skorzystać z wartości podstawowych. Lecz nie to 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 elementy 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.

Recursion

Rekurencja to kolejna z podstawowych broni z arsenału programowania funkcyjnego. Rekurencja tworzy nasz kod mniej zagmatwanym, choć na pierwszy rzut oka może zostać 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 wzór funkcję nie-rekrutencyjną. Oczywiście najpopularniejszy przykład czyli funkcja obliczająca ciąg Fibonacciego. Nie chce 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, czyli na przykład liczba 14 trafi już do pętli for gdzie zostanie obliczony ciąg Fibonacciego, a raczej jego wynik. Wszystkie przypadki, które są bazowymi (base case) są przypadkami rekurencyjny (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 w sumie można powiedzieć, że to tyle. Podobnie jak w poprzedniej funkcji, dwa przypadki bazowe, reszta obliczana jest przy wywoływaniu funkcji samej przez siebie. Jest tu trochę magii, temat jest szeroki więc warto go lepiej poznać na własną rękę.

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 JavaScriptowe.

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 będąć tego do końca świadomymi.

Pewnie bardzo często spotkaliśmy się z stwierdzeniem aby wywołać lub przekazać callbacka. To właśnie first-class functions są naszymi callbackami.

Podstawowy przykład z użyciem first class function. Przekazujemy jakąś funkcje jako argument funkcji sumTwoValues w wyniku czego przekazana funkcja zwraca rezultalt, który jest także zwracany przez funkcje do której została ona przekazana.

const sumTwoValues = (fn, a, b) => fn(a, b); const firstClassFunction = (a, b) => a + b; sumTwoValues(firstClassFunction, 5, 10) // 15

Higher-order function to tak jak wspomniałem wcześniej funkcja wywołująca inna funkcje 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. Warto jednak zapamiętać to czym charakteryzują się poszczególne funkcje według ich nazewnictwa.

Closure

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

Może to wydawać się enigmatyczne (mam nadzieję, że dla osób piszącaych w JS - nie jest), ale jest to także pewnego rodzaju narzędzie, które możemy wykorzystywać. Najczęściej takie rozwiązanie możemy spotkać w momencie 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

Wraz z wywołaniem funkcji sayWelcomeToFPStudent zapamiętywany jest zakres tego wywolania oraz to jakie przywitanie przekazaliśmy do funkcji helloWorld. W ten sposób nie musimy za każdym razem przekazywać tego powitania tylko korzystamy z jednej definicji.

Closure, domknięcie to temat, który warto poznać szerzej jako programista JavaScript. Dobrym początkiem będzie oprócz lepszego zrozumienia tego podstawowe przykładu, który tu ukazałem, zajrzenie do dokumentacji.

Currying

Ostatni z podstawowych elementów arsenału. Currying czyli rozbijanie funkcji posiadających wiele argumentów na funkcje jedno-argumentowe.

Przedstawiona wcześniej funkcja jest dobrym przykładem. Tak by ona wyglądała gdybyśmy nie rozbili jej na wykorzystanie mocy Closure, tym samym zaimplementowanie 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 wykorzystanie Currying 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')('!');

Function composition

Wspominałem we wstępie o komponowaniu funkcji. Najprościej można to określić jako:

Przepływ danych przez funkcje - wynik funkcji staje argumentem, argument staje się wynikiem.

Kompozycja pozwala na stworzenie czegoś skomplikowanego z prostych, przejrzystych funkcji.

Function composition JavaScript

Poniższy prosty przykład przedstawia jak działa komponowanie funkcji. Pierwsza funkcja multiplyBy przyjmuje wartość oraz mnożnik przez którą ma zostać przemnożona. Druga funkcja addTen po prostu dodaje 10 do wyniku funkcji multiplyBy. Ostatnia funkcja addDescription przyjmuje opis oraz wartość czyli w naszym przypadku wynik poprzednich dwóch funkcji.

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"

Jest to dość prosty przykład ale myślę, że dzięki niemu zrozumiałeś czym jest kompozycja funkcji.

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.