Programowanie funkcyjne, jest paradygmatem programowania, w którym nacisk położony jest na pisanie funkcji. Taka definicja sama w sobie nie mówi zbyt wiele. Za programowaniem funkcyjnym kryje się kilka ważnych zasad i w tym artykule postaram się opisać jedną z nich, która skupia się na pisaniu czystych funkcji.
Zachęcam do przeczytania, bowiem przedstawię dużo przykładów z kodem napisanym funkcyjnie w sposób zgodny z tym paradygmatem i w sposób niezgodny z założeniami programowania funkcyjnego. Do każdego przykładu dodam krótki komentarz, aby każdy zrozumiał na czym polega pisanie czystych funkcji i jakie korzyści można dzięki takiemu podejściu uzyskać.
Artykuły dotyczące programowania funkcyjnego celowo zostaną podzielone na części, aby szczegółowo zapoznać się z głównymi zasadami programowania funkcyjnego. Ten artykuł jest pierwszym z nich…
Czyste funkcje – zasady
O czystej funkcji możemy powiedzieć wtedy, kiedy spełnione są następujące zasady:
- nasz wynik, który funkcja zwraca zależy tylko od parametrów wejściowych,
- czyste funkcje nie korzystają również z żadnych zmiennych globalnych,
- czyste funkcje nie zmieniają danych zewnętrznych, zarówno tych globalnych jak i danych, które otrzymały jako parametr funkcji (np. obiekt czy tablica, które „przyszły” z zewnątrz),
- czyste funkcje nie posiadają również żadnych „side-effectów” (czym są, opiszę na koniec artykułu),
- dla wskazanych parametrów wejściowych, zawsze zwrócą jeden, ten sam wynik,
- czysta funkcja musi zawsze przyjmować argument i czysta funkcja musi zawsze coś zwracać.
Część powyższych warunków zawiera się w innych warunkach, jednak postanowiłem wypisać je oddzielnie, aby wszystko było jasne i łatwiejsze do zrozumienia. Jeżeli nadal to wszystko brzmi trochę enigmatycznie, z miłą chęcią zaproszę do przykładów… 🙂
Przykład czystej funkcji – wynik zależy tylko od parametrów wejściowych:
let valueInput = 3; let addInput = 2; const result = (value, add) => { return value + add; } const pureResult = result(valueInput, addInput); console.log(pureResult); // 5 console.log(valueInput); // 3 console.log(addInput); // 2
Dlaczego ta funkcja jest czysta?
Zauważmy, że wynik obliczany jest na podstawie parametrów wejściowych, czyli wartości valueInput
i addInput
. Wewnątrz tej funkcji nie korzystamy z żadnych zmiennych globalnych. Dodatkowo nie modyfikujemy wartości, które funkcja otrzymała. Dzięki takiej budowie kodu, dla wskazanych parametrów value
i add
, zawsze wynik wyjściowy będzie taki sam.
Korzystanie z wartości, które nie są przekazane do funkcji – funkcja nie jest czysta:
let value = 3; let addInput = 2; const result = (add) => { return value + add; // UWAGA!!! } const nonpureResult = result(addInput); console.log(nonpureResult); // 5 console.log(value); // 3 console.log(addInput); // 2
Wynik jest taki sam, więc wydaje się że wszystko jest ok. Dlaczego zatem funkcja nie jest czysta i która zasada została złamana? Mimo, że wynik końcowy jest taki sam to w funkcji korzystamy ze zmiennej, która nie trafiła do funkcji jako parametr, tylko jest pobierana „z zewnątrz”. Tutaj pojawia się sytuacja, że funkcja wywołana z tym samym parametrem wejściowym może zwrócić nam inny wynik. Jak to możliwe?
let value = 3; const result = (add) => { return value + add; } const resultFirst = result(2); console.log(resultFirst); // 5; value = 5; const resultSecond = result(2); console.log(resultSecond); // 7
Wywołaliśmy tą samą metodę result()
z parametrem wejściowym „2”, otrzymując inny wynik. Spróbujmy wyobrazić sobie testy takiej metody. Oczywiście tutaj mamy do czynienia z bardzo prostym przypadkiem. Weźmy teraz pod uwagę sytuację, w którym nasza funkcja korzysta z kilku parametrów wejściowych oraz kilku wartości zewnętrznych/globalnych, które mogą być jeszcze zmieniane przez inne funkcje… Debugowanie, rozwój, zmiany w kodzie na pewno nie będą łatwe.
Zmiana danych przekazanych w parametrze – przykład funkcji, która nie jest czysta:
let user = { name: 'Lukasz', website: null }; const website = (userObject) => { userObject.website = 'htts://www.magicweb.pl'; return userObject; } const userNew = website(user); console.log(userNew); // { name: 'Lukasz', website: 'htts://www.magicweb.pl' } console.log(user); // { name: 'Lukasz', website: 'htts://www.magicweb.pl' }
Co robi nasz kod i dlaczego również ta funkcja nie jest „pure”? Przede wszystkim w funkcji został zmodyfikowany obiekt user
. Mimo, że w funkcji korzystamy z nazwy parametru userObject
(co mogłoby wskazywać, że to przecież inny obiekt) to i tak operujemy na obiekcie user
, ponieważ obiekt ten jest przekazywany przez referencję.
Na temat różnic w przekazywaniu przez wartość i referencję pisałem tutaj – https://www.magicweb.pl/programowanie/frontend/typy-proste-typy-referencyjne/
console.log(userNew === user); // true
Zmiana danych poza funkcją – przykład funkcji, która nie jest czysta:
let productList = []; const addProduct = (name) => { productList.push(name); return productList; } addProduct('milk'); addProduct('chocolate'); console.log(productList); // [ 'milk', 'chocolate' ]
W jaki sposób możemy poprawić powyższą funkcję? Wydaje się, że przekazanie tablicy productList
jako parametr rozwiąże problem… Niestety, jest to analogiczna sytuacja jak w poprzednim przykładzie z obiektem. Funkcje, obiekty i tablice są przekazywane w JS przez referencję, dlatego tutaj musimy utworzyć kopię i działać na tej kopii, tak aby nie zmieniać oryginalnej zawartości…
Źle:
let productList = []; const addProduct = (productListInput, name) => { productListInput.push(name); // UWAGA! nadal modyfikujemy productList return productListInput; }
Dobrze:
let productList = ['milk']; const addProduct = (productListInput, name) => { return [...productListInput, name]; // DOBRZE! została utworzona kopia } const newProductList = addProduct(productList, 'fish'); console.log(productList); // [ 'milk' ] console.log(newProductList); // [ 'milk', 'fish' ]
W metodzie addProduct()
poprzez "..."
, czyli spread operator – https://www.magicweb.pl/programowanie/frontend/spread-operator-spread-syntax/ – dokonaliśmy skopiowania wartości tablicy do nowej tablicy. Dzięki temu stara tablica productList
pozostała niezmieniona, a my działamy na nowej tablicy newProductList
.
Używanie losowych wartości – przykład funkcji, która nie jest czysta:
const nonpureFn = (math) => { return math * Math.random(); } const result = nonpureFn(3); console.log(result); // 0.3208688650115612 const resultTwo = nonpureFn(3); console.log(resultTwo); // 1.7227692539870971
W powyższym przykładzie skorzystaliśmy z funkcji Math.random()
, która generuje wartość w sposób losowy. Nasz wynik nie jest uzależniony tylko od wartości wejściowych, ale jest generowany w sposób losowy. Powinniśmy zatem unikać używania Math.random()
, Date.now()
i innych generatorów danych losowych, wewnątrz funkcji.
Brak wartości wejściowych i wyjściowych -przykład funkcji, która nie jest czysta:
let products = []; const addMilkToProducts = () => { products.push('milk'); } addMilkToProducts(); console.log(products); // [ 'milk' ]
Funkcja nie przyjmuje żadnych parametrów, funkcja nie zwraca nic, dodatkowo funkcja modyfikuje dane zewnętrzne… W ogóle ciężko mi znaleźć dobry przykład funkcji, która miałaby coś robić, gdy nie przyjmuje parametrów, nic nie zwraca, a dodatkowo powinna z założenia być „czysta”.
Ktoś może napisać, że przecież można zrobić funkcję, która pobiera dane i nie musi nic przyjmować (funkcja bez parametru nie będzie czysta)…
W tym momencie możemy przejść do tematu związanego z „side-effect”. Stąd też taki, a nie inny przykład, więc zapraszam do dalszego czytania.
Side effect
Celowo postanowiłem wydzielić temat side effect do oddzielnej sekcji
Zacznijmy od tego, czym są side effect’y i kiedy występują? W skrócie, side effects występują wtedy, kiedy funkcja modyfikuje lub operuje na stanie poza wywoływaną funkcją. Przykłady:
- kiedy modyfikujemy zmienną globalną w funkcji lub w innej funkcji (wywoływanej przez tą funkcję),
- logowanie do konsoli również jest side effect’em,
- zapis do pliku lub pobieranie danych z pliku,
- pobieranie lub zapisywanie czegoś do bazy (np. poprzez API),
- wywoływanie innych funkcji, które posiadają side effecty,
- kiedy operujemy na drzewie DOM,
- wywoływanie zewnętrznych procesów.
Jeżeli ktoś śledził przykłady kodu z artykułu, to na pewno zauważył, że w niektórych z nich już pojawiały się side effecty. Myślę, że powyższych punktów nie trzeba rozwijać, bo powinny być jasne do zrozumienia po zapoznaniu się z wszystkimi przykładami z artykułu.
Jak uniknąć niepotrzebnych side-effectów i pisać możliwie czyste funkcje?
W mojej opinii, powinniśmy uważnie śledzić pisany kod i w sytuacji, kiedy korzystamy z innej wartości niż te, które są przekazane do funkcji, powinno nam się zapalić czerwone światło.
Pamiętajmy również o uważnym działaniu na typach przekazywanych przez referencję, aby niepotrzebnie nie modyfikować wartości.
Możemy również spojrzeć na nasze funkcje pod kątem pisania testów. Funkcje pisane w taki sposób powinny być stosunkowo łatwe do testowania i łatwe do pokrycia testami. To jest kolejny benefit z pisania czystych funkcji. Poza przewidywalnością algorytmu i wartości zwracanej, uzyskujemy łatwość debugowania kodu. Do czystych funkcji łatwiej napisać testy, ponieważ dla tych samych parametrów wejściowych powinniśmy zawsze otrzymać jeden, ten sam wynik.
Co z side effectami, których nie da się uniknąć?
Myślę, że w nowoczesnych aplikacjach uniknięcie side effectów w całej aplikacji jest w zasadzie niemożliwe, natomiast posiadanie świadomości istnienia takiego mechanizmu pozwoli na pisanie lepszego i bardziej przewidywalnego kodu.
O czym warto pamiętać
W związku z tym, że przy użyciu czystych funkcji nie powinniśmy modyfikować danych, które do funkcji przychodzą, wywołując funkcję musimy pamiętać o przypisaniu rezultatu do jakiejś zmiennej. Wyobraźmy sobie, że chcemy stworzyć funkcję, która zwiększa licznik logowań.
let login_counter = 0; const incrementLoginCounter = (current_login_counter) => { return current_login_counter + 1; } incrementLoginCounter(login_counter); incrementLoginCounter(login_counter); incrementLoginCounter(login_counter); console.log(login_counter); // rezultat - nadal 0
W tej sytuacji, tak jak wspomniałem wyżej musimy pamiętać o przypisaniu rezultatu do zmiennej.
let login_counter = 0; const incrementLoginCounter = (current_login_counter) => { return current_login_counter + 1; } login_counter = incrementLoginCounter(login_counter); login_counter = incrementLoginCounter(login_counter); login_counter = incrementLoginCounter(login_counter); console.log(login_counter); // rezultat - 3
Pamiętając założenia programowania funkcyjnego, w podobny sposób musimy operować na wartościach przekazywanych przez referencję. Tutaj musimy pamiętać też o shallow copy.
let login_list = [ {user: 'grazyna', login: 0}, {user: 'janusz', login: 1}, ]; const incrementLogin = (list) => { return list.map((user) => (user.login = user.login + 1)) } incrementLogin(login_list); incrementLogin(login_list); console.log(login_list); // [ { user: 'grazyna', login: 2 }, { user: 'janusz', login: 3 } ]
Mimo, że funkcja .map()
zwraca nam nową tablicę, wewnątrz niej znajdują się obiekty. To sprawia, że finalnie (pomimo zwrócenia nowej tablicy poprzez .map()
) zmodyfikowaliśmy obiekty wewnątrz login_list
. Rozwiązaniem będzie użycie spread operatora wewnątrz funkcji .map()
return list.map((user) => ({...user, login: user.login + 1}))
Myślenie funkcyjne…
Na zakończenie tego artykułu dodam, że w książce „Mastering JavaScript Functional Programming – Second Edition”, Federico Kereki napisał aby nie wpadać w pułapkę pisania kodu zgodnie z zasadami programowania funkcyjnego za wszelką cenę. Kod napisany funkcyjnie nie będzie dobry tylko dlatego, że jest napisany funkcyjnie i można napisać zły kod zarówno używając functional programming, jak również innych technik (Rozdział 1, strona 10).
Dziękuję, że udało Ci się przebrnąć przez masę przykładów oraz opisów i dotrzeć do końca artykułu. Niedługo pojawi się kolejny artykuł dotyczące programowania funkcyjnego, dlatego bądźcie czujni… 😉
Źródła:
Obraz: https://unsplash.com/photos/SnKfmC1I9fU
„Beginning Functional JavaScript Functional Programming with JavaScript Using EcmaScript 6” – Anto Aravinth
„Beginning Functional JavaScript Uncover the Concepts of Functional Programming with EcmaScript 8 Second Edition” – Anto Aravinth, Srikanth Machiraju
„Mastering JavaScript Functional Programming” – Federico Kereki