Programowanie funkcyjne

Spis treści

  1. Programowanie funkcyjne – wstęp
  2. „Odrzućmy operator przypisania”
  3. Łączymy funkcje w łańcuchy (function chaining)
  4. Funkcje wyższego rzędu (higher order functions)
  5. Funkcje czyste (pure functions)
  6. Domknięcia (closures)
  7. Rozwijanie (currying)
  8. Zalety i wady programowania funkcyjnego
  9. Co dalej?

Programowanie funkcyjne – wstęp

Programowanie funkcyjne wróciło do łask kilka lat temu, lecz warto wspomnieć na wstępie, że był to pierwszy paradygmat programowania jaki powstał. Można sądzić, że najpierw ludzie „wynaleźli” programowanie proceduralne, później obiektowe, a dopiero potem przyszedł czas na programowanie funkcyjne. Było zupełnie odwrotnie. Paradygmat funkcyjny był pierwszy.

Ciekawostką jest to, że paradygmaty programowania powstają inaczej niż można by było przypuszczać. Żaden z paradygmatów proceduralny czy obiektowy, nie dodał czegoś nowego. One właściwie coś zabierają / nakładają ograniczenia. Bardzo fajnie opowiada o tym Uncle Bob, polecam obejrzeć:

„Odrzućmy operator przypisania”

W przypadku programowania funkcyjnego został wyrzucony operator przypisania.

Ale jak to? Po co? Komu to przeszkadza? Bez przypisania nie jesteśmy w stanie zmienić stanu! Otóż własnie dlatego. Ma to ogromną zaletę ponieważ, gdy chcemy przetwarzać jakaś operację równolegle na wielu rdzeniach już nie musimy się martwić o dostęp do „sekcji krytycznej”.

Zmiana stanu to po prostu zmiana wartości zmiennej czyli:

a = 0
a = 1
a = 2

aby nie dopuścić do modyfikacji wartości zmiennej a powinniśmy je wrzucać w nowe zmienne:

a0 = 0
a1 = 1
a2 = 2

W tym momencie nie musimy się zmagać z problemami współbieżności. Deadlocki czy też dostęp do sekcji krytyczej przestaje istnieć. Dlatego oprogramowanie napisane w sposób funkcyjny lepiej się skaluje i działa szybciej.

Łączymy funkcje w łańcuchy (function chaining)

W programowaniu funkcyjnym cały przepływ informacji budujemy z funkcji. Najprościej można to opisać w taki sposób: „weź dane, wrzuć do funkcji, przerzuć wynik do kolejnej funkcji następnie weź ten wynik i przekaż dalej do kolejnej funkcji itd„. Jest to tzw. „function chaining”. W pseudo-kodzie można by to było tak zapisać:

array
    .filter(foo(x) { return x.age > 10  }) # filtrujemy tablicę
    .order (foo(x) { return x.firstname }) # sortujemy tablicę
    .select(foo(x) { return x.lastname  }) # zwracamy tablicę

# wynikiem są dane przefiltrowane przez warunek age > 10. 
# Następnie są posortowane i zwrócone jako tablica z nazwiskami.
# Trzeba nadmienić, że każde wywołanie funkcji zwraca zupełnie
# nową tablicę. Nigdzie nie modyfikujemy stanu obecnej.

W jakich językach możemy pisać funkcyjnie? Obecnie prawie wszystkie najnowsze języki dają możliwość programowania funkcyjnego. Oczywiście nie są to 100% języki funkcyjne, lecz zawierają jego elementy. Bez problemu możemy pisać w taki sposób w C#, javie, ruby, pythonie czy w javascriptcie. Jeśli ktoś chciałby programować w typowych językach funkcyjnych to powinien się zaintersować Haskelem, Closurem czy F#.

W przykładach nie będziemy się opierać na żadnej konkretnej składni języka, bo nie ma takiej potrzeby. Idea jest jedna, a sposobów zapisu wiele.

Funkcje wyższego rzędu (higher order functions)

Funkcje wyższego rzędu czyli higher order functions można wytłumaczyć w jednym zdaniu:

  • Funkcja przyjmująca jako parametr inna funkcję i / lub zwracająca na wyjściu również funkcję
function foo1(a, b) { return foo2(a, b) }
function foo1(a, b, foo2) { return foo2(a, b)}

Możemy funkcję traktować jak zmienną, czyli utworzymy ją sobie jakby na boku i możemy przekazać ją jako parametr.

f = foo(a, b) { return a + b }
function foo2(a, b, f) { return f(a, b) } 

Wiele języków pozwala na tworzenie funkcji anonimowych (lambd – funkcji bez nazw). Nie należy się ich bać, tylko śmiało używać, ponieważ są niezwykle wygodne. Powyższy przykład możemy przepisać używając lambdy:

function foo2(a, b, f) { return f(a, b) }

foo2(10, 20, (a, b) => { return a + b })
# wywołanie funkcji foo2 i przekazanie
# lambdy / anonimowej funkcji

Funkcje czyste (pure functions)

Tutaj muszą być spełnione dwa warunki aby można było uznać funkcję za czystą.

  • Funkcja zawsze zwróci ten sam wynik
  • Nie ma efektów ubocznych

Brzmi dziwnie, lecz to jest również prosta idea. Zobaczmy dwa przykłady.

Funkcja NIEczysta (inpure function) może wyglądać tak:

v = 10; # uwaga! zmienna przechowuje stan = 10

function foo(a) {
  v += 10;
  return v + a;
}

