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