ROZDZIAŁ 2
Myślenie funkcyjne dla imperatywnego umysłu
Michael Bevilacqua-Linn
Dokładne określenie, czym w istocie jest programowanie funkcyjne, może być trudne.
Nie jest to łatwe, gdyż termin ten, choć dobrze zdefiniowany teoretycznie, w praktyce wskazuje kilka różnych, choć powiązanych ze sobą idei. Jeśli porozmawiamy z hakerem Closure, zapewne dużo będzie mówił o makrach. Programista w Haskellu może mówić o monadach, a programista w Erlangu o aktorach.
Są to różne pojęcia. Makro dają programistom niezwykle silne możliwości metaprogramowania, monady pozwalają na bezpieczne modelowanie zmian stanu, aktory zaś zapewniają rozbudowany sposób realizacji programowania rozproszonego i współbieżnego.
Mimo to wszystkie te różne idee uważa się za cechy języków funkcyjnych. Mnogość pojęć może nieco utrudnić określenie, o co w tym wszystkim chodzi. A jednak podstawowa idea jest prosta.
Wszystko sprowadza się do funkcji
W swojej istocie programowanie funkcyjne dotyczy programowania z czystymi funkcjami, bez efektów ubocznych.
Oto czysta funkcja:
f(x) = <raise power="2">x</raise>
A więc ta jest czysta:
public int incrementCounter(int counter) {
return counter++;
}
a ta nie jest:
private int counter = 0;
public void incrementMutableCounter() {
counter++;
}
Pierwsze dwa przykłady zwiększają licznik, zwracając nową wartość całkowitą, która jest o jeden większa od przekazanej liczby całkowitej. Trzeci przykład robi to samo, ale zmienia stan, który może być wspólny dla wielu fragmentów programu.
Definicja - funkcja taka jak incrementCounter, która nie jest oparta na zmianie stanu, jest nazywana funkcją czystą. A ta jej czystość daje wiele korzyści. Jeśli na przykład mamy funkcję czystą, która wykonuje jakieś kosztowne obliczenia, możemy zoptymalizować nasz program, wywołując tę funkcję tylko raz i przechwytując wynik - czyli stosując technikę zwaną memoizacją.
Czyste funkcje ułatwiają też analizę programów. Program obiektowy to graf obiektów, gdzie każdy ma wiele mutowalnych stanów. Modyfikacja stanu jednego z obiektów może prowadzić do modyfikacji stanu innego, który może się znajdować w grafie w odległości wielu węzłów. W programie, który ma tylko czyste funkcje, takie działanie na odległość jest niemożliwe.
To jest uproszczony opis programowania funkcyjnego.
Chodzi o niemutowalność
Niestety, pełna czystość nie ma się najlepiej w świecie rzeczywistym. Czyste funkcje mogą dobrze modelować pewne dziedziny, ale do innych nie pasują. Kompilatory to czyste funkcje. Wyszukiwanie w Google nie jest czyste.
Praktyczne języki programowania funkcyjnego kładą nacisk na niemutowalność i czystość funkcyjną, ale muszą mieć też możliwość modelowania zmieniającego się świata, który nie zapewnia pełnej czystości funkcji. W języku Haskell, który jest zapewne najbardziej ścisłym językiem funkcyjnym, można modelować zmiany, wykorzystując monady, zachowując przy tym ścisłą czystość.
Inne języki funkcyjne mają inne techniki minimalizowania i kontroli zmiany stanu, które nie są tak ścisłe jak te z Haskela. Clojure na przykład używa systemu programowej pamięci transakcyjnej w połączeniu ze zbiorem typów referencyjnych oraz bardzo sprytnych niemutowalnych struktur danych, aby utrzymać wysoki poziom czystości, a jedocześnie pozwolić programistom na radzenie sobie w zmieniającym się świecie.
Chodzi o sposób myślenia
Tak więc w pierwszym przybliżeniu programowanie funkcyjne to programowanie z czystymi funkcjami i niemutowalnym stanem, w przeciwieństwie do programowania imperatywnego, które silnie zależy od mutowalności. Wokół tej niemutowalnej podstawy mamy zbiór technik językowych i cech, które zastępują mutowalne techniki imperatywne. Ich analiza da nam lepsze poczucie, czym jest myślenie i programowanie funkcyjne.
Popatrzmy na prosty przykład - filtrowanie listy tak, aby zawierała tylko liczby nieparzyste.
public List<Integer> filterOdds(List<Integer> list) {
List<Integer> filteredList = new ArrayList<Integer>();
for(Integer current : list) {
if(1 == current % 2) {
filteredList.add(current);
}
}
return filteredList;
}
To jest kod imperatywny. Iterujemy po liście i sprawdzamy dla każdego elementu, czy liczba jest nieparzysta, obliczając jej modulo. Moglibyśmy napisać jej zawartość nieco jaśniej, gdybyśmy wyciągnęli sprawdzenie do funkcji pomocniczej i nadali jej nazwę.
public List<Integer> filterOdds(List<Integer> list) {
List<Integer> filteredList = new ArrayList<Integer>();
for (Integer current : list) {
if (isOdd(current)) {
filteredList.add(current);
}
}
return filteredList;
}
private boolean isOdd(Integer integer) {
return 1 == integer % 2;
}
Co należy zrobić, jeśli chcemy teraz utworzyć funkcję, która pozwoli nam odfiltrować liczby parzyste zamiast nieparzystych? Jedyny fragment kodu, który trzeba zmienić, to wywołanie isEven zamiast isOdd.
public List<Integer> filterEvens(List<Integer> list) {
List<Integer> filteredList = new ArrayList<Integer>();
for (Integer current : list) {
if (isEven(current)) {
filteredList.add(current);
}
}
return filteredList;
}
private boolean isEven(Integer integer) {
return 0 == integer % 2;
}
To działa, ale popełniliśmy jeden z kardynalnych grzechów kodowania. Większość filterOutEvens została wycięta i wklejona z filterOutOdds. W istocie chcemy mieć filtr (filter), który może korzystać z dowolnego fragmentu kodu, aby wykonać swoje filtrowanie.
Zastanówmy się, jak możemy dokonać tego w języku Java. Zarówno isOdd, jak i isEven pobierają jeden argument i zwracają wartość logiczną (boolean). Zdefiniujmy interfejs, który obejmuje istotę tych obliczeń. Nazwiemy go Predicate, co stanowi matematyczną nazwę funkcji, która zwraca wartość typu boolean.
public interface Predicate {
public boolean evaluate(Integer argument);
}
Możemy teraz przepisać filterEvens i filterOdds tak, aby były bardziej ogólne.
public List<Integer> filter(List<Integer> list, Predicate predykat) {
List<Integer> filteredList = new ArrayList<Integer>();
for (Integer current : list) {
if (predicate.evaluate(current)) {
filteredList.add(current);
}
}
return filteredList;
}
Następnie definiujemy dwa nasze predykaty.
class isEven implements Predicate {
public boolean evaluate(Integer argument) {
return 0 == argument % 2;
}
}
class isOdd implements Predicate {
public boolean evaluate(Integer argument) {
return 1 == argument % 2;
}
}
Możemy teraz po prostu utworzyć instancję jednego z predykatów i przekazać ją do metody filter. Jeśli pojawi się nowy sposób filtrowania naszej listy - powiedzmy, że zechcemy zachować tylko liczby całkowite, które są idealnymi kwadratami - możemy zdefiniować PerfectSquare zamiast wycinania i wklejania całej funkcji filtrującej.
To, co właśnie zrobiliśmy z metodą filter i interfejsem Predicate, symuluje koncepcje ze świata funkcyjnego - funkcje wyższego rzędu. Są to takie funkcje, które można przekazywać do innych funkcji lub takie, które mogą wracać z innych funkcji. Zastanówmy się, jak zrobilibyśmy podobne filtrowanie w Clojure - nowoczesnym i funkcyjnym wariancie języka Lisp.
(filter odd? [0 1 2 3])
(filter even? [0 1 2 3])
To jest to! Pierwsze, co można zauważyć, to znacznie krótszy zapis niż w wersji Javy. Druga rzecz to zapewne fakt, że nawiasy nie znajdują się w swoim zwykłym położeniu. Clojure i inne wersje języka Lisp do wywoływania funkcji wykorzystują notację z prefiksem. Oznacza to, że funkcja, która jest wywoływana, znajduje się na pierwszym miejscu w nawiasach, a argumenty są umieszczane po niej.
Pomijając różnice składniowe, zauważmy, jak wersja z Clojure używa wszystkich wbudowanych funkcji i cech języka. Nie musimy definiować interfejsu Predicate. Funkcja odd? to funkcja, która pobiera liczbę i zwraca true, jeśli jest ona nieparzysta, natomiast even? robi to samo w odniesieniu do liczb parzystych. Możemy przekazać te funkcje bezpośrednio do funkcji filtrującej dzięki mocy funkcji wyższego rzędu.
To zmienia nasze rozwiązanie imperatywne, w którym pisaliśmy kod związany ze szczegółami związanymi z iteracją po liście, w postać bardzo deklaratywną. Pracujemy na wyższym poziomie abstrakcji, który prowadzi nas często do opisu, jakich wyników potrzebujemy, zamiast szczegółów ich otrzymywania.
Gdy więc mowa jest o programowaniu funkcyjnym, to zwykle chodzi o co najmniej dwie oddzielne, choć powiązane ze sobą sprawy. Po pierwsze, mówimy o programowaniu za pomocą czystych funkcji. Ponieważ jest to nieosiągalne marzenie przy większości rzeczywistych problemów, w praktyce funkcyjne języki programowania koncentrują się na łatwiejszym stosowaniu niemutowalności oraz na możliwości kontrolowania zmienności, gdy jest ona rzeczywiście niezbędna.
Po drugie, mówi się o stylu programowania, który wyrósł wokół podstawy funkcyjnej. Jak widzieliśmy, ten styl opiera się w znacznym stopniu na funkcjach wyższego rzędu i związanych z tym technikami. Techniki te często tworzą kod, który opiera się na wyższym poziomie abstrakcji, jaki widzieliśmy przy korzystaniu z funkcji filtrowania w powyższym przykładzie, a nie na jawnych iteracjach.
Te dwa aspekty programowania funkcyjnego dają znaczące korzyści. Wyjątkowy nacisk na niemutowalność sprawia, że programy są łatwiejsze do zrozumienia. Zachowanie funkcji można zrozumieć, czytając kod samej funkcji i nie martwiąc się o to, że jakiś fragment zmiennego stanu może zależeć od czegoś, co jest oddalone o setki lub tysiące wierszy kodu. Korzystanie z funkcji wyższego rzędu prowadzi często do kodu deklaratywnego, który jest prostszy i bardziej bezpośredni od odpowiednika imperatywnego.
Nie myli się więc ktoś, kto myśląc o programowaniu funkcyjnym, rozważa dwie rzeczy: preferowanie programowania z czystymi funkcjami i styl programowania obejmujący funkcje wyższego rzędu w kodzie deklaratywnym.
Kolejnych kilkanaście rozdziałów jest uporządkowanych według języków, a kilka z nich poświęcono programowaniu funkcyjnemu w tych językach. Można przeskoczyć bezpośrednio do języka, który nas najbardziej interesuje, lub czytać rozdziały po kolei. Ale najlepiej zacząć od następnego rozdziału, w którym Venkat Subramaniam pokaże nam jak Scala, hybrydowy język funkcyjny, wdraża omawiane wyżej problemy.