Podstawowymi jednostkami funkcyjnie zorientowanych programów komputerowych są funkcje. W tym odcinku poznamy sposoby ich tworzenia i używania, a także dowiemy się czym są domknięcia oraz funkcje wyższego rzędu.
Funkcje i domknięcia
Funkcja (ang. function) w programowaniu to podprogram, który przeprowadza pewną operację obliczeniową na danych wejściowych zwanych argumentami (ang. arguments) i emituje jakąś zwracaną wartość (ang. return value). Uruchamianie podprogramu funkcji nazywamy jej wywoływaniem (ang. calling).
Funkcje są przydatne, ponieważ:
pozwalają na łączenie wielu wyrażeń w jeden logiczny zbiór, a następnie wartościowanie tego zbioru dla różnych warunków wejściowych;
umożliwiają dzielenie złożonych problemów na mniejsze części;
umożliwiają selektywne testowanie realizowanych przez program czynności;
pomagają unikać powtarzania się, czyli wielokrotnego wprowadzania tych samych lub podobnych konstrukcji, aby realizować takie same obliczenia (oddzielanie danych od operacji);
umożliwiają abstrahowanie zarządzania danymi przez tworzenie generycznych funkcji mogących przyjmować inne funkcje i/lub zwracać je jako rezultaty obliczeń;
oszczędzają pamięć, ponieważ powstające w funkcjach rezultaty działań pośrednich mogą być bezpiecznie usunięte z pamięci po zakończeniu wywoływania funkcji.
W funkcyjnych językach programowania (ang. functional programming languages) i językach czerpiących z paradygmatu funkcyjnego funkcje są podstawowym elementem programów. Możemy dzięki nim nie tylko dzielić problemy na części, ale również abstrahować je. Polega to na przekształcaniu funkcji przez inne funkcje (przyjmowaniu ich jako argumenty, wywoływaniu i zwracaniu jako wartości).
Języki funkcyjne to takie, w których zastosowanie znajduje funkcyjny paradygmat programowania. Przetwarzanie danych bazuje w nim na obliczaniu wartości funkcji, które są czyste (ang. pure), czyli mogą być wyrażone jako funkcje znane z matematyki. Oznacza to, że dla takich samych argumentów o ustalonych wartościach za każdym razem będą zwracały takie same wyniki i nie zmienią stanu otoczenia (niezwiązanych z nimi obiektów w pamięci). Ich interfejsem do kontaktu z innymi częściami programu będą wyłącznie: argumenty (wejście) i zwracana wartość (wyjście).
W Clojure można stosować zarówno funkcje czyste, jak również takie, których wyniki zależą od otoczenia bądź wpływają na nie, np. wyświetlają coś na ekranie, zapisują bądź odczytują dane z plików czy modyfikują wartości obiektów znajdujących się poza bezpośrednim zasięgiem ich ciał. Mówimy wtedy, że poza realizowaniem właściwych dla niej obliczeń funkcja ma tzw. efekty uboczne (ang. side effects). W związku z tym Clojure nie może być, w przeciwieństwie do np. Haskella, uznanym za język czysto funkcyjny.
Paradygmat funkcyjny oznacza również, że w programie nie operujemy na zmiennych, lecz na wartościach, a w rezultacie unikamy obsługi współdzielonych, zmieniających się stanów, czyli takich obiektów, których pamięciowa zawartość może być bezpośrednio modyfikowana z różnych miejsc w programie. Sposobem na wyrażanie logiki aplikacji w świecie funkcyjnym jest budowanie funkcji, które na podstawie przekazywanych argumentów zwracają wyniki, a gdy pojawia się potrzeba kontaktu ze środowiskiem zewnętrznym (np. podsystemem wejścia/wyjścia) korzysta się specjalnych konstruktów, np. monad.
Clojure pozwala programować bez stosowania zmiennych, współdzielonych stanów, jednak nie ogranicza programisty do czysto funkcyjnego podejścia. Dopuszcza się manipulowanie pewnymi pamięciowymi obiektami dostępnymi z wielu miejsc w programie, jednak z użyciem specjalnych konstrukcji (tzw. typów referencyjnych i odpowiednich form ich obsługi), które gwarantują odpowiednią izolację między wątkami i określone strategie aktualizowania wartości.
Funkcje w Clojure, podobnie jak w innych Lispach, są jednostkami pierwszej kategorii (ang. first-class citizens). Termin ten oznacza, że obiekty reprezentujące funkcje traktowane są w taki sam sposób, jak instancje innych, powszechnie wykorzystywanych typów danych. W związku z tym możemy:
przechowywać odwołania do funkcji w strukturach danych (np. w zmiennych globalnych);
przekazywać funkcje jako argumenty do makr, form specjalnych lub innych funkcji, a także zwracać funkcje jako rezultaty wykonania operacji, czyli tworzyć funkcje wyższego rzędu, a w związku z tym:
korzystać ze struktur danych, w których dostęp do elementów wiąże się z automatycznym wywołaniem ustawionej wcześniej funkcji (zwłoczne obliczanie wartości);
tworzyć funkcje pochodne względem istniejących, które różnią się liczbą przyjmowanych argumentów (zastosowanie częściowe, zwijanie i rozwijanie).
Używanie funkcji
Aby korzystać z funkcji, należy ją wcześniej zdefiniować (ang. define), korzystając z jednej z służących do tego form, przedstawionych w postaci odpowiedniego S-wyrażenia.
W wyrażeniu tym zawarte będzie ciało funkcji (ang. function body) składające się z wyrażeń przeliczanych podczas wywoływania funkcji, poprzedzone wektorem parametrycznym, który określi przyjmowane przez funkcję argumenty i zasady powiązywania ich wartości z widocznymi w jej ciele parametrami. Wartością zwracaną wywoływanej funkcji będzie wartość ostatnio obliczanego wyrażenia z jej ciała.
Korzystanie z funkcji polega na jej wywoływaniu, czyli na odwoływaniu się do jej obiektu w celu zrealizowania umieszczonego tam podprogramu (obliczeniu wartości wyrażeń składających się na jej ciało) z przekazaniem przez wartość podanych argumentów (wcześniejszym przeliczeniu ich do form stałych).
Wywoływanie będzie zwykle odbywało się z użyciem symbolicznego wyrażenia listowego, które zostanie potraktowane jak forma wywołania funkcji. Pierwszym elementem takiego listowego S-wyrażenia będzie najczęściej forma symbolowa, którą powiązano z funkcją z użyciem zmiennej globalnej lub funkcyjny obiekt wyrażony w inny sposób (np. będący efektem wywołania funkcji bądź makra, które zwraca funkcję). Kolejnymi elementami listowego wyrażenia będą przekazywane argumenty, których wartości zostaną obliczone zanim podprogram funkcji rozpocznie pracę.
Definiowanie funkcji
Funkcje w Clojure możemy tworzyć z użyciem kilku form specjalnych i makr.
Funkcje anonimowe, fn
Dzięki formie specjalnej fn
możemy tworzyć funkcje anonimowe (ang. anonymous
functions). Cechuje je to, że nie są powiązane z żadnym symbolicznym identyfikatorem
i w związku z tym nazywa się je także funkcjami nienazwanymi (ang. unnamed
functions). Definicje funkcji anonimowych określamy mianem lambda-wyrażeń
(ang. lambda expressions).
Po co nam funkcje, do których nie możemy odwołać się z użyciem czytelnych nazw?
Przydają się tam, gdzie występuje konieczność utworzenia i przekazania niewielkiego
obiektu funkcyjnego jako argumentu funkcji bądź makra, a także wtedy, gdy zechcemy
samodzielnie powiązać taki obiekt z symbolem, np. w zasięgu leksykalnym formy
specjalnej let
, makra letfn
i podobnych.
Jako pierwszy, opcjonalny argument forma fn
przyjmuje wyrażoną symbolem nazwę
(sic!), która będzie dostępna w jej ciele i pozwoli funkcji odwoływać się do własnego
kodu, gdy zajdzie taka konieczność. Nazwa ta nie będzie widoczna w szerszym niż ciało
funkcji kontekście leksykalnym i jest pomocna w rekurencyjnych wywołaniach.
Kolejnym, obowiązkowym już argumentem jest wektor parametryczny, który tworzy tzw. wektorową formę powiązaniową. Używamy go, aby deklarować zestaw przyjmowanych przez funkcję argumentów. Wektor ten może być pusty (w przypadku braku przyjmowanych argumentów), lecz należy go podać.
W najprostszej postaci wektor parametryczny może składać się z niezacytowanych
symboli, które będą tworzyły wtedy formy powiązaniowe.
Każdy z nich podczas wywoływania funkcji będzie leksykalnie powiązany z umieszczoną
na odpowiadającej mu pozycji wartością przekazanego podczas wywołania argumentu.
Powiązanie będzie widoczne w ciele funkcji, tzn. w wyrażeniach przekazanych jako
ostatnie argumenty formy fn
.
(Dokładniejsze informacje dotyczące argumentów funkcji i konstruowania wektorów z parametrami znajdują się w sekcji „Argumenty funkcji”).
Po parametrach wyrażonych wektorem możemy umieścić kolejny argument opcjonalny – mapowe S-wyrażenie, określające warunki, które muszą być łącznie spełnione, aby wywołanie funkcji uznać za poprawne i nie przerywać działania programu. Mechanizm ten to tzw. asercja.
Wszystkie następne argumenty będą S-wyrażeniami stanowiącymi ciało funkcji. Wartość ostatnio obliczonego wyrażenia będzie wartością zwracaną przez funkcję po jej wywołaniu.
Opcjonalnie możemy podać wiele ciał funkcji. Każde z nich powinno być wtedy zapisane jako listowe S-wyrażenie, którego pierwszym elementem będzie wektor parametryczny. W takim przypadku mamy do czynienia z tzw. funkcją wieloczłonową (zwaną też wieloargumentowościową). Podczas jej wywołania wartościowane będzie to ciało, do którego wektora parametrycznego pasuje przekazany podczas wywołania zestaw argumentów.
Forma fn
zwraca obiekt typu funkcyjnego (ang. function type object).
Użycie:
(fn nazwa? parametry asercje? & wyrażenie…)
;(fn nazwa? (parametry asercje? & wyrażenie…)…)
,
gdzie poszczególne etykiety mają następujące znaczenia:
nazwa
– wewnętrzna nazwa funkcji (Symbol
),parametry
– wektor parametryczny (wektorowe S-wyrażenie),asercje
– warunki wykonania (mapowe S-wyrażenie),wyrażenie
– ciało funkcji składające się z wyrażeń.
Zauważmy, że w powyższym przykładzie forma fn
zwraca funkcyjny obiekt, jednak nie
możemy odwołać się do niego w dowolnym miejscu programu, ponieważ nie nadaliśmy mu
żadnej globalnej tożsamości. Wiemy jednak, że łatwo
ten problem rozwiązać, jeżeli uważnie śledziliśmy rozdział dotyczący zmiennych
globalnych – jesteśmy w stanie stworzyć obiekt typu Var
identyfikowany symbolem i powiązać z nim utworzoną funkcję. Możemy również skorzystać
z konstrukcji let
, jeżeli chcemy odwoływać się do funkcji w ograniczonym
leksykalnie kontekście.
Gdy teraz w programie umieścimy listowe S-wyrażenie i jako jego pierwszy element podamy symbol identyfikujący funkcję, zostanie ona wywołana:
Co się stało? Przeszukana została mapa przestrzeni nazw, w której do
symbolu rok-przestępny?
przypisaliśmy obiekt typu Var
. Ten z kolei
powiązany jest z obiektem stworzonej przez nas funkcji (przechowuje do niego
odniesienie). Obiekt Var
internalizowany w przestrzeni nazw to inaczej zmienna globalna.
Pierwszy element listowego S-wyrażenia w postaci symbolu (rok-przestępny?
) sprawia,
że mamy do czynienia z formą wywołania funkcji, czyli konstrukcją, której wartość
może być obliczona przez wywołanie podprogramu funkcji. Funkcja ta powinna być
powiązana z symbolem umieszczonym jako pierwszy element (globalnie, leksykalnie lub
dynamicznie).
Sam symbol powinien być niezacytowany, czyli wyrażać formę symbolową, która zostanie na początku (podczas wspomnianego przeszukania przestrzeni nazw i dereferencji zmiennej globalnej) zamieniona w funkcyjny obiekt. Obliczanie odbywa się w ten sposób, że kod funkcji jest wykonywany, a zwracana wartość (również będąca jakąś formą) podstawiana w miejscu wywołania funkcji w celu dalszego przeliczania.
Gdyby zamiast listy rozpoczynającej się symboliczną nazwą funkcji pojawiła się po prostu jej zdefiniowana wcześniej nazwa, rezultatem przeliczenia formy symbolowej byłby sam obiekt tej funkcji:
Lambda-wyrażenia, #()
Makro czytnika #()
(symbol kratki i listowe S-wyrażenie), pozwala wyrażać literał
funkcji anonimowej, który – jak sama nazwa wskazuje – pozwala zwięźle i czytelnie
zapisywać funkcje anonimowe. Zastosujemy go tam, gdzie ciała funkcji nie składają
się ze zbyt wielu wyrażeń.
Zawarte w zapisie S-wyrażenie powinno być formą wywołania, czyli jego pierwszym elementem musi być forma symbolowa, odnosząca się do jakiegoś podprogramu (funkcji, makra bądź formy specjalnej), a kolejne S-wyrażenia stanowiły będą argumenty przekazywane podczas wywoływania.
W wyrażeniu można korzystać z symbolu %
, w miejsce którego zostaną podstawione
pozycyjne argumenty wywołania lub z symboli %1
, %2
, %3
itd. W miejsca tych
ostatnich zostaną podstawione argumenty o konkretnych pozycjach, określonych liczbą
całkowitą umieszczoną tuż za znakiem procenta. Możliwe jest również podanie symbolu
%&
, który zostanie zastąpiony wektorem, gdzie każdy kolejny element będzie
wartością kolejnego przekazanego argumentu (jak w argumencie wariadycznym).
Wartością zwracaną przez makro jest obiekt typu funkcyjnego.
Makro #()
jest często wykorzystywane tam, gdzie trzeba naprędce stworzyć funkcję
anonimową celem przekazania jej do funkcji wyższego rzędu,
np. w funkcjach filtrujących czy warunkujących, które wymagają podania zewnętrznego
operatora.
W zaleceniu odnośnie stylu programowania w Clojure, jeden ze współtwórców języka, Stuart Sierra, zwraca uwagę, że stosowanie numerowanych argumentów pozycyjnych w skróconym zapisie definiującym anonimowe funkcje może utrudniać późniejszą analizę kodu źródłowego z uwagi na mało znaczącą symbolikę.
Użycie:
#(operator & operand…)
.
Funkcje nazwane, defn
Aby programiści nie musieli samodzielnie nadawać funkcjom nazw (z użyciem zmiennych
globalnych), w Clojure istnieje makro defn
, które uczytelnia i przyspiesza proces
definiowania funkcji. Działa ono podobnie do omówionej wcześniej formy
fn
, jednak wytwarza funkcję nazwaną (ang. named function), do której
można odwoływać się z użyciem symbolicznego identyfikatora o nieograniczonym zasięgu
i kontrolowanej widoczności. Odwołanie to przechowywane jest w zmiennej
globalnej internalizowanej w przestrzeni nazw.
Pierwszym argumentem wywołania defn
powinna być nazwa funkcji wyrażona
niezacytowanym symbolem. Następnym, opcjonalnym argumentem może być łańcuch
dokumentujący, a kolejnym (również nieobowiązkowym) mapa metadanowa
zawierająca metadane, które zostaną ustawione dla zmiennej globalnej
odwołującej się do obiektu funkcji. Warto nadmienić, że w przypadku mapy z metadanymi
podanej w defn
jej S-wyrażenie nie jest poprzedzane znakiem akcentu przeciągłego
(^
).
Nic nie stoi również na przeszkodzie, aby metadane zamiast w argumentach defn
zostały podane bezpośrednio przed symbolem określającym nazwę funkcji. Zostaną wtedy
skopiowane do obiektu typu Var
odnoszącego się do funkcyjnego obiektu. W takim
przypadku znak akcentu jest wymagany, gdy korzystamy z mapowego S-wyrażenia.
Po nazwie i opcjonalnym łańcuchu dokumentującym i/lub metadanych należy podać wektor parametryczny, który określa przyjmowane argumenty. Po parametrach można też umieścić opcjonalne warunki wykonania (tzw. asercje) w postaci mapy.
Kolejne argumenty to wyrażenia składające się na ciało funkcji. Wartość ostatnio wykonywanego stanie się wartością zwracaną po wywołaniu funkcji i zrealizowaniu jej podprogramu.
Podobnie jak w przypadku formy specjalnej fn
możemy podać wiele ciał
funkcji. Każde z nich powinno być w takim przypadku zapisane jako listowe
S-wyrażenie, którego pierwszym elementem jest wektor parametryczny. Będziemy
wtedy mieć do czynienia z tzw. funkcją wieloczłonową
(zwaną też wieloargumentowościową). Podczas jej wywołania wartościowane będzie
to ciało, do którego wektora parametrycznego pasuje przekazany podczas wywołania
zestaw argumentów.
Makro defn
zwraca obiekt typu funkcyjnego, a jego efektem ubocznym jest
stworzenie identyfikowanej symbolem zmiennej globalnej w bieżącej przestrzeni
nazw, która będzie powiązana z tym obiektem.
Użycie:
(defn nazwa dok? metadane? parametry asercje? & wyrażenie…)
,(defn nazwa dok? metadane? (parametry asercje? & wyrażenie…)… metadane?)
.
gdzie poszczególne etykiety mają następujące znaczenia:
nazwa
– nazwa funkcji i reprezentującej ją zmiennej globalnej (Symbol
),dok
– łańcuch dokumentujący (String
),metadane
– metadane zmiennej globalnej (mapowe S-wyrażenie),parametry
– wektor parametryczny (wektorowe S-wyrażenie),asercje
– warunki wykonania (mapowe S-wyrażenie),wyrażenie
– ciało funkcji składające się z wyrażeń.
Wyjaśnijmy co dzieje się w powyższym przykładzie:
W pierwszej linii mamy wywołanie makra defn
. W Clojure służy ono do tego, aby
do przestrzeni nazw wpisać symbol i skojarzyć go z obiektem typu Var
, tworząc
zmienną globalną, która odwołuje się do obiektu zdefiniowanej funkcji. W tym
przypadku symbolem będzie rok-przestępny?
.
Kolejna linia (i jednocześnie trzeci element podanej listy) to wektor
parametryczny, który pozwala zadeklarować, jakie argumenty
funkcja będzie przyjmowała. Formy powiązaniowe symboli umieszczone w tym wektorze
nazywamy parametrami i zostaną one powiązane z wartościami odpowiadających im
argumentów w momencie wywołania funkcji. W podanym wyżej przykładzie symbol rok
zostanie powiązany z wartością pierwszego (i jedynego) argumentu.
(Dokładniejsze informacje dotyczące argumentów funkcji i konstruowania wektorów z parametrami znajdują się w sekcji „Argumenty funkcji”).
Czwarty element listy (linia nr 3) to ciało funkcji. Może nim być dowolna lispowa
forma, ale w naszym przypadku jest to listowe S-wyrażenie rozpoczynające się symbolem
or
, a więc forma wywołania makra o takiej właśnie nazwie.
Po wywołaniu każda funkcja zwraca wartość ostatnio obliczonego wyrażenia z jej ciała,
a więc w tym przypadku wartość wywoływanego makra or
. Makro to sprawdza, czy któryś
z podanych mu argumentów jest różny od false
i różny od nil
. Gdy trafi na taki,
zwraca go jako wartość, a w przeciwnym razie zwraca false
.
Do wywołania or
przekazujemy rezultat obliczenia wartości dwóch funkcji. Ostatnia
z nich sprawdza, czy rok
dzieli się bez reszty przez 400 (wtedy na pewno jest
przestępny), a pierwsza jest makrem and
, które zwraca ostatni z argumentów, jeżeli
żaden z nich po wyliczeniu nie jest wartością false
ani nil
.
Wewnątrz and
mamy dwa warunki: sprawdzenie czy rok
dzieli się bez reszty przez 4
(czy dzielenie modulo zwraca zero) i czy (jednocześnie) dzieli się z resztą przez
100.
Funkcję możemy wywołać, stosując znaną już notację listową:
Pytajnik na końcu nazwy jest kwestią konwencji i oznacza, że mamy do czynienia z predykatem, tzn. funkcją zwracającą wartość logiczną po przeprowadzeniu jakiegoś rachunku na danych wejściowych.
Zobacz także:
- „Zmienne globalne i typ Var”, rozdział VII.
Funkcje prywatne, defn-
Makro defn-
działa identycznie jak defn
, lecz ustawia metadaną
:private
, dzięki czemu internalizowana w przestrzeni nazw zmienna
globalna, która odnosi się do obiektu funkcji będzie zmienną
prywatną. Oznacza to, że widoczność symbolicznego
identyfikatora nazywającego funkcję zostanie ograniczona do bieżącej przestrzeni
nazw. Nie będzie więc możliwe zastosowanie symbolu z dookreśloną przestrzenią
nazw, aby wywołać tak zdefiniowaną funkcję z poziomu innej
przestrzeni ustawionej jako bieżąca. W Clojure jest to sposób kapsułkowania
(ang. encapsulation) kodu.
Argumenty wywołania w przypadku makra defn-
są takie same, jak
w defn
.
Makro defn-
zwraca obiekt typu funkcyjnego, a jego efektem ubocznym jest utworzenie
identyfikowanej symbolem, prywatnej zmiennej globalnej w bieżącej przestrzeni nazw,
która będzie powiązana z tym obiektem.
Użycie:
(defn- nazwa dok? metadane? parametry asercje? & wyrażenie…)
,(defn- nazwa dok? metadane? (parametry asercje? & wyrażenie…)… metadane?)
.
Argumenty funkcji
Argumenty funkcji (ang. function arguments, skr. args) są sposobem przekazywania wartości początkowych do podprogramu realizującego algorytm funkcji. Każdy argument jest identyfikowany w ciele funkcji symbolem o ustalonej nazwie. Jeżeli w danym kontekście leksykalnym istnieją już powiązania symboli o nazwach takich, jak symbole identyfikujące argumenty, ich widoczność zostanie przesłonięta.
Argumenty funkcji tym różnią się od innych konstrukcji, które pozwalają wytwarzać powiązania z wartościami, że w chwili definiowania nie są jeszcze znane wartości kojarzone z symbolami. Pojawiają się dopiero, gdy kod funkcji poddawany jest wartościowaniu.
Parametry funkcji
Parametrami funkcji (ang. function parameters, skr. params) nazwiemy symbole podane w jej definicji, które zostaną powiązane z przekazywanymi argumentami, gdy funkcja zostanie wywołana.
Terminu argument używamy więc, gdy mówimy o wywoływaniu funkcji, natomiast parametr, gdy opisujemy samą funkcję. Symboliczne identyfikatory w ciele funkcji będziemy więc nazywali parametrami, ale akceptowane przez funkcję dane wejściowe argumentami.
Wektor parametryczny
To, jakie argumenty funkcja będzie przyjmowała i jak będą się nazywały odpowiadające
im parametry, możemy wyrazić, korzystając z wektora parametrycznego
(ang. parameter vector). Należy go przekazać jako jeden z argumentów form specjalnych
i makr służących do definiowania funkcji (np. fn
i defn
).
W podstawowej postaci wektor parametryczny jest wektorowym S-wyrażeniem, które składa się z symboli w formach powiązaniowych:
[symbol…]
,
na przykład:
[długość szerokość coś]
.
Każdy z poszczególnych parametrów umieszczonych w takim wektorze musi być niezacytowanym symbolem, który podczas wywołania funkcji będzie powiązany z wartością odpowiadającego mu, spodziewanego argumentu na odpowiadającej pozycji. Wartości argumentów będą w tym przypadku wyrażeniami inicjującymi parametry.
Poza podstawową formą wektora parametrycznego, dzięki której można określać tzw. argumenty pozycyjne funkcji, istnieją też jego warianty pozwalające na obsługę argumentów nazwanych czy nawet abstrakcyjnych powiązań strukturalnych (zarówno pozycyjnych, jak i asocjacyjnych).
Argumentowość funkcji
Argumentowość (ang. arity), zwana też arnością lub członowością, jest cechą, która oznacza liczbę i charakter argumentów przyjmowanych przez funkcję.
Możemy więc mówić na przykład o funkcjach unarnych (jednoargumentowych, zwanych też monadycznymi), binarnych (dwuargumentowych, zwanych też diadycznymi), ternarnych (trójargumentowych, zwanych też triadycznymi) itd.
Funkcje, które przyjmują wiele argumentów nazywamy ogólnie wieloargumentowymi (ang. multi-argument), poliadowymi (ang. polyadic) lub multarnymi (ang. multary, multiary).
Obsługa argumentowości
Funkcje mogą przyjmować stałą lub zmienną liczbę argumentów. W przypadku zmiennej ich liczby istnieją dwa rodzaje funkcji, które pozwalają na obsługę takich przypadków:
funkcje wieloczłonowe – mające wiele ciał różniących się zestawami przyjmowanych argumentów (argumentowościami);
funkcje wariadyczne – przyjmujące dowolną liczbę argumentów, grupowanych w strukturze parametru wariadycznego widocznego w ich ciałach.
Funkcje wieloczłonowe
Funkcje wieloargumentowościowe (ang. multi-arity functions), zwane też funkcjami o wielu argumentowościach lub funkcjami wieloczłonowymi, to takie funkcje, które mają więcej niż jedną argumentowość, czyli można tworzyć a następnie wywoływać różne ich warianty, z których każdy przyjmuje inny zestaw argumentów.
W Clojure możemy tworzyć funkcje wieloczłonowe, umieszczając w ich definicjach wiele ciał wraz z wektorami parametrycznymi.
Technika ta nazywa się ogólnie przeciążaniem argumentowości funkcji, przeciążaniem listy argumentów lub przeciążaniem arności (ang. arity overloading). Różni się ona od często występującego w innych językach programowania mechanizmu przeciążania funkcji (ang. function overloading) tym, że mamy do czynienia z pojedynczym obiektem funkcyjnym, a nie z osobnymi funkcjami z różną liczbą, rodzajem bądź kolejnością obsługiwanych argumentów.
Każda forma ciała funkcji wieloczłonowej powinna być reprezentowana listowym S-wyrażeniem, którego pierwszym elementem jest wyrażenie powiązaniowe, a kolejnymi elementami wyrażenia listowe realizujące algorytm funkcji – będą one przeliczane, gdy wywołany zostanie pasujący do argumentowości wariant.
Użycie:
(fn nazwa? (parametry & wyrażenie…)+)
,(defn nazwa dok? metadane? (parametry asercje? & wyrażenie…)+ metadane?)
.
Funkcje wariadyczne
Funkcje o zmiennej argumentowości (ang. variable arity functions), zwane też funkcjami zmiennoargumentowościowymi, funkcjami wariadycznymi (ang. variadic functions) lub funkcjami zmiennie poliadowymi (ang. variably polyadic functions), są funkcjami, które mogą przyjmować dowolną liczbę argumentów.
W Clojure funkcje tego rodzaju definiowane są z użyciem dodatkowego parametru
(tzw. parametru wariadycznego) umieszczonego na ostatniej pozycji wektora
parametrycznego. Podczas wywoływania funkcji powiązana z nim struktura będzie
zawierała wszystkie nadmiarowe argumenty, które przekazano. Dla przechowania 17
lub mniejszej liczby elementów użyta będzie uporządkowana tablica (obiekt typu
clojure.lang.ArraySeq
), a dla liczby większej łańcuch komórek Cons
(typu
clojure.lang.Cons
).
Dla oznaczenia wielu argumentów zgrupowanych w strukturze używa się symbolu
ampersandu (&
) umieszczonego przed symboliczną nazwą parametru wariadycznego.
Użycie:
& symbol
.
Funkcja tożsamościowa, identity
Funkcja tożsamościowa (ang. identity function), zwana także funkcją identycznościową, to taka funkcja jednoargumentowa, której zwracaną wartością jest zawsze wartość przekazana jako jej argument.
W Clojure funkcją tożsamościową jest wbudowana funkcja identity
. Przyjmuje ona
jeden argument i zwraca jego wartość.
Funkcja identity
przydaje się często tam, gdzie musimy przekazać operator do
funkcjonału (np. funkcji filtrującej czy odwzorowującej sekwencję), lecz nie
potrzebujemy, aby dochodziło do zmiany wartości przetwarzanych elementów.
Użycie:
(identity wartość)
.
Liczba przyjmowanych argumentów
W języku Clojure lista argumentów funkcji nazwanych jest przechowywana
w metadanych obiektu typu Var
zawierającym odniesienie do jej
obiektu. Kluczem tej konkretnej metadanej jest :argslist
.
Niestety powyższa metoda nie zadziała dla funkcji anonimowych. Aby uzyskać listę argumentów dla obiektu funkcji, należy posłużyć się metodami Javy.
Liczba przekazywanych argumentów
Liczbę argumentów rzeczywiście przekazanych do funkcji możemy zbadać w jej ciele, sumując stałą liczbę wymaganych argumentów pozycyjnych z liczbą argumentów w parametrze wariadycznym (jeżeli istnieje).
Rodzaje argumentów
Argumenty można klasyfikować, przyjmując różne kryteria. Ze względu na konieczność występowania możemy wyróżnić ich następujące rodzaje:
obowiązkowe (ang. obligatory), zwane też wymaganymi (ang. required);
opcjonalne (ang. optional), zwane też dodatkowymi (ang. additional):
- o określonych wartościach domyślnych;
- bez określonych wartości domyślnych.
Z kolei ze względu na sposób uporządkowania możemy podzielić argumenty na:
- pozycyjne (ang. positional),
- nazwane (ang. named).
Argumenty pozycyjne
Argumenty pozycyjne (ang. positional arguments) to takie argumenty funkcji, których znaczenie bazuje na kolejności ich przekazywania. W praktyce oznacza to, że powiązania przekazywanych wartości z symbolami wektora parametrycznego zależą od uporządkowania elementów.
Z argumentami pozycyjnymi mamy do czynienia w większości wbudowanych funkcji, makr i form specjalnych języka Clojure.
Uwaga: W Clojure możemy definiować funkcje, które przyjmują maksymalnie 25 argumentów pozycyjnych. Jeżeli występuje konieczność obsługi większej ich liczby, należy skorzystać z parametru wariadycznego.
Argumenty nazwane
Argumenty nazwane (ang. named arguments) to takie argumenty funkcji, których kolejność występowania nie ma znaczenia, ponieważ występują w formie asocjacji wyrażanych parami klucz–wartość, gdzie kluczami są ich nazwy. Oznacza to, że przekazując takie argumenty w sposób jawny, wyrażamy też ich identyfikatory, zamiast polegać na kolejności występowania.
Nazwy argumentów wyrażone mogą być dowolnymi wartościami, np. symbolami czy łańcuchami znakowymi, chociaż na zasadzie konwencji stosuje się słowa kluczowe. W takim przypadku argumenty nazwane bywają określane mianem argumentów bazujących na słowach kluczowych (ang. keyword arguments).
W Clojure argumenty nazwane obsługiwane są w funkcjach wariadycznych, a dokładniej przez automatyczną dekompozycję struktury parametru wariadycznego, który grupuje wszystkie lub nadmiarowe argumenty.
Możemy wyrażać wprost, jakich argumentów nazwanych się spodziewamy (ułatwi nam to
omówiona niżej dyrektywa :keys
), albo skorzystać z dyrektywy :as
, aby uzyskać
dostęp do mapy, którą sami przetworzymy – przydaje się to na przykład w obsłudze
opcji, które nie są znane w momencie definiowania funkcji lub w przetwarzaniu
argumentów, których część jest używana przez inną funkcję. Argumenty nazwane jesteśmy
bowiem w stanie w całości lub części przekazać do wywołania innej funkcji –
wymaga to skorzystania z kilku konstrukcji, które będą
odpowiednikiem apply
dla argumentów wyrażonych asocjacyjnie.
Powiązania parametrów określających argumenty nazwane z wartościami mogą być wyrażane asocjacyjnymi strukturami danych, np. mapami. Parametry takie nazywa się parametrami nazwanymi (ang. named parameters).
Parametry nazwane w definicji funkcji grupuje się w umieszczonym po symbolu ampersandu parametrze wariadycznym z użyciem tzw. mapy powiązaniowej (ang. binding map), która powinna wyrażać mapową formę powiązaniową.
Mapa powiązaniowa musi być zawarta w wektorze parametrycznym – dzięki temu można deklarować zarówno argumenty nazwane, jak i pozycyjne. Klucze mapy powinny być niezacytowanymi symbolami, które powiązane zostaną z wartościami przekazywanych argumentów, a przypisane do nich wartości powinny być nazwami tych (spodziewanych) argumentów (zwyczajowo wyrażone słowami kluczowymi).
Użycie:
[ & {symbol nazwa-argumentu …}…]
.
W powyższym przykładzie w wektorze parametrycznym umieszczona została mapa
parametryczna, której kluczami są symbole, a wartościami słowa kluczowe określające
nazwy oczekiwanych argumentów. Symbol a
zostanie więc powiązany z wartością, którą
przekazano do funkcji w parze z kluczem :a
, symbol b
z wartością nazwaną :b
itd.
Dyrektywa :keys
W ostatnim przykładzie możemy zauważyć pewną redundancję – podane nazwy symboli
i nazwy argumentów w mapie parametrycznej są takie same (chociaż nic nie stoi na
przeszkodzie, aby były różne). W takich przypadkach warto skorzystać z dyrektywy
:keys
. Jeżeli zostanie ona podana jako klucz mapy parametrycznej, to przypisany do
niej wektor będzie potraktowany tak, jakby zawierał nazwy symboli i odpowiadających
im poszukiwanych kluczy, z których wartościami należy te symbole powiązać. Słowa
kluczowe w wektorze mogą być zapisane wprost lub wyrażone niezacytowanymi symbolami.
Użycie:
[ & {:keys [nazwa-argumentu…] …}]
.
Dyrektywy :strs
i :syms
Alternatywnymi (bądź uzupełniającymi) w stosunku do :keys
dyrektywami są :strs
(oznaczająca łańcuchy znakowe) i :syms
(oznaczająca symbole). Gdy
pojawią się w mapie, przypisane do nich wektory będą grupowały nazwy argumentów
wyrażane odpowiednio łańcuchami znakowymi lub symbolami, a nie słowami kluczowymi.
Użycie:
[ & {:strs [nazwa-argumentu…] …}]
,[ & {:syms [nazwa-argumentu…] …}]
.
Dyrektywa :as
Inną dyrektywą, która może okazać się pomocna, jest :as
. Pozwala ona na
bezpośredni dostęp do struktury zawierającej przekazywane argumenty nazwane.
Przydaje się to na przykład podczas badania obecności argumentu nazwanego, gdy może
on mieć wartość nil
, a także podczas obsługi opcji przekazywanych do funkcji,
których strukturami chcemy samodzielnie zarządzać, a nie korzystać z dostępu do
każdej z nich z osobna.
Zauważmy, że wartość ta zostanie powiązana z symbolem parametru zarówno wtedy, gdy
argumentu o danej nazwie nie przekazano podczas wywoływania funkcji, jak i wtedy, gdy
argument znajduje się w mapie, ale po prostu ma wartość nil
.
Użycie:
[ & {:as symbol …}]
.
Dyrektywa :or
Dzięki dyrektywie :or
możemy ustawiać wartości domyślne dla argumentów, których
nie przekazano podczas wywoływania funkcji. Po słowie kluczowym należy podać
parametryczną mapę inicjującą, której kluczami będą nazwy argumentów, a wartościami
przypisane do nich domyślne wartości.
Użycie:
[ & {:or {nazwa-argumentu wartość-domyślna …}} …]
.
Łączenie dyrektyw
Jak zdążyliśmy zauważyć, dyrektywy można łączyć. Na przykład możemy ustawiać domyślne wartości parametrów dla pominiętych argumentów, a jednocześnie uzyskiwać dostęp do przekazanej struktury.
Argumenty obowiązkowe
Argumenty obowiązkowe (ang. obligatory arguments) to takie argumenty, które muszą być do funkcji przekazane podczas jej wywoływania.
W przypadku argumentów pozycyjnych, wyrażonych jako symbole w wektorze parametrycznym, mamy do czynienia z domniemaniem, że wszystkie są obowiązkowe. Wynika to z konieczności zachowania porządku w tego typu strukturze. Gdyby jakiś (poza ostatnim) argument nie został przekazany, nie można byłoby przed wywołaniem funkcji rozpoznać który, ponieważ w języku dynamicznie typizowanym, jakim jest Clojure, wyłącznie kolejność argumentów pozycyjnych komunikuje ich znaczenia.
W przypadku argumentów nazwanych należy wprowadzić dodatkowe testy w ciele funkcji lub posłużyć się asercją, aby uzależnić możliwość jej wywołania od obecności wymaganych kluczy w przekazanej mapie.
Podobnie rzecz się ma w przypadku funkcji wariadycznych, w których ostatni parametr jest strukturą zawierającą wszystkie nadmiarowe argumenty pozycyjne. W najprostszym przypadku będziemy tam mieć do czynienia z wektorem, którego kolejne elementy są przekazanymi argumentami. Pojawia się tu znów kwestia ich rozpoznawania, ponieważ mamy do czynienia z wyrażaniem znaczenia przez porządek. Testować możemy więc wartości konkretnych argumentów lub podaną ich liczbę.
Argumenty opcjonalne
Argumenty opcjonalne (ang. optional arguments) to takie, które mogą być pominięte (nieprzekazywane) podczas wywoływania funkcji. W dokumentacji oznaczane są symbolem pytajnika umieszczonego za symbolem.
Argumenty opcjonalne mogą przyjmować wartości domyślne (ang. default values). W takim przypadku istnieje kilka sposobów ustawiania tych wartości w zależności od rodzaju argumentów:
Dla argumentów pozycyjnych:
przez użycie funkcji o wielu argumentowościach i stworzenie wariantu z zestawem przyjmowanych argumentów, w którym jeden lub więcej z nich pominięto, lecz podano wartości domyślne w umieszczonym w jej ciele wywołaniu wariantu przyjmującego wszystkie argumenty;
przez użycie funkcji o zmiennej argumentowości i ustawienie wartości w ciele funkcji, jeżeli wykryto wartości
nil
lub mniejszą liczbę przekazanych argumentów.
Dla argumentów nazwanych:
przez użycie parametrów nazwanych i ustawienie wartości w ciele funkcji, jeżeli wykryto wartości
nil
lub braki kluczy w mapie parametrycznej;przez użycie parametrów nazwanych i ustawienie w mapie inicjującej wartości domyślnych określonych kluczem
:or
.
Dekompozycja parametrów
Podane wcześniej przykłady parametrów nazwanych korzystają z mechanizmu zwanego dekompozycją (ang. decomposition) lub destrukturyzacją (ang. destructuring). Polega ona na wytwarzaniu powiązań symboli z wartościami umieszczonymi w strukturach o wielu elementach (asocjacyjnych lub pozycyjnych) bez konieczności wywoływania dodatkowych funkcji.
Możemy rozpatrywać dekompozycję jako jeden z wariantów tzw. dopasowywania
do wzorców (ang. pattern matching). Zamiast używać operacji, takich jak first
,
nth
czy rest
, korzysta się po prostu ze specyficznej składni, która w miarę jasny
sposób wyraża, z jakimi symbolami mają zostać powiązane kolejne (w przypadku struktur
sekwencyjnych, np. wektorów) lub odpowiednio nazwane (w przypadku struktur
asocjacyjnych, np. map) elementy.
Dekompozycji możemy używać również w ciele funkcji, nie polegając na wewnętrznych
mechanizmach makr służących do definiowania funkcji, ale korzystając
np. z konstrukcji let
. Spójrzmy:
W powyższym przykładzie parametr wariadyczny będzie wektorem zawierającym argumenty nazwane przekazane do funkcji wyrażone naprzemiennie: elementy parzyste będą kluczami (nazwami argumentów), a nieparzyste ich wartościami:
Zapis {:keys [a b c]}
w wektorze powiązaniowym konstrukcji let
jest składnią
specyficzną dla dekompozycji. Mówi on, że w strukturze danych, która jest podstawą do
wytworzenia powiązań, spodziewamy się kluczy o nazwach a
, b
i c
, a ich
wartościami powinny być przypisane do kluczy wartości z tej struktury. Gdyby była to
mapa, będą to po prostu odpowiednie wartości, ale ponieważ jest to wektor, zostanie
on najpierw tymczasowo przekształcony do postaci asocjacyjnej. W efekcie symbolowi
a
zostanie przypisana wartość 1
(klucz :a
), zaś symbolowi b
wartość 2
(klucz :b
). Powiązania symboli z wartościami będą widoczne w wyrażeniu podanym jako
drugi argument wywołania let
, czyli w kontekście leksykalnym.
Poza destrukturyzacją parametrów nazwanych możemy w Clojure dokonywać też
dekompozycji innych wartości przekazywanych jako argumenty funkcji, np. map czy
wektorów, bez korzystania z let
, ale polegając na mechanizmie destrukturyzacji
parametrów.
Zobacz także:
- „Dekompozycja”, rozdział VI.
Dekompozycja asocjacyjna
Dekompozycja asocjacyjna (ang. associative decomposition), zwana też destrukturyzacją asocjacyjną (ang. associative destructuring), pozwala na czytelne uzyskiwanie dostępu do wartości argumentów, które są zorganizowane w strukturach asocjacyjnych (np. [mapach]).
Spójrzmy na przykład destrukturyzacji argumentu funkcji, którego wartością jest struktura asocjacyjna:
Zauważmy, że nie korzystamy z parametru wariadycznego, który grupuje przekazane argumenty w wektorze. W związku z tym wywołując funkcję musimy wprost wyrazić strukturę danych i jako argument przekazać mapę.
Podobnie jak we wcześniejszych przykładach, związanych z argumentami nazwanymi, możemy korzystać z następujących dyrektyw:
:keys [symbol…]
– wyrażającą, że spodziewamy się wartości skojarzonych z kluczami (w postaci słów kluczowych) o podanych nazwach i chcemy je powiązać z symbolami o takich samych identyfikatorach;:syms [symbol…]
– wyrażającą, że spodziewamy się wartości skojarzonych z kluczami wyrażonymi jako symbole i chcemy je powiązać z symbolami o takich samych nazwach;:strs [symbol…]
– wyrażającą, że spodziewamy się wartości skojarzonych z kluczami wyrażonymi jako łańcuchy tekstowe i chcemy je powiązać z symbolami o takich samych nazwach;:as symbol
– wyrażającą, że chcemy, aby przekazywana wartość argumentu została dodatkowo powiązana z podanym symbolem bez dekompozycji;:or {klucz wartość …}
– wyrażającą wartości domyślne dla kluczy, których nie odnaleziono w podanej strukturze.
Zobacz także:
- „Dekompozycja asocjacyjna”, rozdział VI.
Dekompozycja pozycyjna
Dekompozycja pozycyjna (ang. positional decomposition), zwana też destrukturyzacją pozycyjną (ang. positional destructuring) jest czytelnym sposobem uzyskiwania dostępu do argumentów zorganizowanych w strukturze o następczym interfejsie dostępu (np. liście czy wektorze). Jest ona przydatnym sposobem kontroli listy argumentów przyjmowanych przez funkcje.
Możemy na przykład dokonywać destrukturyzacji parametru wariadycznego i w ten sposób obsługiwać argumenty opcjonalne, bez konieczności dodatkowego przetwarzania wartości parametru w ciele funkcji:
Podobnie jak we wcześniejszych przykładach, związanych z argumentami pozycyjnymi, możemy korzystać z następujących dyrektyw:
:as symbol
– wyrażającą, że chcemy, aby przekazywana wartość argumentu została dodatkowo powiązana z podanym symbolem bez dekompozycji;_
– wyrażającą elementy struktur, które chcemy zignorować i nie dokonywać ich powiązań z żadnymi symbolami;&
– wyrażającą elementy struktur, które chcemy zgrupować w jednym wektorze (przypomina to parametr wariadyczny).
Zobacz także:
- „Dekompozycja pozycyjna”, rozdział VI.
Przekazywanie wartości argumentów
Zdarza się, że przekazane do funkcji wartości wszystkich lub wybranych argumentów musimy dalej przekazać jako argumenty wywołania innej funkcji. W zależności od tego, czy mamy do czynienia z argumentami nazwanymi czy pozycyjnymi, wariadycznymi czy o ustalonej liczbie, będziemy korzystali z różnych sposobów.
Przekazywanie pozycyjnych
Przekazywanie argumentów pozycyjnych jest proste. W wywołaniu kolejnej funkcji umieszczamy po prostu symbole powiązane z wartościami parametrów:
Przekazywanie wariadycznych
Przekazywanie argumentów zgrupowanych w parametrze wariadycznym wymaga wyekstrahowania z jego struktury wszystkich lub wybranych elementów, a następnie przekazanie ich jako argumenty do wywołania funkcji.
Możemy wyróżnić różne przypadki, zależnie od liczby przekazywanych w ten sposób argumentów (wszystkie lub część), a także od tego, czy wykorzystywana jest destrukturyzacja:
- przekazywanie argumentu wariadycznego w całości,
- przekazywanie argumentu wariadycznego w części.
W obydwu posłużymy się funkcją apply
, która sprawi, że kolejne elementy
struktury danych reprezentującej parametr wariadyczny zostaną podstawione jako
argumenty wywołania funkcji.
W wywołaniu innej funkcji z przekazaniem jej tylko części argumentów, poza użyciem
apply
dokonamy pewnej operacji na parametrze wariadycznym, np. skorzystamy
z funkcji drop
, aby pominąć pierwszy element (odpowiadający pierwszemu
argumentowi):
Przekazywanie dekomp. pozycyjnie
Przekazywanie argumentów wariadycznych poddawanych dekompozycji
pozycyjnej może odbywać się również w całości lub
w części. W tym pierwszym przypadku należy użyć dyrektywy :as
w parametrze
wariadycznym, która powoduje, że poza dekompozycją będziemy mieli dostęp również do
struktury parametru wariadycznego zawierającej wszystkie powiązane z nim
argumenty. Podobnie jak w przykładach z przekazywaniem parametru wariadycznego,
z którym de facto mamy tu do czynienia, korzystamy z funkcji apply
, aby
kolejne elementy struktury stały się wartościami argumentów przekazywanych do
wywołania funkcji.
Drugi przypadek to przekazywanie wartości wybranych argumentów pochodzących z dekompozycji parametru wariadycznego. Jego obsługa w praktyce nie różni się od zwykłego przekazywania argumentów pozycyjnych z użyciem symboli reprezentujących odpowiadające im parametry:
Przekazywanie dekomp. asocjacyjnie
Przekazywanie argumentów pochodzących
z dekompozycji asocjacyjnej jest nieco bardziej
skomplikowane, niż ta sama operacja dla argumentów poddawanych destrukturyzacji
pozycyjnej. Mamy w nim do czynienia z mapą, której klucze i wartości muszą
zostać przekształcone najpierw do postaci sekwencyjnej klucz-1 wartość-1
klucz-2 wartość-2 …
, a dopiero elementy tej struktury mogą być przekazane jako
kolejne argumenty wywołania funkcji.
Aby uzyskać dostęp do parametru wariadycznego, w mapie powiązaniowej należy
umieścić dyrektywę :as
. Następnie uzyskaną strukturę należy przekształcić do
wspomnianej postaci. W tym celu możemy użyć funkcji mapcat
, której
jako transformator podamy identity
, ponieważ nie zależy nam na
przekształcaniu kluczy i wartości, lecz ich grupowaniu w sekwencji.
Zauważmy, że opisywana tu metoda pozwala na przekazywanie wszystkich argumentów nazwanych, które w Clojure obsługuje się właśnie z użyciem parametru wariadycznego i asocjacyjnej dekompozycji.
Asercje
Asercja (ang. assertion) to mechanizm przerywania wykonywania programu lub zgłaszania wyjątku, gdy podany predykat (funkcja sprawdzająca wystąpienie pewnych warunków) nie zwraca logicznej prawdy.
Asercje znajdują zastosowanie w testowaniu programów komputerowych, zabezpieczaniu ich fragmentów przed występowaniem błędów związanych z danymi wejściowymi lub wartościami podawanymi przez programistę, a także w tzw. programowaniu kontraktowym (ang. contract programming), zwanym również projektowaniem bazującym na kontrakcie (ang. Design by Contract, skr. DbC), gdzie sprawdzanie poprawności jest fundamentalną częścią procesu tworzenia oprogramowania.
W Clojure (od wydania 1.1) podczas definiowania funkcji z użyciem formy specjalnej
fn
lub makra defn
możemy po wektorze parametrycznym umieścić tzw. mapę
warunkową (ang. condition map) zawierającą zestawy warunków, które muszą być
spełnione, aby funkcja została wywołana. Jeżeli którykolwiek z podanych predykatów
zwróci wartość false
lub nil
, zgłoszony zostanie wyjątek AssertionError
.
Zestawy warunków powinny być wyrażone wektorami, podanymi jako wartości kluczy
:pre
lub :post
mapy. Klucz :pre
to warunki, które zostaną sprawdzone zanim
ciało funkcji będzie poddane wartościowaniu, natomiast :post
grupuje wyrażenia
warunkowe obliczane po zakończeniu wykonywania kodu funkcji.
Użycie:
{:pre [wyrażenie…]}
,{:post [wyrażenie…]}
,{:pre [wyrażenie…] :post [wyrażenie…]}
.
Uwaga: Jeżeli po mapie podanej po wektorze parametrycznym nie pojawi się ciało funkcji, będzie ona potraktowana jak wyrażenie reprezentujące ciało, a nie jak mapa warunkowa.
Wszystkie wyrażenia logiczne umieszczone w każdym z wektorów muszą być spełnione łącznie, aby uznać dany warunek za spełniony.
W obu wektorach możemy tworzyć wyrażenia, które będą odwoływały się do parametrów
funkcji, czyli do wartości przekazanych jej argumentów. Poza tym w wektorze
przypisanym do klucza :post
jesteśmy w stanie skorzystać z symbolu %
, w miejsce
którego zostanie podstawiona wartość zwracana przez funkcję.
Innym sposobem określenia warunków wykonywania funkcji jest dołączenie mapy
warunkowej do metadanej :argslist
przypisanej do zmiennej globalnej, która zawiera
odniesienie do obiektu funkcji.
Korzystanie z asercji można wyłączyć (globalnie lub w danym wątku) powiązując
zmienną dynamiczną *assert*
z wartością false
.
Funkcje wyższego rzędu
Ponieważ w Clojure funkcje są typem pierwszoklasowym (jednostkami pierwszej kategorii), więc możliwe jest tworzenie tzw. funkcji wyższego rzędu (ang. higher-order functions), zwanych także formami funkcjonalnymi (ang. functional forms), funkcjonałami (ang. functionals) lub funktorami (ang. functors).
Funkcje wyższego rzędu to takie funkcje, które przyjmują przynajmniej jedną funkcję jako argument lub zwracają funkcję jako wartość.
W matematyce ten typ funkcji bywa nazywany operatorami, a analogiczną konstrukcją w rachunku różniczkowym i całkowym jest np. pochodna – funkcja stworzona na podstawie innej funkcji.
Funkcje wyższego rzędu doskonale nadają się do filtrowania i redukowania kolekcji danych, szczególnie wyposażonych w sekwencyjny interfejs dostępu. Wiele wbudowanych w Clojure form przyjmuje jako argumenty operatory wyrażone obiektami funkcyjnymi.
Używanie funkcji wyższego rzędu
Tworzenie funkcji wyższego rzędu
Tworzenie funkcji wyższego rzędu nie różni się od definiowania innych funkcji. Po prostu przynajmniej jeden z przyjmowanych argumentów musi być obiektem typu funkcyjnego:
Powyższy kod definiuje nazwaną funkcję funk
, która jako pierwszy argument przyjmuje
inną funkcję, a następnie wywołuje ją, przekazując dwa kolejne, otrzymane
argumenty. Czynność przeprowadzana jest dwukrotnie, a wyniki są sumowane.
Gdybyśmy chcieli jej użyć, możemy ustawić operator (+
) i operandy:
Można też definiować funkcje, które zwracają inne funkcje:
Powyżej mamy funkcję lubię-kolor
, która przyjmuje jeden argument (nazwę koloru),
a następnie zwraca bezargumentową funkcję. Wartością tej ostatniej jest złączenie
napisu Lubię kolor
, wprowadzonego koloru i kropki (do tego służy funkcja str
).
Warto zauważyć, że zwracane przez lubię-kolor
funkcje odwołują się do powiązania
pochodzącego z obejmującego jej definicję zasięgu leksykalnego (wartości z nazwą
koloru, którą przekazano do funkcji wyższego rzędu z użyciem argumentu kolor
),
nawet jeżeli wywoływane są w innych leksykalnych kontekstach. To „przywłaszczanie”
i zapamiętywanie przez funkcje niektórych powiązań obecnych w otoczeniu nazywamy
zamykaniem ich w funkcji, a funkcję, która tego dokonuje
tzw. domknięciem.
Wbudowane funkcje wyższego rzędu
Clojure wyposażono w funkcje wyższego rzędu, które pozwalają dokonywać popularnych przekształceń z wykorzystaniem innych funkcji.
Dopełnienie, complement
Operacja dopełniania (ang. complement) jest takim przekształceniem predykatu (funkcji zwracającej wartość logiczną), że nowo powstała funkcja zwraca wartość logiczną przeciwną do oryginalnej.
W Clojure możemy przekształcać funkcję, aby zwracała komplementarną do oryginalnie
zwracanej wartość logiczną, korzystając z funkcji complement
. Przyjmuje ona jeden
argument, który powinien być funkcją, a zwraca funkcję, która emituje przeciwną
wartość logiczną. Mówiąc precyzyjnie: funkcja ta będzie zwracała wartość true
,
jeżeli oryginalna (wywoływana przez nią) funkcja zwróci false
lub
nil
. W przeciwnym przypadku będzie zwracała false
(wewnętrznie korzysta z funkcji
not
).
Użycie:
(complement funkcja)
.
Argumenty wartościowe, fnil
Dzięki funkcji fnil
możemy generować funkcje, które wywołują oryginalne funkcje,
przy czym wartością przekazywanego im argumentu lub argumentów nigdy nie będzie
nil
, nawet gdy taka nieustalona wartość zostanie przekazana jako argument.
Funkcja fnil
przyjmuje dwa obowiązkowe argumenty. Pierwszym powinna być funkcja,
a drugim wartość, która zastąpi przyjmowany argument podczas jej wywoływania, jeżeli
będzie on równy nil
.
Jeżeli podano więcej argumentów, to zostaną one użyte jako domyślne wartości kolejnych argumentów oryginalnej funkcji.
Funkcja fnil
zwraca obiekt typu funkcyjnego.
Użycie:
(fnil funkcja wartość-domyślna & wartość-domyślna…)
.
Funkcja stała, constantly
Funkcja stała (ang. constant function) to taka funkcja, która niezależnie od wartości przekazywanych argumentów zwraca zawsze stałą wartość.
W Clojure możemy łatwo definiować funkcje stałe, korzystając z funkcji wyższego rzędu
o nazwie constantly
. Przyjmuje ona jeden argument, a zwraca wieloargumentową
funkcję, która wywołana będzie zawsze zwracała jego wartość.
Funkcje stałe w praktyce przydają się tam, gdzie wymagane jest przekazanie funkcji transformującej bądź decydującej, a chcemy korzystać z ustalonej wartości.
Przykładem może być tu alter-var-root
, używana do zmiany
powiązania głównego obiektu typu Var
. Wymaga ona podania funkcji, która na bazie
poprzedniej wartości wyemituje nową. Zamiast tworzyć taką funkcję w postaci
lambda-wyrażenia czy konstrukcji fn
, możemy uczytelnić kod stosując
constantly
. Innym zastosowaniem może być przekazanie stałej funkcji do
update-in
, aby aktualizować elementy wektora stałą
wartością.
Użycie:
(constantly wartość)
.
Częściowe zastosowanie funkcji, partial
Operacja częściowego zastosowania funkcji (ang. partial function application) polega na przekształceniu funkcji, która przyjmuje pewną liczbę argumentów, w taki sposób, że powstaje nowa funkcja o mniej licznej argumentowości. Wynikowa funkcja przyjmuje część oryginalnych argumentów i wraz z zestawem argumentów o wcześniej ustalonych wartościach przekazuje je do przekształcanej funkcji podczas wywoływania.
W Clojure możemy posłużyć się funkcją partial
, aby dokonać operacji częściowego
zastosowania funkcji. Przyjmuje ona jeden obowiązkowy argument, którym powinna być
n-argumentowa funkcja i mniejszą lub równą n liczbę argumentów,
a zwraca funkcję, która przyjmuje pozostałe (nie podane) argumenty i wywołuje
przekazaną funkcję dla wszystkich.
Użycie:
(partial funkcja & argument…)
.
Rozwijanie funkcji (currying)
Operacja rozwijania funkcji (ang. currying) polega na przekształceniu funkcji przyjmującej pewną liczbę argumentów w taki sposób, że powstaje sekwencja funkcji, z których każda przyjmuje jeden argument. Nietrudno się domyślić, że w przypadku wszystkich funkcji poza ostatnią argumentami będą funkcje wywołujące kolejne funkcje z sekwencji. Rozwijanie funkcji bywa często mylone z częściowym zastosowaniem.
W czysto funkcyjnym języku Haskell, którego jednym z fundamentów jest automatyczne
rozwijanie funkcji, istnieje wbudowana obsługa tego procesu, której nie znajdziemy
w Lispach (włączając Clojure). Powodem jest przede wszystkim fakt, że dla poprawnej
automatyzacji rozwijania argumentowość funkcji musi być zawsze ściśle
określona. Najlepiej więc, aby w takim modelu typizowanie było silne, a nie
dynamiczne. Warto mieć jednak na względzie, że większość sytuacji można obsłużyć,
posługując się funkcją partial
, i stosując parametry
wariadyczne.
Można pokusić się o stworzenie marka, które posłuży do definiowania automatycznie rozwijanych funkcji. W Sieci znajdziemy nawet gotowe przykłady:
Zobacz także:
- curry.clj, alternatywna implementacja (GitHub)
- Is it possible to implement auto-currying to the Lisp-family languages?,
kwestia w Stack Overflow
Złożenie funkcji, comp
Złożenie funkcji (ang. function composition), zwane też superpozycją funkcji jest operacją polegającą na takim przekształceniu podanych jako argumenty funkcji w nową funkcję, że zwracaną wartością jest wartość pierwszej składanej funkcji wywołanej dla argumentu będącego rezultatem obliczenia wartości kolejnej. Najbardziej zagnieżdżona funkcja z zestawu będzie przyjmowała argumenty, które podano przy wywołaniu nowo utworzonej funkcji (zwanej funkcją złożoną).
W Clojure do składania funkcji służy funkcja comp
. Przyjmuje ona zero lub więcej
argumentów, które powinny być funkcjami, a zwraca funkcję będącą ich
złożeniem. Funkcje składane są od lewej podanej do prawej. Najbardziej zagnieżdżoną
funkcją składaną będzie więc ta, którą przekazano jako ostatni argument i to do niej
trafią oryginalnie przekazane argumenty.
Użycie:
(comp & funkcja…)
.
Redukowanie (zwijanie), reduce
Zwijanie (ang. fold), zwane też redukowaniem (ang. reduce) to operacja wyrażana funkcjami wyższego rzędu, która polega na redukcji elementów kolekcji o dostępie sekwencyjnym do pojedynczej wartości przez wywoływanie podanego operatora na kolejnych elementach i zakumulowanym rezultacie wywoływania go na poprzednich.
W Clojure mamy do czynienia z tzw. zwijaniem lewym (ang. left fold, skr. foldl),
które jest obsługiwane przez funkcje reduce
, reduce-kv
i reductions
(omówione w rozdziałach poświęconych kolekcjom
i sekwencjom).
Użycie:
(reduce-kv operator akumulator wektor)
,(reduce operator sekwencja)
,(reductions operator sekwencja)
,(reductions operator wartość sekwencja)
.
Odwzorowywanie elementów, map
Za odwzorowywanie (ang. mapping) elementów struktury o sekwencyjnym interfejsie
dostępu odpowiada funkcja map
(dokładniej opisana w rozdziale
IX). Dzięki niej możemy przekształcać wartości kolejnych elementów
sekwencji na podstawie podanej funkcji.
Funkcja map
przyjmuje dwa argumenty. Pierwszy z nich powinien być funkcją, która
będzie wywołana dla każdego elementu podanej jako drugi argument kolekcji
o sekwencyjnym interfejsie dostępu. Wartością zwracaną będzie leniwa
sekwencja, której elementy są rezultatami wywołań przekazanego operatora
na odpowiadających im elementach oryginalnej struktury.
Gdy podano więcej niż jedną sekwencję, przekazana funkcja musi być w stanie przyjąć odpowiednio większą liczbę argumentów, ponieważ w takim przypadku przetwarzane będą kolejne elementy z każdej podanej sekwencji jednocześnie.
W wariancie jednoargumentowym funkcja zwraca transduktor.
Odmianą funkcji map
, która do funkcji przekształcającej przekazuje dodatkowo numer
kolejny elementu (poczynając od 0), jest map-indexed
. Istnieje również
funkcja pmap
, realizująca przekształcenie w sposób współbieżny.
Użycie:
(map transformator sekwencja & sekwencja…)
,(map transformator)
,(pmap transformator sekwencja & sekwencja…)
,(map-indexed transformator)
,(map-indexed transformator sekwencja & sekwencja…)
.
Filtrowanie elementów, filter
Filtry (ang. filters) to popularna klasa funkcji wyższego rzędu, które służą do przekształcania struktury o sekwencyjnym interfejsie dostępu. Dzięki operacji filtrowania powstaje struktura pochodna bez tych elementów, dla których przekazany predykat zwraca wartość logicznej prawdy.
W Clojure istnieje funkcja filter
, dzięki której możemy dokonywać filtrowania
danych sekwencyjnych. Zwraca ona leniwą sekwencję składającą się
z elementów sekwencji podanej jako drugi argument, dla których jednoargumentowa
funkcja podana jako pierwszy argument zwraca wartości różne od false
i różne od
nil
.
W wariancie jednoargumentowym funkcja zwraca transduktor.
Użycie:
(filter predykat sekwencja)
.
Podobne w działaniu funkcje opisano w rozdziałach VIII i IX: keep
,
keep-indexed
, filterv
.
Zestawienie funkcji, juxt
Zestawienie funkcji (ang. functions juxtaposition) to operacja, która dla zestawu podanych funkcji tworzy funkcję zwracającą zestaw wartości emitowanych przez wywołanie każdej z nich.
W Clojure zestawienie realizowane jest przez funkcję juxt
. Przyjmuje ona jeden lub
więcej argumentów, które powinny być funkcjami, a zwraca wieloargumentową funkcję,
która wywołuje każdą z nich, zaś rezultaty umieszcza w zwracanym wektorze.
Użycie:
(juxt funkcja & funkcja…)
.
W powyższym przykładzie przekształcamy wektor imiona
w ten sposób, że
dla każdego elementu jest stosowana funkcja emitowana przez juxt
, która dokonuje
wytworzenia wektora. Pierwszym elementem każdego z wektorów jest wartość tego
elementu (rezultat działania funkcji identity
), a drugim liczba znaków
łańcucha tekstowego stanowiącego wartość (rezultat działania funkcji
count
).
Argumenty z sekwencji, apply
Funkcja apply
pozwala używać sekwencji jako źródła argumentów
funkcji. Każdy kolejny element sekwencji podanej jako ostatni argument jej wywołania
będzie podstawiony do zestawu argumentów przyjmowanych przez funkcję, którą należy
podać jako pierwszy argument. Wszystkie dodatkowe argumenty przekazane do apply
zostaną przekazane w niezmienionej postaci do wywoływanej funkcji przed argumentami
pochodzącymi z sekwencji.
Użycie:
(apply funkcja sekwencja)
,(apply funkcja argument-funkcji… sekwencja)
.
Uwaga: Funkcji apply
nie możemy stosować, przekazując jej jako pierwszy argument
wartości makr, ponieważ makra są obliczane we wcześniejszej fazie działania
programu.
Suma predykatów, some-fn
Funkcja some-fn
służy do generowania takich funkcji, które dla każdego
przekazywanego im argumentu sprawdzają, czy dowolny z podanych predykatów
wywołany z wartością tego argumentu zwróci logiczną prawdę (wartość różną od nil
i różną od false
). Jeżeli choć jeden argument przekazany do wygenerowanej
funkcji spełni ten warunek, testowanie kolejnych przekazanych argumentów zostanie
przerwane i zwrócona będzie wartość będąca efektem wywołania ostatniego
predykatu. Jeżeli dla żadnego z argumentów nie zostanie spełniony wspomniany warunek
prawdy, zwrócona zostanie wartość ostatnio wywołanego predykatu dla ostatniego
sprawdzanego argumentu.
Funkcja some-fn
przyjmuje przynajmniej jeden argument (predykat wyrażony obiektem
funkcyjnym), a zwraca obiekt typu funkcyjnego.
Użycie:
(some-fn predykat & predykat…)
.
Zobacz także:
- „Element z kryterium”, rozdział X.
Iloczyn predykatów, every-pred
Funkcja every-pred
służy do generowania takich funkcji, które dla każdego
przekazywanego im argumentu sprawdzają, czy każdy z podanych predykatów wywołany
z wartością tego argumentu zwróci logiczną prawdę (wartość różną od nil
i różną od
false
). Jeżeli choć jeden argument przekazany do wygenerowanej funkcji nie
spełni tego warunku, testowanie kolejnych przekazanych argumentów zostanie
przerwane i zwrócona będzie wartość false
. Jeżeli dla każdego z argumentów
zostanie spełniony wspomniany warunek prawdy, zwrócona zostanie wartość true
.
Funkcja every-pred
przyjmuje przynajmniej jeden argument (predykat wyrażony
obiektem funkcyjnym), a zwraca obiekt typu funkcyjnego.
Użycie:
(every-pred predykat & predykat…)
.
Zobacz także:
- „Predykaty”, rozdział X.
Spamiętywanie
Spamiętywanie (ang. memoization) to sposób optymalizacji programów komputerowych przez zapisywanie rezultatów operacji (np. wartości zwracanych przez funkcje) w pamięci podręcznej, aby przy kolejnych wywołaniach tych samych podprogramów korzystać z już obliczonych wyników dla takich samych danych wejściowych (np. wybranych lub wszystkich argumentów).
Możemy powiedzieć, że spamiętywanie jest pewną formą podręcznego buforowania (ang. caching) – pojawia się zysk wydajnościowy, jednak przy zwiększonym zapotrzebowaniu na zasoby pamięciowe, używane do przechowywania rezultatów.
Spamiętywanie przydaje się szczególnie w rekurencyjnych funkcjach i generatorach sekwencji, ponieważ nie trzeba wtedy przeliczać od początku całego łańcucha zależnych od siebie wartości, lecz można rozpocząć od konkretnego elementu ze zbioru wyników (wartości bezpośrednio lub pośrednio poprzedzającej wyliczaną).
Dzięki wbudowanej funkcji memoize
możemy dokonywać spamiętywania wartości
zwracanych przez funkcję podaną jako pierwszy argument. Wywołanie memoize
zwraca
nową funkcję, która najpierw sprawdza w podręcznej mapie, czy dla
przekazanych wartości argumentów stanowiących klucz nie zostały już zapamiętane
wyniki. Jeżeli tak jest, oryginalna funkcja nie jest wywoływana, lecz zwracany jest
zbuforowany wynik. Jeżeli w pamięci podręcznej nie znaleziono odpowiedniej asocjacji,
jest ona do niej dodawana po zwróceniu wartości przez oryginalną funkcję, a następnie
zwracana jest ta wartość.
Użycie:
(memoize funkcja)
.
Widzimy, że w przedostatniej linii przykładu nie został uruchomiony podprogram
funkcji funkcja
(zawierający efekt uboczny w postaci wypisywania tekstu na
konsolę), lecz dla istniejącego w pamięci rezultatów klucza (1
) funkcja
spamiętująca zwróciła wcześniej zapisany wynik.
Zobacz także:
- „Implementacja spamiętywania”, rozdział XV;
- „Spamiętywanie”, rozdział XII.
Inne funkcje wyższego rzędu
W Clojure poza omówionymi wcześniej istnieją również wbudowane funkcje wyższego rzędu o mniej ogólnym zastosowaniu, których opisy znajdziemy w innych rozdziałach, np. poświęconym sekwencjom czy kolekcjom:
remove
– usuwanie elementów sekwencji,drop-while
– częściowe usuwanie elementów,take-while
– częściowe wybieranie elementów,group-by
– grupowanie elementów sekwencji,tree-seq
– sekwencyjny dostęp do zagnieżdżonych map,every?
– test prawdy dla każdego elementu sekwencji,not-every?
– odwrócony test prawdy dla każdego elementu sekwencji,not-any?
– odwrócony test prawdy dla dowolnego elementu sekwencji,clojure.set/select
– tworzenie podzbioru.
Domknięcia
Domknięcie (ang. closure), zwane też domknięciem leksykalnym (ang. lexical
closure) lub domknięciem funkcyjnym (ang. function closure) to taka funkcja,
w której ciele są widoczne powiązania dostępne w leksykalnym zasięgu obejmującym
jej definicję. Przykładem mogą być tu powiązania o zasięgu leksykalnym stworzone
w nadrzędnych wyrażeniach let
.
Domknięciem będzie więc funkcja, w której „zamknięto” używane w wyrażeniach jej ciała powiązania pochodzące z leksykalnego otoczenia (zawierającego jej definicję). Oznacza to, że na zasięg leksykalny funkcji będącej domknięciem składa się nie tylko kod źródłowy jej ciała, ale również jego otoczenia.
Nieco prościej i bardziej ogólnie: domknięcie to funkcja, w której możemy korzystać z powiązań widocznych w otoczeniu jej definicji.
Przymiotnik „domknięcie” używany jest na nazwanie zjawiska zamykania części leksykalnego środowiska w obiekcie funkcji. Do tego środowiska mogą należeć np. powiązania symboli z wartościami tuż przed zdefiniowaniem funkcji w obejmującym ją zasięgu. Funkcja niejako ujmuje, otacza środowisko, a jego część może „zabrać ze sobą” i użytkować, gdy realizowany będzie podprogram zdefiniowany jej ciałem.
W języku Clojure każda funkcja jest domknięciem.
Jak tworzone są domknięcia?
Dla każdej formy symbolowej użytej w ciele funkcji, która powiązana jest z wartością pochodzącą z obejmującego obszaru leksykalnego, będzie wygenerowana specjalna referencja. Dzięki niej możliwym stanie się odwoływanie do wskazywanej powiązaniem wartości w kodzie danej funkcji, niezależnie od tego, gdzie została wywołana. Proces ten nazywamy kopiowaniem wartości do domknięcia, mimo że w przypadku Clojure nie są kopiowane wartości, ale właśnie odwołania do nich.
Możemy też spotkać się z określeniem, że pewne powiązanie (np. zmienna leksykalna pochodząca spoza funkcji) jest zamykane w funkcji.
W domknięciu nie będą widoczne:
wartości przesłonięte lokalnymi powiązaniami (np. parametrami funkcji czy użytymi powiązaniami leksykalnymi o takich samych identyfikatorach),
obecne w środowisku lokalne powiązania zmiennych (nieglobalne obiekty typu
Var
, stworzone np. z użyciem makrawith-local-vars
).
Należy również ostrożnie podchodzić do lokalnych, tymczasowych redefinicji zmiennych globalnych (czyt. nie polegać na ich powiązaniach w domknięciach).
Jeżeli pochodząca z otoczenia funkcji wartość obiektu referencyjnego, która wyraża zmieniający się stan pewnej tożsamości, ulega zmianie, wtedy w każdym kolejnym wywołaniu tej funkcji jej uzyskiwana wartość bieżąca będzie różna.
Przykłady domknięć
Domknięcia a funkcjonały
Funkcje wyższego rzędu, których wartościami zwracanymi są obiekty typu funkcyjnego, mogą być ciekawym przykładem domknięć, ponieważ tworzone w nich obiekty funkcyjne mogą zamykać w sobie obecne w nich powiązania.
W powyższym przykładzie wartość parametru o-ile
jest zamykana w anonimowej funkcji
zwracanej przez funkcję powiększalnik
.
Domknięcia a stałe tożsamości
Ograniczenia domknięć
Niektóre powiązania nie będą domykane w tworzonych funkcjach. Należą do nich między innymi zmienne lokalne:
Zobacz także: