22/02/2021
Haskell, jako język programowania funkcyjnego, podchodzi do funkcji w sposób, który może zaskoczyć programistów przyzwyczajonych do paradygmatów imperatywnych. W przeciwieństwie do języków takich jak Java czy Python, gdzie funkcje często przyjmują wiele argumentów naraz, Haskell traktuje każdą funkcję jako przyjmującą tylko jeden argument. Ten z pozoru drobny szczegół kryje za sobą potężne koncepcje: funkcje wyższego rzędu oraz kurryzację, które są filarami elegancji i elastyczności kodu w Haskellu. Zrozumienie tych mechanizmów jest kluczowe do pełnego wykorzystania potencjału tego języka i pisania czystego, modułowego kodu. Przygotuj się na podróż do świata, gdzie funkcje są obywatelami pierwszej klasy, a ich zastosowanie jest znacznie bardziej intuicyjne, niż mogłoby się wydawać na pierwszy rzut oka.

Zrozumienie funkcji wyższego rzędu
Funkcje wyższego rzędu (HOF – Higher-Order Functions), zwane czasem funkcjonałami w matematyce, to nic innego jak funkcje, które mogą przyjmować inne funkcje jako argumenty lub zwracać je jako wyniki. W typowych językach imperatywnych funkcje operują na wartościach takich jak liczby czy ciągi znaków. Ale co, jeśli same funkcje mogłyby być traktowane jak wartości?
Wyobraźmy sobie prostą funkcję f a b c = a + b - c. Jest to funkcja, która sumuje a i b, a następnie odejmuje c. Ale co, jeśli chcielibyśmy, by czasem sumowała a i b, a czasem mnożyła? Albo dzieliła przez c zamiast odejmować? Pisanie oddzielnych funkcji, takich jak g a b c = a * b - c czy h a b c = a + b / c, jest nieefektywne i sprzeczne z zasadami programowania.
W Haskellu operatory takie jak (+) czy (*) są po prostu funkcjami. Nie ma w nich nic specjalnego, co by je wyróżniało od innych funkcji. Możemy więc przekazać je jako argumenty do innych funkcji. Oto przykład, jak można zgeneralizować naszą funkcję w Haskellu:
let f g h a b c = a `g` b `h` c in f (*) (/) 2 3 4 -- zwróci 1.5W tym przypadku f jest funkcją wyższego rzędu, ponieważ przyjmuje dwie inne funkcje (g i h) jako argumenty.
Haskell pozwala również na zwracanie funkcji. Rozważmy przykład, w którym tworzymy funkcję g, która przyjmuje inną funkcję f i argument n, a następnie zwraca nową funkcję. Ta nowa funkcja przyjmuje parametr m i stosuje do niego f oraz n.
let g f n = (\m -> m `f` n); let f_plus_2 = g (+) 2 in f_plus_2 10 -- zwróci 12Konstrukcja (\m -> m `f` n) to funkcja anonimowa, która przyjmuje jeden argument m i stosuje funkcję f do m i n. Kiedy wywołujemy g (+) 2, tworzymy nową funkcję jednoargumentową, która po prostu dodaje 2 do wszystkiego, co otrzyma. Zatem let f_times_5 = g (*) 5 in f_times_5 5 zwróci 25. To pokazuje ogromną elastyczność w manipulowaniu logiką programu poprzez operowanie na samych funkcjach.
Kurryzacja: Domyślne zachowanie Haskella
Teraz przejdźmy do jednej z najbardziej fundamentalnych i często mylonych koncepcji w Haskellu: kurryzacji. Kurryzacja to technika przekształcania funkcji, która przyjmuje wiele argumentów, w sekwencję funkcji jednoargumentowych. Każda z tych funkcji przyjmuje jeden argument i zwraca kolejną funkcję, która czeka na następny argument, aż do momentu, gdy zostaną dostarczone wszystkie argumenty, a funkcja zwróci ostateczną wartość.
Brzmi skomplikowanie? W rzeczywistości jest to prostsze niż się wydaje. Weźmy funkcję (+), która w tradycyjnym ujęciu dodaje dwie liczby. W Haskellu, gdy podamy jej tylko jeden argument, ona zwróci funkcję, która "pamięta" ten pierwszy argument i czeka na drugi. Na przykład:
let odejmowanie_od_10 n = (\m -> n - m); let od_10 = odejmowanie_od_10 10; od_10 8 -- zwróci 2; od_10 4 -- zwróci 6Zgadnij co? Haskell domyślnie kurryzuje wszystkie funkcje! Technicznie rzecz biorąc, w Haskellu nie ma funkcji wieloargumentowych. Istnieją tylko funkcje jednoargumentowe, z których niektóre mogą zwracać nowe funkcje jednoargumentowe.
Jest to doskonale widoczne w sygnaturach typów. Wpisz :t (++) w interpreterze GHCi, gdzie (++) to funkcja, która łączy dwie listy (np. ciągi znaków). Otrzymasz: (++) :: [a] -> [a] -> [a].
Sygnatura typu to nie [a],[a] -> [a], ale [a] -> [a] -> [a]. Oznacza to, że (++) przyjmuje jedną listę (typu [a]) i zwraca funkcję typu [a] -> [a]. Ta nowa funkcja może następnie przyjąć kolejną listę, a dopiero wtedy zwróci ostateczną, połączoną listę typu [a].
Dlatego właśnie składnia aplikacji funkcji w Haskellu nie wymaga nawiasów i przecinków, w przeciwieństwie do Pythona czy Javy, gdzie piszemy f(a, b, c). To nie jest kwestia estetyki. W Haskellu aplikacja funkcji odbywa się od lewej do prawej, więc f a b c jest faktycznie interpretowane jako (((f a) b) c). To ma sens, gdy wiemy, że f jest domyślnie kurryzowane.
W sygnaturach typów natomiast, asocjacja jest od prawej do lewej, więc [a] -> [a] -> [a] jest równoważne z [a] -> ([a] -> [a]). W Haskellu są to dokładnie te same rzeczy. Ma to sens, ponieważ gdy zastosujesz tylko jeden argument, otrzymujesz z powrotem funkcję, która czeka na kolejne argumenty.
Dla kontrastu, sprawdź typ funkcji map: map :: (a -> b) -> [a] -> [b]. Funkcja map przyjmuje funkcję jako swój pierwszy argument, co jest wyraźnie zaznaczone w typie jako (a -> b).

