Why are curried functions spicier?

Pikantne Funkcje Curried: Smak Elastyczności

15/01/2021

Rating: 4.94 (11478 votes)

W świecie programowania, zwłaszcza w paradygmacie funkcyjnym, spotykamy się z różnymi sposobami definiowania i używania funkcji. Czasem funkcje wydają się bardziej elastyczne, bardziej dynamiczne, a wręcz… bardziej „pikantne”. To właśnie te cechy często przypisuje się funkcjom curried, które, wbrew intuicyjnej nazwie, nie mają nic wspólnego z kulinarną przyprawą, a z matematykiem i logikiem Haskell Currym. Ale dlaczego właściwie mówi się, że są „pikantniejsze”? Zagłębmy się w ten fascynujący aspekt programowania.

What is the difference between Curry and uncurry in Haskell?
Given that Haskell-style multi-argument functions are called curried functions, the function to turn a function of type (a, b) -> c into a function of type a -> b -> c is called curry, and the one that turns a function of type a -> b -> c into a function of type (a, b) -> c, being its inverse, is called uncurry.

Programowanie funkcyjne, z którego wywodzą się funkcje curried, stawia na modularność i kompozycję. Funkcje są traktowane jako obywatele pierwszej klasy, co oznacza, że mogą być przekazywane jako argumenty, zwracane jako wyniki innych funkcji, a także przypisywane do zmiennych. W tym kontekście, sposób, w jaki funkcja przyjmuje wiele argumentów, staje się kluczowy dla jej elastyczności i użyteczności.

Czym są Funkcje Curried i Uncurried?

Zanim zrozumiemy, dlaczego funkcje curried są tak wyjątkowe, musimy rozróżnić je od ich „mniej pikantnych” odpowiedników – funkcji uncurried. Rozważmy funkcję, która przyjmuje dwa argumenty, na przykład dwie liczby całkowite, i zwraca ich sumę. W języku takim jak OCaml, który jest silnie związany z koncepcjami programowania funkcyjnego, możemy zdefiniować taką funkcję na kilka sposobów.

Funkcja Curried:

Funkcja curried to taka, która przyjmuje argumenty jeden po drugim, zwracając kolejno funkcje, dopóki nie otrzyma wszystkich potrzebnych argumentów i nie zwróci ostatecznego wyniku. Typ takiej funkcji, która przyjmuje argumenty typu t1 i t2 i zwraca wartość typu t3, wygląda następująco: t1 -> t2 -> t3. Zauważ, że strzałki łączą argumenty sekwencyjnie. Oznacza to, że funkcja przyjmuje t1, a następnie zwraca *nową funkcję*, która czeka na t2, aby w końcu zwrócić t3. To jest klucz do jej „pikantności”.

let add x y = x + y
val add: int -> int -> int = <fun>

W powyższym przykładzie funkcja add najpierw przyjmuje x, a następnie y. Nie przyjmuje ich jednocześnie w jednym „pakiecie”.

Funkcja Uncurried:

Z drugiej strony, funkcja uncurried przyjmuje wszystkie swoje argumenty jednocześnie, zazwyczaj w postaci pojedynczej krotki (tuple). Jej typ dla tej samej operacji wyglądałby następująco: t1 * t2 -> t3. Gwiazdka oznacza, że argumenty są spakowane razem w jedną krotkę.

Możemy zdefiniować taką funkcję, używając funkcji pomocniczych fst i snd, które pobierają odpowiednio pierwszy i drugi element krotki:

let add' t = fst t + snd t
val add': int * int -> int = <fun>

Albo, co jest bardziej idiomatyczne i czytelne, używając wzorca krotki bezpośrednio w definicji funkcji:

let add'' (x, y) = x + y
val add'': int * int -> int = <fun>

W obu przypadkach, add' i add'', funkcja oczekuje jednego argumentu – krotki zawierającej dwie liczby. Nie ma tu możliwości „podania” tylko jednej części argumentu.

Dlaczego Funkcje Curried Są „Pikantniejsze”?

