Na czym polega programowanie funkcyjne

Programowanie funkcyjne korzysta z możliwości funkcji, aby stworzyć pewien poziom abstrakcji ułatwiający czytelność i łatwość pisania kodu. Najważniejszym elementem będzie jednak łączenie małych i prosto nazwanych funkcji w większe struktury. Czytając kod napisany funkcyjnie, nie będziesz musiał przetwarzać w głowie każdej modyfikacji danych, aby go zrozumieć. Funkcje, z których skorzystasz będą w jasny sposób opisywały jakie operacje są wykonywane na argumentach.

Skupiając się na czytelności kodu ułatwiamy pracę programistom, którzy bedą pracowali nad rozwojem aplikacji w przyszłości. Pewnie niejednokrotnie zdażyło ci się spojrzeć nad czyjś kod (lub nawet twój własny) i zastanawiać się co autor miał na myśli. Właśnie takich sytuacji chcemy uniknąć. Kod pisany w sposób funkcyjny powinien być zrozumiały, bez potrzebny dokładnej analizy co się dzieje z programem krok po kroku. Widząc funkcję sortBy('name') od razu wiemy czym będzie się zajmować.

Spójrzy na operację filtrowania oraz agregowania danych na temat zakupów dokonanych w aplikacji:

const filterField = 'name'
const filterValue = 'Tom'
let activeUsersCount = 0
let averageCartValue = 0

for (let i = 0; i < users.length; i++) {
  if (users[i][filterField] === filterValue) {
    users.splice(i, 1);
  }
  if (users[i].active) {
    activeUsersCount++
  }
  averageCartValue += users[i].cartValue
}
averageCartValue = averageCartValue / users.length

Kod napisany w ten sposób jest mało czytelny. Zawiera też kilka błędów, które na pierwszy rzut oka ciężko zauważyć. Nawet dla doświadczonych osób setki linijek podobnego kodu mogą stanowić nielada wyzwanie przy debugowaniu.

Dla kontrastu kod napisany funkcyjnie dla tej samej operacji wyglądałby tak:

const filterField = 'name'
const filterValue = 'Tom'

function isEqual(item1) {
  return item2 => item1 === item2
}

function size(item) {
  return item.length
}

function get(key) {
  return obj => obj[key]
}

function sum(acc, value) {
  return acc + value
}

const filteredUsers = users.map(get(filterField)).filter(isEqual(filterValue))
const activeUsersCount = size(users.map(get('active')).filter(Boolean))
const cartValueSum = users.map(get('cartValue')).reduce(sum, 0)
const averageCartValue = cartValueSum / size(users)

W tym przykładzie każdy element obliczeń jest napisany osobno. Aby zrozumieć działanie kodu możemy odseparować od siebie odpowiednie elementy zamiast myślec o nim jako o całości. Funkcje takie jak map, filter operujące na tablicy używają małych i prostych funkcji do wykonywania operacji.

Elementem przemawiającym na niekorzyść będzie wydajność. Jednak dopóki zmniejszona wydajność aplikacji nie jest zauważalna oraz nie powoduje problemów w korzystaniu ze strony nie powinniśmy poświęcać dla niej czytelności kodu, z pewnością nie w takim przypadku.

Dodatkowo większość funkcji pomocniczych takich jak, isEqual, size i get jest dostępna w gotowych bibliotekach, np. ramda czy lodash. Posiadają one bardzo dobrą dokumentacji, przykłady użycia oraz testy.

Programowanie funkcyjne ma bardzo ważną zasadę, którą chciałbym rozwinąć. Jest to wykorzystanie jedynie czystych funkcji do wykonywania operacji.

Czyste funkcje

Aby funkcję można było nazwać czystą musi ona spełniać dwa warunki:

  • funkcja dla danego argumentu zawsze musi zwracać ten sam wynik
  • funkcja nie może powodować efektów ubocznych

Spójrzmy na poniższy fragment kodu:

let number = 1

function addToNumber(x) {
  return number + x
}

const addedNumbers = addToNumber(5) // 6

Funkcja addToNumber ma za zadanie dodać do zmiennej number dowolną liczbę podaną jako argument. Nie można jej nazwać czystą, ponieważ korzysta z zewnętrznej zmiennej.

Dla argumentu 5 może ona zwrócić tak na prawdę dowolny wynik - wystarczy, że wartość number się zmieni.

Czysta wersja tej funkcji wygląda tak:

function add(x, y) {
  return x + y
}

const addedNumbers = add(1, 5) // 6

Wynik funkcji add jest zależny wyłącznie od jej argumentów.

W przypadku bardziej rozbudowanych operacji czyste funkcje mogą również korzystać z innych funkcji. Jednak, aby nadal można było nazywać je czystymi - funkcje z których korzystają również muszą być czyste.

Spójrzmy na poniższy fragment kodu:

function add(x, y) {
  return x + y
}

function average(x, y) {
  return add(x, y) / 2
}

function getUsersAverageAge(u1, u2) {
  return average(u1.age, u2.age)
}

const user1 = { age: 20 }
const user2 = { age: 40 }
const averageAge = getUsersAverageAge(user1, user2) // 30

Definiujemy trzy funkcje, które korzystają ze swoich możliwości. Dzięki temu, że add jest funkcją czystą - average oraz getUsersAverageAge też mogą być nazwane funkcjami czystymi.

Tworzenie funkcji, które korzystają tylko z argumentów do obliczenia zwracanej wartości daje nam większą pewność, że funkcje wykonuje jedynie swoje główne zadanie oraz zmniejsza prawdopodobieństwo występowania błędów.