Aby naprawdę utrwalić koncepcję kurryzacji, spróbuj znaleźć typy następujących wyrażeń w interpreterze:
(+)(+) 2(+) 2 3mapmap (\x -> head x)map (\x -> head x) ["conscience", "do", "cost"]map headmap head ["conscience", "do", "cost"]
Te przykłady jasno pokazują, jak typy funkcji zmieniają się w miarę częściowego stosowania argumentów, co jest bezpośrednim wynikiem domyślnej kurryzacji.
Częściowe zastosowanie i sekcje
Zrozumienie funkcji wyższego rzędu i kurryzacji otwiera drzwi do pisania bardziej zwięzłego i elastycznego kodu w Haskellu. Gdy wywołujesz funkcję z jednym lub kilkoma argumentami, aby uzyskać z powrotem funkcję, która nadal akceptuje argumenty, nazywamy to częściowym zastosowaniem (partial application).
Wiesz już, że zamiast tworzyć anonimowe funkcje, możesz po prostu częściowo zastosować istniejącą funkcję. Na przykład, zamiast pisać (\x -> replicate 3 x), możesz po prostu napisać (replicate 3). Oba wyrażenia zwrócą funkcję, która powieli swój argument trzy razy.
Co jednak, jeśli chcesz częściowo zastosować operator infixowy, taki jak dzielenie (/)? Haskell oferuje specjalną składnię dla operatorów infixowych, zwaną sekcjami.
Sekcje pozwalają na częściowe zastosowanie operatora z jednym z jego argumentów:
(2/)jest równoważne z(\x -> 2 / x)– funkcja, która dzieli 2 przez swój argument.(/2)jest równoważne z(\x -> x / 2)– funkcja, która dzieli swój argument przez 2.
Możesz użyć backticków, aby utworzyć sekcję z dowolnej funkcji binarnej: (2`elem`) jest równoważne z (\xs -> 2 `elem` xs). Ta sekcja tworzy funkcję, która sprawdza, czy liczba 2 znajduje się na liście xs.
Pamiętaj, że każda funkcja jest domyślnie kurryzowana w Haskellu i dlatego zawsze przyjmuje jeden argument. Sekcje mogą być więc używane z każdą funkcją. Jeśli masz funkcję f przyjmującą cztery argumenty, np. let f a b c d = a + b + c + d, to wyrażenie (2`f`) 3 4 5 będzie równoważne z f 2 3 4 5, co zwróci 14.
Kompozycja funkcji i operator aplikacji
Inne przydatne narzędzia do pisania zwięzłego i elastycznego kodu to operator kompozycji (.) i operator aplikacji ($).
Operator kompozycji (.) łączy funkcje w łańcuch. Wynik jednej funkcji staje się argumentem następnej, podobnie jak w matematyce (f . g)(x) = f(g(x)). Jest to niezwykle potężne narzędzie do budowania złożonych operacji z prostszych elementów. Na przykład, (length . show) to funkcja, która najpierw konwertuje coś na ciąg znaków (show), a następnie zwraca długość tego ciągu (length).
Operator aplikacji ($) po prostu stosuje funkcję po lewej stronie do argumentu po prawej stronie. Zatem f $ x jest równoważne z f x. Jego główną zaletą jest jednak jego najniższy priorytet ze wszystkich operatorów. Pozwala to na pozbycie się nawiasów w wielu sytuacjach. Na przykład, f (g x y) jest równoważne z f $ g x y. To znacznie poprawia czytelność kodu, zwłaszcza gdy mamy do czynienia z zagnieżdżonymi wywołaniami funkcji.
putStrLn $ show $ sum [1,2,3]jest znacznie czytelniejsze niż putStrLn (show (sum [1,2,3])).
Tabela Porównawcza: Haskell vs. Języki Imperatywne
| Koncepcja | Haskell | Języki Imperatywne (np. Java/Python) |
|---|---|---|
| Funkcje wieloargumentowe | Wszystkie funkcje są domyślnie kurryzowane; technicznie przyjmują tylko jeden argument i zwracają kolejną funkcję. | Funkcje są jawnie definiowane z określoną liczbą argumentów. |
| Aplikacja funkcji | f a b c (bez nawiasów, aplikacja od lewej do prawej, wykorzystuje kurryzację). | f(a, b, c) (z nawiasami i przecinkami, wszystkie argumenty podawane naraz). |
| Zwracanie funkcji | Powszechne i domyślne zachowanie dzięki kurryzacji; często tworzone poprzez częściowe zastosowanie. | Wymaga jawnego zdefiniowania i zwrócenia obiektu funkcji/lambda. |
| Częściowe zastosowanie | Naturalne i często używane, np. (replicate 3). | Wymaga tworzenia nowych funkcji (np. lambd) lub użycia technik takich jak functools.partial w Pythonie. |
| Sekcje operatorów | Specjalna, zwięzła składnia dla częściowego zastosowania operatorów infixowych, np. (2/). | Brak bezpośredniego odpowiednika; wymaga jawnych lambd. |
| Kompozycja funkcji | Wbudowany operator (.) do łączenia funkcji: (f . g). | Zazwyczaj wymaga jawnego zagnieżdżania wywołań f(g(x)) lub użycia bibliotek pomocniczych. |
Często zadawane pytania (FAQ)
- Czym jest kurryzacja w Haskellu?
- Kurryzacja to technika, w której funkcja przyjmująca wiele argumentów jest przekształcana w sekwencję funkcji, z których każda przyjmuje tylko jeden argument i zwraca kolejną funkcję, aż do dostarczenia wszystkich argumentów i uzyskania ostatecznego wyniku. W Haskellu wszystkie funkcje są domyślnie kurryzowane.
- Czy Haskell ma "prawdziwe" funkcje wieloargumentowe?
- Technicznie rzecz biorąc, nie. W Haskellu każda funkcja jest funkcją jednoargumentową. Kiedy wydaje się, że funkcja przyjmuje wiele argumentów, w rzeczywistości jest to funkcja, która przyjmuje pierwszy argument, a następnie zwraca nową funkcję, która przyjmuje drugi argument, i tak dalej.
- Do czego służą sekcje?
- Sekcje to zwięzła składnia w Haskellu, która pozwala na częściowe zastosowanie operatorów infixowych. Dzięki nim można łatwo tworzyć nowe funkcje, dostarczając tylko jeden z argumentów operatora, np.
(+5)tworzy funkcję, która dodaje 5 do swojego argumentu. - Jaka jest różnica między operatorem kompozycji
(.)a operatorem aplikacji($)? - Operator kompozycji
(.)służy do łączenia funkcji. Tworzy nową funkcję, która jest wynikiem sekwencyjnego zastosowania dwóch innych funkcji (np.f . g). Operator aplikacji($)służy do stosowania funkcji do argumentu (f $ x). Jego główną zaletą jest to, że ma najniższy priorytet, co pozwala na eliminowanie nawiasów i poprawia czytelność kodu w zagnieżdżonych wywołaniach funkcji.
Zrozumienie kurryzacji i funkcji wyższego rzędu to kamień węgielny efektywnego programowania w Haskellu. Te koncepcje, choć początkowo mogą wydawać się obce, są kluczowe dla pisania zwięzłego, modułowego i potężnego kodu funkcyjnego. Pozwalają na tworzenie funkcji, które są niezwykle elastyczne, łatwe do komponowania i ponownego użycia. Dzięki temu, że Haskell traktuje funkcje jako wartości pierwszej klasy i domyślnie je kurryzuje, programiści zyskują narzędzia do abstrakcji i generalizacji, które są trudne do osiągnięcia w językach imperatywnych. Opanowanie tych technik nie tylko uczyni Cię lepszym programistą Haskella, ale także poszerzy Twoje horyzonty myślowe w kontekście innych paradygmatów programowania.
Zainteresował Cię artykuł Haskell: Kurryzacja i Funkcje Wyższego Rzędu? Zajrzyj też do kategorii Kulinaria, znajdziesz tam więcej podobnych treści!