Metafora „pikantności” w odniesieniu do funkcji curried doskonale oddaje ich elastyczność i moc. Głównym powodem, dla którego są one uważane za „pikantniejsze”, jest możliwość ich częściowego aplikowania (partial application). To cecha, która radykalnie zmienia sposób, w jaki możemy myśleć o kompozycji i ponownym użyciu kodu.

Częściowe aplikowanie oznacza, że możesz wywołać funkcję curried, podając jej tylko część oczekiwanych argumentów. W rezultacie otrzymasz nową funkcję, która „pamięta” te podane już argumenty i czeka na resztę. Wyobraź sobie kucharza, który przygotowuje danie wymagające wielu składników, ale zamiast prosić o wszystkie naraz, prosi o nie pojedynczo. Gdy dostanie pierwszy składnik, jest gotowy do dalszego działania, ale wciąż czeka na pozostałe, aby dokończyć potrawę. Tak działa częściowe aplikowanie.

W przypadku naszej funkcji add:

let add x y = x + y

Możemy ją wywołać, podając tylko pierwszy argument x:

let add_five = add 5
val add_five: int -> int = <fun>

Teraz add_five jest nową funkcją, która zawsze dodaje 5 do swojego argumentu. Typ int -> int jasno wskazuje, że oczekuje ona jednej liczby całkowitej i zwróci jedną liczbę całkowitą. Możemy jej użyć w ten sposób:

let result = add_five 10 (* result będzie równe 15 *)

To jest właśnie ta „pikantność”! Stworzyliśmy nową, wyspecjalizowaną funkcję z istniejącej funkcji ogólnej, po prostu podając jej część argumentów. Ta technika jest niezwykle potężna w programowaniu funkcyjnym, ponieważ pozwala na:

  • Tworzenie wyspecjalizowanych funkcji: Zamiast pisać wiele podobnych funkcji, możesz stworzyć jedną ogólną i generować z niej specyficzne wersje.
  • Zwiększoną czytelność kodu: Kod często staje się bardziej zwięzły i elegancki, gdy używa się częściowego aplikowania.
  • Łatwiejszą kompozycję funkcji: Funkcje, które zwracają inne funkcje, idealnie pasują do siebie w łańcuchach operacji.

W przeciwieństwie do tego, funkcje uncurried nie oferują tej możliwości. Nie możesz „podać połowy krotki”. Jeśli masz funkcję add'' (x, y) = x + y, musisz zawsze podać jej pełną krotkę (x, y). Próba podania tylko x nie ma sensu, ponieważ funkcja oczekuje *jednego* argumentu – krotki. To sprawia, że są one mniej elastyczne w scenariuszach, gdzie częściowe aplikowanie jest pożądane.

Historia za „Curry”: Nie Przyprawy, Lecz Logik

Warto rozwiać wszelkie wątpliwości: nazwa „curried functions” nie pochodzi od aromatycznej przyprawy ani potrawy curry. Pochodzi ona od nazwiska amerykańskiego matematyka i logika Haskella Curry'ego. Curry wniósł znaczący wkład w rozwój logiki kombinatorycznej, która jest podstawą rachunku lambda, a ten z kolei stanowi teoretyczne fundamenty wielu języków programowania funkcyjnego. To właśnie on sformalizował ideę przekształcania funkcji przyjmujących wiele argumentów w sekwencję funkcji przyjmujących pojedynczy argument. Jest to jeden z niewielu przypadków w informatyce, gdzie zarówno imię (Haskell, nazwa języka programowania) jak i nazwisko (Curry, pojęcie currying) osoby zostały uhonorowane nazwami w dziedzinie programowania.

Konwersja Między Funkcjami Curried i Uncurried

W praktyce programistycznej często zdarza się, że natrafiasz na biblioteki, które oferują funkcje w formie uncurried, podczas gdy ty wolisz używać ich w formie curried, aby skorzystać z częściowego aplikowania. Lub, co jest mniej powszechne, masz funkcję curried i potrzebujesz jej wersji uncurried. Umiejętność konwersji między tymi dwoma stylami jest niezwykle przydatna.