Większe projekty będą zawierały dziesiątki funkcji, które mają pomóc w modyfikacji danych lub wykonywaniu obliczeń. W przypadku funkcji czystych nie musimy zastanawiać się, w którym miejscu aplikacji funkcja zostanie użyta. Jeśli podamy jej poprawne argumenty, zawsze otrzymamy oczekiwany wynik.

Efekty uboczne

Kolejny warunek, który musi być spełniony przez czyste funkcje to brak efektów ubocznych (side effects).

function pushToArray(element, array) {
  return array.push(element)
}

const oldArray = [1, 2, 3]
const newArray = pushToArray(4, oldArray)

oldArray // [1, 2, 3, 4]
newArray // [1, 2, 3, 4]

Powyższy przykład kodu przedstawia funkcję pushToArray, która powinna dodać nowy element do tablicy. Korzysta ona jednak z metody push, która modyfikuje tablicę przekazaną jako argument.

Nazywane jest to efektem ubocznym, ponieważ oprócz głównego zadania funkcji (dodanie nowego elementu) wykonuje ona dodatkowe czynności wpływające na inne obszary aplikacji. W tym przypadku efektem ubocznym jest zmiana wartości przekazanego argumentu.

async function getUserName(id) {
  const userFromAPI = await getUserFromAPI(id)
  return userFromAPI.name
}

W tym przypadku funkcja również powoduje skutek uboczny - zapytanie do API.

Nie można nazwać jej funkcją czystą, ponieważ nie ma pewności, że przy tych samych argumentach otrzymamy ten sam wynik. Dane zwracane przez API mogą się zmienić.

Inne przykłady efektów ubocznych to:

  • wypisywanie danych do konsoli
  • modyfikacja DOM
  • modyfikacja zmiennych zewnętrznych
  • odczytywanie i zapisywanie danych do pliku
  • korzystanie z timera (setTimeout, setInterval)
  • korzystanie z innych funkcji powodujących efekty uboczne

Po przeczytaniu tej listy nasuwa się pytanie: jak zbudować prawdziwą aplikację, która nie powoduje efektów ubocznych.

Nie można, ponieważ działania aplikacji bez efektów ubocznych nie da się zaobserwować.

Okazuje się, że aplikacje z którymi spotykamy się na co dzień zawsze będą powodowały jakieś efekty uboczne.

Decydując się na programowanie w sposób funkcyjny chcemy, aby nasz kod był łatwy do zrozumienia, a jego działanie przewidywalne.

Dlatego tworząc prawdziwe aplikacje starajmy się pisać jak najwięcej funkcji bez efektów ubocznych. Zapytania do API, czy modyfikacje DOM grupujmy tak, aby osoba czytająca kod od razu zauważyła, że powodują one efekty uboczne i nie są czyste. Można to zrobić za pomocą nazw funkcji, komentarzy w kodzie, czy nawet umieszczając je w osobnym pliku.

Przypuśćmy, że masz za zadanie pobrać dane o użytkowniku, a następnie pokazać jego imię i nazwisko na stronie. Przykładowe rozwiązanie, które od razu nasuwa się na myśl może wyglądać w ten sposób:

async function fetchAndDisplayUser(id) {
  const users = await getUsers(id);
  let name, surname

  for(let i = 0; i < users.length; i++ {
    if (users[i].id === id) {
      name = users[i].name
      surname = users[i].surname
    }
  }
  document.querySelector('.user-name').innerHTML =
    `${userData.name} ${userData.surname}`
}

Tworzenie takich funkcji nie jest niepoprawne. Powyższy kod zadziała i spełni swoje zadanie.

Jednak zgodnie z zasadami programowania funkcyjnego powinniśmy podzielić taką funkcję na kilka mniejszych - każda spełniająca pojedyncze zadanie.

W większych projektach funkcje mogą zawierać dziesiątki, czy setki linijek kodu. Analiza funkcji, która powoduje efekty uboczne oraz mutuje dane może być większym wyzwaniem, w którym nietrudno o pomyłkę.

Gdy już podzielimy naszą funkcje na mniejsze - żródła błędów będziemy szukać najpierw w miejscach gdzie wykonywane są efekty uboczne.

Programowanie funkcyjne daje nam możliwość skupienia naszej uwagi na najważniejszych, krytycznych elementach działania aplikacji.

Powyższy w sposób funkcyjny mógłby zostać napisany tak:

import { prop, join, find } from 'ramda'

function writeToDOM(text, element) {
  element.innerHTML = text
}

const currentUserId = 1
const users = await fetchUsers();
const currentUser = find(user => user.id === currentUserId, users)
const fullUserName = join(' ', [prop('name', currentUser), prop('surname', currentUser)]) 
writeToDom(fullUserName, document.querySelector('.user-name'))

Kod stał się nieco bardziej rozbudowany, mimo to jest czytelniejszy.

Czytając takie nazwy funkcji jak fetchUsers czy writeToDOM od razu zauważamy, że nie operują one tylko na danych przekazanych w argumentach, ale wykonują dodatkowe operacje.

Podsumowanie

Programowanie funkcyjne pozwala na pisanie kodu aplikacji, który jest łatwiejszy do zrozumienia, przetestowania oraz debugowania.

Pisząc czyste funkcje niwelujemy możliwość ich wpływu na inne obszary aplikacji. Dzięki czemu w przypadku szukania błędów możemy skupić się na jednym fragmencie kodu.

Wykorzystywania możliwości programowania funkcyjnego nie będzie polegało jedynie na pisaniu małych, czystych funkcji. Najważniejsze i zarazem najtrudniejsze będzie komponowanie funkcji, aby wspólnie tworzyły całość i spełniały swoją rolę.

Dołącz do Newslettera

A będziesz na bieżąco ze wszystkimi postami na moim blogu.