Sieć połączonych ze sobą żyłek.

Programowanie funkcyjne – Powtórka przed ReactJS #11

Dzisiaj przyjrzymy się, czym tak naprawdę zajmują się koderscy hipsterzy. Programowanie funkcyjne to najpopularniejsza alternatywa dla programowania obiektowego. W ostatnich latach zdobywa coraz większą popularność. Wbrew pozorom wywołanym przez ten hype, samo programowanie funkcyjne nie jest niczym nowym. Ten paradygmat istnieje od dziesięcioleci. Do tej pory był używany przede wszystkim w środowiskach akademickich. Jego popularność wzrosła za sprawą Reacta. Ta biblioteka wykorzystuje wiele fundamentalnych koncepcji programowania funkcyjnego.

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

  1. 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.
  2. 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 z README.md.

Podsumowanie

Z metod tablicowych korzysta się na każdym kroku. Łatwość rozwiązywania powszechnie występujących problemów, zdecydowanie wynagradza wysiłek poniesiony podczas nauki.

Pamiętam, że początki z programowaniem funkcyjnym były dla mnie szczególnie trudne. Jeżeli właśnie stawiasz pierwsze kroki, to mam nadzieję, że ten wpis rozjaśnił Twoje wątpliwości. Jeżeli masz jakiekolwiek pytania, z checią udzielę odpowiedzi w komentarzach.

Zachęcam do udostępniania artykułu na Facebooku, Twiterze oraz Linkedin. Nie bądź sknera, podziel się wiedzą z znajomymi ;).

Tym optymistycznym akcentem, kończymy przygodę z serią Powtórka przed ReactJS. Od dwóch tygodni stawiam swoje pierwsze kroki z tą biblioteką. Póki co, jestem naprawdę zadowolony.

Co najważniejsze, nie mam żadnych problemów ze zrozumieniem przyswajanego materiału. Wszystkie informacje, które przekazywałem Wam we wpisach z tej serii, znajdują praktyczne zastosowanie. Posiadana wiedza o JS pozwala w pełni skupiać się na nowych możliwościach oferowanych przez Reacta. Misja powtórkowa zakończona sukcesem.

Kolejna seria będzie nosiła nazwę ‚Pierwszy projekt w ReactJS’. Jak nietrudno się domyślić, będziemy wspólnie budowali prostą aplikację od podstaw. W każdym wpisie będę skupiał się na omawianiu konkretnego zagadnienia i pokazywał jego praktyczne zastosowanie w budowanym projekcie. Jako że przygotowywane przeze mnie kompilacje praktycznych zadań cieszyły się szczególną popularnością, to możecie się ich spodziewać również w nadchodzącej serii.

Co do terminu pojawienia się najbliższego wpisu, obstawiałbym drugą połowę stycznia. Widmo nadchodzącej sesji ogranicza czas, jaki mogę przeznaczyć na pisanie. Wasza cierpliwość będzie wynagrodzona.

Dzięki za wspólne przygotowanie do podróży, czas wyruszyć na reactową przygodę!

Zdjęcie tytułowe autorstwa: unsplash-logoJason Leung

Marcin Czarkowski

Cześć! Ja nazywam się Marcin Czarkowski, a to jest AlgoSmart - blog, na którym dzielę się wiedzą o React.js, JavaScript oraz CSS.

  • Tomasz Sochacki

    Fajny wpis. Wg mnie warto tylko dodać, że metody map, reduce itp. owszem nie zmieniają tablicy „bazowej”, ale jest jedna metoda która to robi – Array.prototype.sort:

    const arr1 = [2,5,1];
    const arr2 = arr1.sort( ( a, b ) => a-b );

    arr2; //[1, 2, 5]
    arr1; //[1, 2, 5]

    Warto o tym pamiętać jeśli w jakimś projekcie używa się sortowania elementów tablic.
    Powodzenia w blogowaniu o React, fajnie że coraz więcej o tym w polskim świecie blogowym 🙂

  • Dzięki za ciekawy artykuł. Pamiętam jak poznawałem trójcę map, filter i reduce. Dziś ciężko mi sobie wyobrazić pisanie kodu JS bez nich.