Z czym to się je
Programowanie funkcyjne polega na kompozycji funkcji, unikaniu współdzielenia stanu oraz mutowania danych.
Rozpracujmy te enigmatyczne pojęcia.
Kompozycja funkcji polega na łączeniu dwóch, lub większej ilości, funkcji w celu stworzenia nowej funkcji.
Unikanie współdzielenia stanu to ograniczenie wykorzystywania zmiennych z zakresów zewnętrznych. Opieramy działanie naszych funkcji o dane wejściowe.
Unikanie mutowania danych polega na traktowaniu wszystkich zmiennych jak stałych.
Powyższe zasady stawiają wysokie wymagania, zwłaszcza wobec osób przyzwyczajonych do programowania obiektowego. Warto zacisnąć zęby. Wysiłek zaowocuje wymiernymi korzyściami.
Funkcje przeznaczone do kompozycji są proste, co ułatwia wielokrotne ich zastosowanie.
Unikanie współdzielenia stanu uchroni nas przed wystąpieniem błędów, związanych z jednoczesnym modyfikowaniem tych samych zasobów przez różne części naszego systemu.
Rezygnowanie z mutowania danych daje nam pewność co do wartości wszystkich zmiennych zadeklarowanych w naszym programie.
Aby programowanie funkcyjne było efektywne, wykorzystywany język programowania musi posiadać dwa następujące mechanizmy: funkcje pierwszej klasy i funkcje wyższego rzędu. Funkcja pierwszej klasy to funkcja, które może być traktowana jak wartość. Można ją przypisać do zmiennej, przekazać jako argument do innej funkcji. Funkcja wyższego rzędu to funkcja, która przyjmuje funkcję jako argument lub zwraca inną funkcję.
Dobrze się składa. W JavaScript mamy do dyspozycji zarówno funkcje pierwszej klasy, jak i funkcje wyższego rzędu.
Zanim przejdziemy do praktyki, zastanówmy się nad minusami tego paradygmatu.
Po pierwsze, nadużywanie technik programowania funkcyjnego prowadzi do zredukowania czytelności. Kod staje się bardzo abstrakcyjny. Wyczucie złotego środka nie jest łatwe. Programowanie funkcyjne ma stromą krzywą nauki.
Druga wada nie wynika z samego programowania funkcyjnego, a raczej popularności konkurencyjnego paradygmatu. Zdecydowanie większa liczba programistów korzysta na co dzień z programowania obiektowego. Taki trend w inżynierii oprogramowania utrzymuje się od lat 90.
Deklaratywność kontra imperatywność
W programowaniu funkcyjnym koncentrujemy się na tym, co chcemy osiągnąć (programowanie deklaratywne), zamiast na tym, jak to zrobić (programowanie imperatywne). Ułatwia to refactoring i optymalizację.
Różnicę pomiędzy tymi dwoma sposobami myślenia najłatwiej zaobserwować za pomocą przykładów (nareszcie).
function logEach(array) { for (var i = 0; i < array.length; i++) console.log(array[i]); }
Powyższa funkcja to przykład programowania imperatywnego. Mało tutaj abstrakcji. Kładziemy nacisk na wykonanie konkretnych czynności. Z tego powodu funkcja logEach nadaje się wyłącznie do realizacji jednego celu – wydrukowania wszystkich elementów tablicy przekazanej w parametrze.
Przyjrzyjmy się, jak można ugryźć nasz problem za pomocą programowania deklaratywnego.
function forEach(array, action) { for (var i = 0; i < array.length; i++) action(array[i]); } const logEach()
Funkcja forEach przyjmuje dodatkowy parametr action
. Jest to wartość funkcyjna. Podczas iterowania przekazujemy do action
kolejne elementy array
. Jeżeli przypiszemy do action
funkcję console.log
, to deklaratywne forEach
zadziała dokładnie tak samo jak imperatywne logEach
. Co najważniejsze, to dopiero początek naszych możliwości. forEach
poradzi sobie z każda funkcją przyjmującą jeden parametr. Subtelna zmiana w sposobie myślenia znacznie zwiększa wszechstronność i reużywalność pisanego kodu.
Funkcje czyste
Kolejną fundamentalną koncepcją w programowaniu funkcyjnym jest wykorzystywanie czystych funkcji (ang. pure functions). Czyżbyśmy zarzucali programowaniu obiektowemu niedociągnięcia na tle higieny? Poniekąd. Czyste funkcje operują jedynie na swoich danych wejściowych. Nie korzystają ze zmiennych z zakresów otaczających. Nie powodują efektów ubocznych. Z zasady powinny być proste w działaniu.
const z = 5; function multiply(x, y) { return x + y; }
Funkcja multiply nie korzysta ze zmiennej
z
. Nie pobiera jej wartości. Nie przypisuje do niej nowej wartości. Jedynie odczytuje wartość parametrów x
i y
oraz zwraca wynik ich sumy.
W związku z zakazem korzystania z zakresów otaczających i powodowania efektów ubocznych, funkcja czysta musi przyjmować przynajmniej jeden parametr i zwracać jakąś wartość, aby mieć praktyczne zastosowanie.
Funkcje czyste są deterministyczne. Cóż oznacza to trudne słowo? A no, dla takich samych danych wejściowych funkcja czysta zawsze zwróci takie same dane wyjściowe. Jedno wywołanie funkcji z danymi wartościami daje nam informację o efekcie kolejnych stu wywołań.
console.log(multiply(2,2)); // 4 console.log(multiply(2,2)); // 4 console.log(multiply(2,2)); // 4 // ... - resztę testów możecie przeprowadzić we własnej konsoli ;)
Pewnie wielu z Was zadaje sobie następujące pytanie: jakim cudem mam się obyć bez efektów ubocznych? Przecież aktualizacja bazy danych, wysyłanie zapytań AJAX, nadpisywanie plików – wszystkie te metody opierają się przede wszystkim na efektach ubocznych.
Programowanie funkcyjne wcale nie wiąże się z redukcją efektów ubocznych do zera. Jest to antyproduktywne. Ba, w 99% programów wręcz niemożliwe. Musimy postawić sobie inny, racjonalny cel. Zadowolimy się ograniczaniem efektów ubocznych do minimum.
Dzięki wspomnianemu determinizmowi, nasz wysiłek będzie nagrodzony mniejszą ilością błędów. Z tymi, które prześlizgną się przez naszą funkcyjną defensywę, poradzimy sobie z łatwością. Wszystko dzięki temu, że na stan danych wejściowych nie będzie wpływało kilka części składowych systemu jednocześnie.
Funkcje tablicowe
Czas zapoznać się z najpopularniejszym sposobem wykorzystania programowania funkcyjnego w JS. Są to tablicowe funkcje wyższego rzędu. Do tego zacnego grona należą: forEach, filter, map, reduce, every, some, find, includes.
Każda z wymienionych metod jest właściwością Array.prototype
. Ich znajomość pozwala robić niesamowite rzeczy z tablicami.
forEach(callback, thisArg) DS: MDN link
To funkcja wyższego rzędu przyjmująca jako parametr callback
i opcjonalnie kontekst this
. forEach
przekazuje do callbacka trzy następujące parametry: element tablicy, po której iterujemy, jego indeks oraz referencję do całej tablicy. Takie same parametry trafiają do callbacków przekazanych do: filte
, map
, every
, some
, find
i includes
.
Tak się składa, że idealnym kandydatami do roli callbacka są funkcje czyste.
forEach
nie mutuje tablicy oraz ignoruje wartość zwracaną przez callbacka. Zawsze przeiteruje on całą tablicę. Nadaje się przede wszystkim do wywoływania efektów ubocznych z użyciem każdego elementu tabeli. Przykładowe zastosowania: logowanie zawartości tabeli obiektów, przypisywanie elementów do innej struktury. Wartość zwrotna z forEach to zawsze undefined
.
const nbaPlayers = [ { name: 'Chris Paul', team: 'Houston Rockets' }, { name: 'Steph Curry', team: 'Golden State Warriors' }, { name: 'Lebron James', team: 'Cleveland Cavaliers' }, { name: 'Russel Westbrook', team: 'Oklahoma City Thunder' }, ]; const logTeam = player => console.log(player.team); const newArr = players.forEach(sumValAndIndex); // 'Houston Rockets', 'Golden State Warriors', 'Cleveland Cavaliers', 'Oklahoma City Thunder'; newArr: undefined;
Funkcyjna święta trójca
filter
, map
i reduce
, w przeciwieństwie do forEach
, reagują na wartość zwracaną przez callbacka. Wartość zwrotna z każdego wywołania callbacka, ma wpływ na zawartość tablicy, zwracanej przez tę funkcję. Daje to szerokie pole do popisu, w zakresie obróbki danych, przechowywanych w tablicy.
Uwaga: Wspomniane metody zwracają nową tablicę. Nie myl tego z mutowaniem tablicy, na której wywołujesz metodę.
filter
Nazwa tej metody daje bardzo trafną wskazówkę, dotyczącą jej działania.
Jej wywołanie skutkuje przefiltrowaniem tablicy, w celu wyznaczenia elementów, które spełniają postawiony warunek.
filter
dokonuje konwersji wartości zwrotnej callbacka do typu boolowskiego. Jeżeli wartość zwrotna zostanie obliczona jako true
, element zostanie dodany do nowej tablicy. W przeciwnym przypadku, gdy otrzymamy false
, element zostanie zignorowany.
const isHoustonPlayer = player => player.team === 'Houston Rockets'; const houstonPlayers = players.filter(isHoustonPlayer); // houstonPlayers: [{ player: 'Chris Paul', team: 'Houston Rockets' }];
map
Po raz kolejny poszukajmy podpowiedzi w nazwie analizowanej metody.
Mapowanie to proces przyporządkowania jednej rzeczy do drugiej. Przyporządkujemy efekty operacji na wartościach elementów starej tablicy do odpowiednich indeksów nowej tablicy.
Tablica zwracana przez map zawsze miała taką samą długość co tablica wyjściowa.
const getPlayerTeam = player => player.team; const topNbaTeams = players.map(getPlayerTeam); // topNbaTeams: ['Houston Rockets', 'Golden State Warriors', 'Cleveland Cavaliers', 'Oklahoma City Thunder']
reduce
Najlepsze zostawiłem na koniec – reduce. Tym razem będziemy redukowali tablicę do nowej formy. Efektem wywołania tej metody może być zwrócenie wartości prymitywnej nowego obiektu.
Za pomocą reduce
można zaimplementować własną wersję poprzednio zaprezentowanych funkcji oraz zrobić wiele, wiele więcej. Ogranicza nas jedynie wyobraźnia.
Jest to możliwe ze względu na dwie unikalne funkcjonalności.
Po pierwsze, mamy do dyspozycji dodatkowy, względem tych, które poznaliśmy wcześniej, parametr przekazywany do callbacka.
Jest to accumulator
, czyli wartość zwrócona przez callback w poprzedniej iteracji. Dzięki temu reduce świetnie sprawdza się przy obliczaniu sumy, średniej i innych działaniach matematycznych.
const arr = [1, 2, 3]; const sum = (acc, cur) => acc + cur; arr.reduce(sum); // 6
Po drugie, reduce
oprócz callbacka przyjmuje drugi, opcjonalny parametr – initialValue
. Jest to wartość startowa accumulatora
. Jeżeli zrezygnujemy z podania tego parametru, reduce
przypisze do accumulatora
pierwszy element tablicy, a do currentValue
trafi drugi element tablicy. initialValue
zdejmuje z nas ograniczenia co do typu wartości zwracanej przez reduce
.
Finalnie reduce
zwraca wartość z ostatniego wywołania callbacka.
const count = (acc, cur) => { const hasCur = acc.hasOwnProperty(cur); acc[cur] = hasCur ? acc[cur] + 1 : 1; return acc; } const names = ['Daniel', 'Marcin', 'Jędrzej', 'Michał', 'Marcin', 'Daniel', 'Michał', 'Jędrzej', 'Michał']; names.reduce(count, {}); // {Daniel: 2, Marcin: 2, Jędrzej: 2, Michał: 3}
Funkcje testujące
Znamy już najważniejsze funkcje wyższego rzędu pozwalające przetwarzać zawartość tablicy. Mamy jeszcze do dyspozycji metody o innym zastosowaniu. Za pomocą every
i some możemy sprawdzić, w jakim stopniu elementy tablicy spełniają warunek postawiony w callbacku.
every
Po raz kolejny nazwa metody służy nam za świetną dokumentację. Sprawdzamy, czy każdy (ang. every) element spełnia postawiony warunek.
Jeżeli dla wszystkich elementów, callback zwrócił wartość obliczoną jako true
, every
również zwróci true
.
Jeżeli chociażby dla jednego elementu, wartość zostanie obliczona jako false
, every
również zwróci false
.
Przykładowo: chcemy podzielić dowolną liczbę przez wszystkie elementy tablicy. Najpierw wypadałoby upewnić się, że wszystkie elementy są wartościami typu Number
, różnymi od 0
i NaN
. To idealne zadanie dla every
.
const isValidDivider = el => el !== 0 && Number.isFinite(el); const validDividers = [1, 2, 4]; const notSoValidDividers = [1, 0, 2]; const notSoValidDividers2 = [1, NaN, 2]; let isValid = validDividers.every(isValidDivider); // true isValid = notSoValidDividers.every(isValidDivider); // false isValid = notSoValidDividers2.every(isValidDivider); // false
some
W przypadku some
rozluźniamy trochę wymagania. Chcemy się dowiedzieć, czy jakikolwiek element spełnia postawiony warunek. Jeżeli tak, some
zwróci true
. Jeżeli wszystkie wywołania callbacka zwrócą false
, wtedy some
pójdzie w ich ślady i również zwróci false
.
const isEven = el => el % 2 === 0; const evenArr = [2, 4, 8]; const notSoEvenArr = [2, 5, 8]; const notEvenAtAllArr = [3, 5, 7]; evenArr.some(isEven); // true notSoEvenArr.some(isEven); // true notEvenAtAllArr.some(isEven); // false
Głównym zastosowaniem every
i some
, jest testowanie elementów tablicy, pod kątem przystosowania do dalszego przetwarzania za pomocą map
i reduce
.
Warto wiedzieć: every
i some
są funkcjami leniwymi. Przestają analizować tablicę, gdy natrafią na pierwszy false
(w przypadku every
) lub true
(w przypadku some
).
Materiały pomocnicze
- Array Explorer – projekt autorstwa Sary Drasner. Pozwala wybrać właściwą metodę tablicową, w oparciu o informacje dotyczące problemu, który chcemy rozwiązać. Przyjemny dla oka design i intuicyjny interfejs sprawiają, że chętniej korzysta się z tego rozwiązania niż z tradycyjnej dokumentacji.
- Native array methods exercises – repozytorium na GitHubie z ćwiczeniami do wszystkich omówionych dzisiaj metod. Wystarczy pobrać projekt, wklepać
npm install
i podążać za kolejnymi wskazówkami zREADME.md
.