Spójrzmy, jak możemy ręcznie przekształcić funkcję add'' (uncurried) na formę curried i odwrotnie.

Z Uncurried na Curried:

Mamy funkcję add'', która przyjmuje krotkę:

let add'' (x, y) = x + y

Chcemy uzyskać funkcję, która przyjmuje x, a potem y. Możemy to zrobić, definiując nową funkcję:

let curried_add x y = add'' (x, y)

Tutaj curried_add przyjmuje x i y oddzielnie, a następnie przekazuje je jako krotkę do oryginalnej funkcji add''. Typ curried_add będzie int -> int -> int, czyli jest to funkcja curried.

Z Curried na Uncurried:

Mamy funkcję add, która jest curried:

let add x y = x + y

Chcemy uzyskać funkcję, która przyjmuje krotkę (x, y). Robimy to następująco:

let uncurried_add (x, y) = add x y

W tym przypadku uncurried_add przyjmuje krotkę (x, y), a następnie „rozpakowuje” ją i przekazuje x i y jako oddzielne argumenty do funkcji add. Typ uncurried_add będzie int * int -> int, co oznacza, że jest to funkcja uncurried.

Użycie Funkcji Wyższego Rzędu do Konwersji

Ręczne konwersje są użyteczne do zrozumienia mechanizmu, ale w praktyce programiści często używają funkcji wyższego rzędu (higher-order functions) do automatyzacji tego procesu. Funkcje wyższego rzędu to takie, które przyjmują inne funkcje jako argumenty lub zwracają funkcje jako wyniki. Są one filarem programowania funkcyjnego i idealnie nadają się do tworzenia generycznych narzędzi do manipulacji funkcjami.

Możemy zdefiniować dwie takie funkcje, curry i uncurry, które będą ogólnymi konwerterami:

  • curry: Przyjmuje funkcję uncurried i zwraca jej curried odpowiednik.
  • uncurry: Przyjmuje funkcję curried i zwraca jej uncurried odpowiednik.

Oto ich definicje (w składni OCaml):