foo(10); #=> 30
foo(10); #=> 40

widać, że za każdym razem gdy wywołamy funkcję z tym samym parametrem zwróci inny rezultat. Istnieje tutaj stan (zmienna v)/wartość współdzielona. Jeślibyśmy chcieli uruchomić tę funkcję współbieżnie musielibyśmy martwić się o synchronizację i dostęp sekcji krytycznej (znów ukłony w stronę zmiennej v).

Zatem jak można się domyśleć funkcję czystą możemy przedstawić w taki sposób:

foo(a) { return a + 10; }

foo(10); #=> 20
foo(10); #=> 20

Proste. Funkcja nie ma żadnych efektów ubocznych. Uruchomienie jej za każdym razem z tym samym parametrem zwróci zawsze tę samą wartość.

Daje nam to ogromną zaletę podczas przetwarzania danych. Skoro funkcja zawsze zwróci tę samą wartość dla konkretnego argumentu/ów funkcji znaczy, że nie musimy ponawiać obliczeń dla tego właśnie konkretnego argumentu. Wystarczy zwrócić wynik.

Higher order functions oraz pure functions są to dwie najważniejsze cechy programowania funkcyjnego. Warto natomiast przedstawić jeszcze:

  • Domknięcia (closures)
  • Rozwijanie (currying)

Domknięcia (closures)

Czym jest domknięcie? Każda funkcja ma własny zasięg zmiennych. Dzięki temu wywołana funkcja może korzystać ze swojego zasięgu jak i zasięgu zmiennych innej funkcji. Można to zgrabniej ująć: funkcja zapamiętuje środowisko w jakim została wywołana.

a = 10;
foo(b) {
  c = 30 # korzystamy tutaj ze swojego zasięgu
  return a + b + c; # pobieramy wartość ze zmiennej `a`, 
  # która nie należy do zasięgu funkcji foo
}

foo(20) #=> 50

Rozwijanie (currying)

Currying można zdefiniować jako transformację wieloparametrowej funkcji, na wywołania z jednym argumentem. Użyjemy przykładu aby zobrazować o co chodzi:

function foo(a) {
   return function(b) { 
       return function(c) {
          return a + b + c
       }
   }
}
foo(1)(2)(3) #=> 3

Co tu się właściwie stało? Rozłożymy to na czynniki pierwsze aby pokazać działanie krok po kroku:

f2 = foo(1) # teraz pod f2 mamy funkcję function(b)
f3 = f2(2) # f3 to teraz funkcja function(c) z `return a + b`

f3(3) #=> 1 + 2 + 3 => 6 # dopiero w tym momencie 
# następuje wykonanie funkcji f3 `a + b`

foo(1)(2)(3)
# całość można własnie skrócić do takiego zapisu.
# Najpierw pierwsza funkcja, zwraca kolejną funkcje z ustawionym
# parametrem, po czym kolejna zostaje zwrócona aż dochodzi 
# do `a + b + c`

Do czego możemy wykorzystać currying? Na przykład do tworzenia pre-definiowanych funkcji. Np. jeśli mamy 2 rodzaje obniżek cen w systemie, to możemy sobie utworzyć prosty kod, który dostarczy takie zachowanie:

function getDiscount(discount) {
   return function(price) {
      return price - discount
   }
}

# teraz możemy sobie zdefiniować funkcje dla różnych klientów

getDiscountForNewMembers = discount(10)
getDiscountForOldMembers = discount(40)

# jesli chcemy wyliczyć zniżkę dla nowych klientów użyjemy
getDiscountForNewMembers(100) # zwróci 90, ponieważ cena to 100 
# natomiast zniżka 10 zdefiniowana wyżej

getDiscountForOldMembers(100) # zwróci 60 natomiast to na 
#zwróci nam obniżkę dla starych klientów

Zalety i wady programowania funkcyjnego

Zalety

  • Z racji tego, że czyste funkcje (pure functions) nie zmieniają stanu i całkowicie polegają na danych jakie przyjmują, jest je bardzo łatwo zrozumieć
  • Możliwość uruchomienia kodu równolegle, bez zbędnego martwienia się o problemy współbieżności
  • Można wiele rzeczy wyrazić zgrabniej i zwięzlej niż w innych językach
  • Dzięki temu, że funkcje są izolowane ułatwia nam to znajdowanie błędów w kodzie. Nie mamy tutaj żadnych ukrytych stanów i „efektów ubocznych”. Możemy wziąć daną funkcję na warsztat i przetestować ją

Wady

  • Wiele rzeczy jesteśmy w stanie załatwić bardzo łatwo rekurencją, lecz jest niestety bardzo kosztowna z punktu widzenia procesora jak i pamięci. Rekurencja wymaga również dodatkowego zrozumienia przez programistę
  • Musimy zrezygnować z obsługi np. plików (IO). Operacje wejścia/wyjścia to skutek uboczny. Modyfikując plik zmieniamy jego stan. W takich przypadkach musimy odizolować część aplikacji odpowiedzialnej za IO
  • Brak możliwości zmiany stanu ma wadę z punktu widzenia wydajności. Jeśli mamy jakąś daną to musimy utworzyć zupełnie nową już ze zmienioną wartością.

Co dalej?

W następnej części zajmiemy się przestawieniem praktycznego zastosowania programowania funkcyjnego. Zobaczymy jak zgrabnie można napisać w pełni działający, szybki a przede wszystkim łatwy do zrozumienia kod.