Funktorem nazywamy obiekt, który może być wywoływany jak funkcja. Często chcemy też, by wywołanie takiego funktora przypominało składnią wywołanie normalnej funkcji w danym języku programowania.
W niektórych językach (np. w C++, o czym niedawno pisałem) funktorów używa się do naprawienia braku naturalnego wsparcia dla funkcji first-class. Jednak Common Lisp takie wsparcie ma, więc zasadnicze pytanie brzmi: po co nam funktory w języku, w którym funkcja jest takim samym typem danych jak każdy inny?
Zadajmy sobie inne pytanie - co robimy z funktorami?
- Przekazujemy je do funkcji wyższego rzędu, takich jak
map
czy reduce
.
- Ponieważ są to obiekty, możemy trzymać w nich dodatkowe informacje lub umożliwiać wykonywanie na nich dodatkowych operacji.
Wyobraźmy sobie, że piszemy bibliotekę do obliczania minimum zadanej funkcji matematycznej*. Niektóre metody potrzebują jedynie znać wartości naszej funkcji w kilku punktach. Inne potrzebują także pochodnych lub gradientu. Chcielibyśmy takie informacje trzymać "razem" z naszą funkcją, móc uzyskać do nich dostęp w razie potrzeby (na tym etapie nie zastanawiamy się nad sposobem ich wyliczenia czy przechowywania). Nasza matematyczna funkcja powinna więc być obiektem. Z drugiej strony, chcemy mieć możliwość użycia jej jak normalnej funkcji w języku programowania - choćby po to, by nie mnożyć dziwnej składni tylko dlatego, że chcemy "na boku" przechowywać dodatkowe informacje. W C++ przeładowalibyśmy operator()
- spełniając w ten sposób wymagania 1) i 2) naszego funktora.
Deklaracja i użycie wyglądałyby na przykład tak:
class MathFunction
{
public:
MathFunction FirstDerivative(); double operator() (double x); };
MathFunction funkcja;
double wynik = funkcja(100.0);
std::for_each(dane.begin(), dane.end(), funkcja);
Powiedzmy raz jeszcze, co chcemy uzyskać w Lispie - chcielibyśmy mieć obiekt funkcja
, który dałoby się wywołać jak funkcję, pisząc: (funcall funkcja 100)
**, oraz przekazać do funkcji wyższego rzędu, np.: (map 'list funkcja dane)
, ale równocześnie chcemy uzyskiwać z niego pewne informacje, np. (first-derivative funkcja)
.
Rozwiązaniem jest użycie Metaobject Protocol do zmiany typu klasy z standard-class
na funcallable-standard-class
. Przykładowo (w Clozure Common Lisp***):
(defclass math-function ()
() (:metaclass ccl:funcallable-standard-class))
Definiujemy również metodę zwracającą pierwszą pochodną:
(defgeneric first-derivative ()
(:documentation "Returns analytical first derivative of a function."))
(defmethod first-derivative ((f math-function))
(...))
Od teraz obiekty klasy math-object
są "funcallable" - mogą być przekazane do funcall
, co oznacza, że mogą być argumentami funkcji wyższego rzędu. Spełniamy tym samym wymagania 1) i 2) naszego funktora.
Pozostaje tylko pytanie, jaki kod wykona "wywołanie" naszego obiektu? Kod ten możemy ustawić za pomocą set-funcallable-instance-function
. Wygodnie jest zrobić to w konstruktorze. W Lispie, konstruktor klasy tworzymy dodając metodę after o nazwie initialize-instance
.
(defmethod initialize-instance :after ((f math-function) &key code)
(set-funcallable-instance-function f code))
Dodaliśmy parametr kluczowy code
, który pozwala nam tworzyć obiekty w następujący sposób:
(setq square (make-instance 'math-function :code (lambda (x) (* x x))))
(funcall square 3)
Jeżeli chcemy, aby nasz funktor był widziany jak funkcja globalna i używany ze składnią normalnego wywołania funkcji, możemy napisać:
(setf (fdefinition 'square) square)
(square 3)
Wydaje się, że konieczność zaprzęgania Metaobject Protocol sprawia, iż stworzenie funktora jest czymś skomplikowanym w stosunku do np. C++. Należy jednak pamiętać, funktory w Lispie zwykle nie są potrzebne - same funkcje są first-class i można je przekazywać oraz zwracać jak każdy inny typ. Konieczność łączenia z funkcjami dodatkowych danych i operacji jest - wydaje mi się - czymś rzadkim. I nawet nie jestem pewien, czy nie znalazłaby się lepsza abstrakcja dla tego problemu.
Przypisy
* - problem nie jest wcale taki błahy ani wzięty z powietrza - w tym semestrze miałem na studiach cały przedmiot poświęcony znajdywaniu minimum funkcji na różne, niekiedy dość dziwne sposoby.
** - składnia (funcall funkcja argumenty)
jest naturalnym sposobem wywoływania funkcji przekazanej w zmiennej w Common Lisp. Język ten, w przeciwieństwie do Scheme, pozwala funkcjom i zmiennym nosić te same nazwy - dlatego nie możemy napisać po prostu (przekazana-funkcja argumenty)
(tak jak zrobilibyśmy w Scheme), gdyż Lisp będzie szukał symbolu przekazana-funkcja
w przestrzeni nazw funkcji.
*** - jest trochę zamieszania z Metaobject Protocol w różnych implementacjach Common Lispu. Sam symbol funcallable-standard-class
znajduje się w pakiecie specyficznym dla danej implementacji. Dla Clozure Common Lisp jest to pakiet CCL (tak jak w przykładzie), w SBCL prawdopodobnie SB-MOP. Powstał projekt Closer mający na celu ujednolicenie Metaobject Protocol pomiędzy różnymi implementacjami Common Lispu, ale nie miałem okazji jeszcze z niego korzystać.