let curry f x y = f (x, y)
val curry: ('a * 'b -> 'c) -> 'a -> 'b -> 'c = <fun>

Funkcja curry przyjmuje funkcję f, która oczekuje krotki ('a * 'b) i zwraca 'c'. Następnie zwraca nową funkcję, która przyjmuje 'a' i 'b' oddzielnie, a wewnątrz wywołuje oryginalną funkcję f z krotką (x, y). Typy polimorficzne 'a, 'b, 'c oznaczają, że te funkcje działają dla dowolnych typów, co czyni je bardzo uniwersalnymi.

let uncurry f (x, y) = f x y
val uncurry: ('a -> 'b -> 'c) -> 'a * 'b -> 'c = <fun>

Funkcja uncurry przyjmuje funkcję f, która jest curried (typu 'a -> 'b -> 'c'). Następnie zwraca nową funkcję, która oczekuje krotki (x, y). Wewnątrz tej nowej funkcji, x i y są przekazywane jako oddzielne argumenty do oryginalnej funkcji f.

Dzięki tym dwóm pomocniczym funkcjom, konwersja staje się prosta i elegancka:

let my_curried_add = curry add''
let my_uncurried_add = uncurry add

To pokazuje, jak programowanie funkcyjne, dzięki koncepcjom takim jak currying i funkcje wyższego rzędu, dostarcza potężnych narzędzi do manipulowania i komponowania logiki programu w bardzo elastyczny sposób.

Porównanie: Funkcje Curried vs. Uncurried

Podsumujmy kluczowe różnice i zastosowania obu stylów funkcji w poniższej tabeli:

CechaFunkcje CurriedFunkcje Uncurried
Struktura typówt1 -> t2 -> t3 (sekwencyjne)t1 * t2 -> t3 (krotka)
Przyjmowanie argumentówJeden po drugim, zwracając nową funkcję.Wszystkie naraz, jako pojedyncza krotka.
Częściowe aplikowanieMożliwe (kluczowa cecha), tworzy nowe funkcje.Niemożliwe, zawsze wymaga wszystkich argumentów.
ElastycznośćWyższa, idealne do kompozycji i tworzenia wyspecjalizowanych funkcji.Niższa, bardziej statyczne podejście do argumentów.
Czytelność koduMoże być bardziej zwięzła i elegancka w przypadku kompozycji.Często bardziej intuicyjna dla początkujących, przypomina tradycyjne wywołania.
ZastosowaniaFunkcje pomocnicze, kompozycja, fabryki funkcji.Operacje wymagające jednoczesnego dostępu do wszystkich danych, API bibliotek.

Najczęściej Zadawane Pytania o Funkcje Curried

Czy funkcje curried są zawsze lepsze?

Nie zawsze. Chociaż oferują większą elastyczność dzięki częściowemu aplikowaniu, w niektórych scenariuszach funkcje uncurried mogą być bardziej czytelne lub wydajniejsze, zwłaszcza gdy wszystkie argumenty są zawsze dostępne jednocześnie i nie ma potrzeby ich częściowego aplikowania. Wybór zależy od kontekstu i preferencji.

Co to jest częściowe aplikowanie?

Częściowe aplikowanie to technika polegająca na wywołaniu funkcji z mniejszą liczbą argumentów niż ta, którą oczekuje. W wyniku tego wywołania otrzymujemy nową funkcję, która „pamięta” już podane argumenty i czeka na pozostałe. Jest to możliwe tylko w przypadku funkcji curried.

Czy currying jest specyficzne tylko dla języka OCaml?

Nie, currying to fundamentalna koncepcja w programowaniu funkcyjnym i jest obecna w wielu językach, takich jak Haskell (który domyślnie curry'uje wszystkie funkcje), F#, Scala, a nawet w pewnym stopniu w JavaScript (choć wymaga to jawnej implementacji lub użycia bibliotek).

Jaka jest różnica między curryingiem a kompozycją funkcji?

Currying to technika transformacji funkcji, która przyjmuje wiele argumentów w serię funkcji przyjmujących po jednym argumencie. Kompozycja funkcji to łączenie dwóch lub więcej funkcji w nową funkcję, gdzie wyjście jednej funkcji staje się wejściem innej. Chociaż są to różne koncepcje, currying często ułatwia kompozycję funkcji, ponieważ pozwala na łatwe tworzenie funkcji pośrednich.

Czy currying wpływa na wydajność?

W większości nowoczesnych kompilatorów języków funkcyjnych, różnica w wydajności między funkcjami curried a uncurried jest marginalna lub nieistotna dla większości zastosowań. Kompilatory są bardzo dobre w optymalizacji tych konstrukcji. W specyficznych, bardzo wydajnych scenariuszach, uncurried funkcje mogą być minimalnie szybsze ze względu na mniejszą liczbę pośrednich wywołań funkcji, ale rzadko jest to czynnik decydujący o wyborze.

Dlaczego używa się funkcji wyższego rzędu takich jak curry i uncurry?

Użycie funkcji wyższego rzędu do konwersji między stylami curried i uncurried pozwala na pisanie bardziej ogólnego i ponownego używalnego kodu. Zamiast ręcznie przekształcać każdą funkcję, można zastosować uniwersalne konwertery, co zwiększa abstrakcję i elegancję rozwiązania.

Podsumowując, funkcje curried są „pikantniejsze” nie dlatego, że są częścią kulinarnej potrawy, lecz ze względu na swoją wrodzoną elastyczność i moc, wynikającą z możliwości częściowego aplikowania. Pozwalają programistom na tworzenie bardziej modularnego, reużywalnego i eleganckiego kodu, co jest esencją nowoczesnego programowania funkcyjnego. Zrozumienie ich działania i umiejętność konwersji między nimi a ich uncurried odpowiednikami to cenna umiejętność w arsenale każdego programisty, otwierająca drzwi do bardziej zaawansowanych technik kompozycji i abstrakcji.

Zainteresował Cię artykuł Pikantne Funkcje Curried: Smak Elastyczności? Zajrzyj też do kategorii Kulinaria, znajdziesz tam więcej podobnych treści!

Go up