Szybki przegląd języka Common Lisp

Lisp jest językiem legendą, można napisać o nim wiele ciekawych rzeczy. Bardziej ogólne rzeczy, wnioski, bez przykładów wyrażonych w kodzie, zawarłem w tym artykule. Ten artykuł zaś ma szybko pokazać Lisp od strony technicznej, z przykładowymi kodami źródłowymi. Jednak nie jest to całościowy poradnik, ma raczej rozbudzać ciekawość i stanowić punkt odniesienia w dalszych poszukiwaniach. Jeśli szukasz dobrego, całościowego źródła wiedzy, polecam: „Practical Lisp” Petera Seibela.

spis treści:

  1. Podstawowe wyrażenia, zmienne, funkcje
  2. Pętle i inne makra
  3. Rozszerzanie funkcji, zmienne dynamiczne
  4. Paradygmat obiektowy: metody
  5. Klasy
  6. Składanie metod :around
  7. Składanie metod: dziedziczenie
  8. Makra leksykalne
  9. Podsumowanie

Podstawowe wyrażenia, zmienne, funkcje

Najpierw, absolutne podstawy. Po pierwsze, jeśli masz już interpreter CL (ja używam SBCL), możesz wszystko na bieżąco sprawdzić w trybie interaktywnym (REPL). To co pewnie każdy o Lispie wie, to dużo nawiasów, a to dlatego, że właściwie wszystkie konstrukcje w języku przyjmują formę listy elementów oddzielanych spacjami i otoczoną nawiasem (nie liczę komentarzy, które oznacza się znakiem ;).

Na przykład wywołanie funkcji:

(+ 3 5 8) ;; => 16

Nazwa funkcji zawsze jest na początku, jeśli chcemy połączyć dwie operacje, dochodzi kolejny nawias:

(* 2 (+ 2 3)) ;; => 12

To samo dotyczy operatorów specjalnych i makr. To z nich zbudowane są wszystkie konstrukcje w języku, więc:

(let ((x 4)) (+ x x))    ;; => 8

(defun mul2 (x) (* x 2));; definiuje funkcję
(mul2 21) ;; => 42

(defun fibonacci (x)
  (if (<= x 1) x
    (+ (fibonacci (- x 1)) (fibonacci (- x 2)))))

(fibonacci 5)    ;; => 5
(fibonacci 10)    ;; => 55

Konstrukcja let pozwala nam utworzyć zmienną lokalną (lub dynamiczną). W związku z tym, że jest operatorem specjalnym, stanowi odstępstwo od podstawowej składni. W następującym po niej nawiasie są wymienione symbole, które mają być powiązane z nowymi zmiennymi. Przeważnie te są umieszczone w kolejnym nawiasie, wraz z początkową wartością. Jest to wyjątkowa sytuacja, w której pierwszy element listy nie jest działaniem. Tego typu zachowanie wprowadzać mogą też makra, w tym defun, używany do definiowania funkcji. Innym przykładem wyjątkowego zachowania jest if. Jego wyjątkowość polega na tym, że dla każdej funkcji najpierw obliczane są wyrażenia każdego argumentu po kolei, a potem przekazywane jako parametry. W przypadku if najpierw wykonywany jest drugi parametr i w zależności od jego wyniku 3 lub czwarty. Podobny wyjątek stanowią makra or i and, przestają one obliczać kolejne wyrażenia gdy któreś już da wynik odpowiednio fałsz lub prawdę (w związku z czym wartość całego wyrażenia jest już znana).

Może pojawić się pytanie, czemu właściwie taka, niezbyt strawna, składnia? Po co tyle nawiasów? Okazuje się, że nie jest to niedopatrzenie, taka forma wygrała próbę czasu i są ku temu przyczyny. Przyczyny wyjaśniam to w innym artykule w rozdziałach o składni i szerzej, o makrach.

Pętle i inne makra

Makra pozwalają odejść od podstawowej składni i modelować ją wedle potrzeb. Między innymi, wszystkie pętle w CL są makrami i mają kilka różnych odmian. Dwie podstawowe:

(doList (x (list 1 2 3)) (print x))    ;; wypisze 1, 2 i 3.
(doTimes (x 3) (print (+ x 1)))        ;; tak samo

Podobnie, jak w przypadku let i defun, makro tworzy nam odmienny rodzaj składni, mamy nawias w którym na początku jest nazwa zmiennej, której użyjemy by odnosić się do kolejnego elementu listy czy numeru iteracji. Jest to oczywiście bardzo podstawowy przykład i mało spektakularny, tym bardziej, że nie jestem szczególnym wielbicielem takich konstrukcji. Wolę takie:

(mapcar (lambda (x) (print x)) '(1 2 3))

Też wypisze te 3 liczby, chociaż różni się tym, że wartość tego wyrażenia to '(1 2 3), zaś doList i doTimes zwracają nil (czyli nic). Przy okazji wprowadzam notację z pojedynczym cudzysłowem. Generalnie cokolwiek, poprzedzone nim, nie będzie interpretowane (żeby było ciekawiej, język daje możliwość definiowania podobnych modyfikatorów przy użyciu makr działających na etapie analizy leksykalnej — reader macros). Dlatego jeśli poprzedzimy nim listę, Lisp nie będzie próbować jej interpretować jako wywołanie tylko potraktuje jako gotową listę. Funkcja mapcar to jeden z wariantów typowej dla programowania funkcyjnego operacji map. Wykona ona funkcję z pierwszego parametru na każdym elemencie listy z drugiego parametru i zwróci listę wyników.

Kod w Lispie składa się z zagnieżdżonych list, to istotne gdy chcemy napisać makro:

(defmacro x (n) (list '+ 3 n))
(x 5) ;; => 8

Oczywiście możemy napisać:

(defmacro x2 (n) (+ 3 n))
(x2 5) ;; => 8

;; ale wtedy:

(let ((n 42)) (x n)) ;; błąd

Dzieje się tak dlatego, że makro dostaje symbol, więc podstawia symbol, a nie jego wartość. Natomiast funkcja + nie wie co zrobić z wartością typu SYMBOL. Za to, dla literału liczbowego, to makro od razu podstawi od razu wynik. Myślę, że lepiej pokazać to tak:

(macroexpand-1 '(x 5))    ;; wypisze (+ 3 5), zwróci T czyli true
(macroexpand-1 '(x2 5))    ;; wypisze 8, zwróci T

Bardziej rozbudowanym przykładem makra jest pętla loop. Jest to w zasadzie odrębny język programowania. Przyznaję, że w pierwszej chwili przeraża, i mnie przerażała. Jednak gdy zrozumiałem, że poza tym, że niczym nie przypomina reszty języka, jest wspaniałym narzędziem. Oto niewielki wycinek jej możliwości:

(loop for x = (apply loader '())
    until (apply (orf #'not (chunk-limiter this)) `(,x))
    collect x)

Jest to kawałek większego kodu, więc nie zadziała bez stosownych definicji. Mimo, to myślę, że w dużym stopniu mówi sam za siebie. Używa funkcji przekazanej parametrem loader (do tego służy funkcja apply, '() to pusta lista parametrów). W sekcji until mamy trochę skomplikowany warunek stopu. Znów mamy funkcję apply, ale tym razem używamy jej do wykonania funkcji zwróconej przez makro orf. Jest to makro, które napisałem i łączy funkcje operatorem or (o nim za chwilę). #' przed not oznacza, że not ma być rozumiane jako funkcja (w CL klasy, funkcje i zmienne mają odrębne przestrzenie nazw. Lista parametrów jest zapisana przy pomocy apostrofu. ma on znaczenie podobne do ', lecz pozwala, przy pomocą przecinka, oznaczyć elementy, które mają być jednak zinterpretowane. Innymi słowy, jedynym parametrem będzie x, czyli kolejna wartość z funkcji loader. Podsumowując, pętla będzie wykonywać się tak długo jak loader zwraca wartości inne niż nil lub wartość spełniającą warunek opisany przez funkcję, którą zwróci (chunk-limiter this). Taka konstrukcja się bierze stąd, że jest to fragment konstruktora. Natomiast ta wartość przerywająca różni się dla poszczególnych podklas. Wreszcie collect oznacza, że pętla zwróci listę wszystkich wartości x (poza ostatnią, która warunku nie spełniła).

Teraz zobaczmy jak zdefiniowane jest makro orf, zawiera ono zmienną ilość parametrów (tak by mogła działać niezależnie od ilości parametrów przyjmowanych przez funkcje. Trzeba jednak pamiętać, żeby wszystkie funkcje przyjmowały tę samą ilość parametrów, inaczej zwrócą błąd. Zawiera również inną konstrukcję pętli loop:

(defmacro orf (&rest funs)
    `(lambda (&rest args) ,(cons 'or (loop for x in funs
                    collect `(apply ,x args)))))

Jak widać będziemy zwracać funkcję anonimową o zmiennej ilości parametrów określonych przez args. Iterujemy po liście funkcji podanych w parametrze makra. W sekcji collect zaś definiujemy wyrażenie określające wywołanie tej funkcji z parametrami, które zostaną podane dopiero w momencie wykonania tej funkcji. Jednak sama lista poszczególnych wywołań to mało, więc używam funkcji cons, żeby dopisać na początek listy operator or. Znów, kod pewnie powie więcej niż kilka zdań prozy:

(macroexpand-1 '(orf #'not #'>))

;; (LAMBDA (&REST ARGS) (OR (APPLY #'NOT ARGS) (APPLY #'> ARGS)))

(let ((f (orf #'stringp #'listp)))     ;; predykaty typów
    (print (apply f '(3)))        ;; NIL
    (print (apply f '(nil)))    ;; T
    (print (apply f '((1 2 3)))))    ;; T

Dla precyzji jeszcze dodam, że nil to dokładnie to samo co '(), dlatego (listp nil) zwraca prawdę. Parametr, który pojawia się po znaczniku &rest zawiera listę wszystkich kolejnych parametrów. Natomiast funkcja apply pobiera wszystkie parametry w postaci listy. Z kolei konstrukcja loop ma jeszcze wiele opcji, pozwala na odliczanie w zakresie ze zmiennym krokiem, a nawet przeprowadzanie kilku iteracji na raz, jak również zwrócenie kilku kolekcji jednocześnie.

Innym sposobem na wykorzystanie makr jest stworzenie składni dla testów:

(defmacro test-case ((input test-fun expected-output) &body body)
    `(let ((output (apply (lambda (input) ,@body) '(,input))))
    (unless (apply #',test-fun (list ,expected-output output))
        (format t "TEST FAILED: ~A -> ~A != ~A~%    action: ~a~%"
            ',input ,expected-output output ',body))))

(test-case ("foo baria" string= "foo baria")
    (value (!+ 'string-iterator :< input)))

(test-case ("This is quite longer text :" equal '(#\h #\i #\s #\Space #\i #\
s))
    (let ((i (!+ 'string-iterator :< input)))
    (next i)
    (loop for x from 0 to 5
        collect (next i))))

Wówczas test jednostkowy przybiera formę nieco podobną do definicji funkcji, tylko, że zamiast parametrów, w pierwszej linii widnieją dane wejściowe, oczekiwany wynik i funkcja porównująca wynik. Pojawia się również konstrukcja &body - ,@body, typowa dla makr. &body to dokładnie to samo co &rest (wyraża tylko inne znaczenie). Zaś ,@body oznacza, że w danym miejscu zostanie wklejona nie lista body lecz cała jej zawartość.

Rozszerzanie funkcji, zmienne dynamiczne

Załóżmy że spodobało nam się programowanie aspektowe i chcemy rozbudować istniejącą funkcję. Albo może debugujemy funkcję w REPL i zrozumieliśmy, że zabrakło nam ważnego warunku. Generalnie CL pozwala ponownie zdefiniować funkcję o tej samej nazwie (nawet jeśli ma inne argumenty, nadpisze starą). Jest to zachowanie, które może być problematyczne, ale w tym wypadku możemy użyć tej cechy by napisać makro, które rozszerzy funkcję. Rozważmy poniższy skrypt:

(defmacro extend-fun (name args expr)
    `(let (oldfun) (setf oldfun #',name)
        (defun ,name ,args ,expr)))

(defun a (n) (* n n))
(print (a 3)) ;; 9

(extend-fun a (n)
    (+ n (apply oldfun (list n))))

(print (a 3)) ;; 12

Co zatem robi to makro? Tworzy wiązanie do nowej zmiennej, przypisuje do niej modyfikowaną funkcję i w jej zakresie definiuje funkcję na nowo, zgodnie z argumentami. W efekcie mam nową funkcję, która jest rozszerzoną wersją starej. Takie wiązanie zmiennych z funkcjami jest popularne w językach funkcyjnych i nazywa się po angielsku closure. Nie mam pojęcia, kto wymyślił polskie tłumaczenie domknięcie. Tu niczego się nie domyka tylko zamyka. Nie lepiej byłoby to nazywać wiązanką?

Skoro już jesteśmy przy zmiennych, jeszcze inna ciekawa cecha CL. Mianowicie, możemy przesłonić w jakimś zakresie wartość zmiennej globalnej, co więcej nie tylko w zakresie leksykalnym, również w wywoływanych funkcjach:

(defvar *what* "Lisp")

(defun greet () (format t "niech żyje ~A~%" *what*))

(greet) ;; Niech żyje Lisp!

(let ((*what* "Linux")) (greet)) ;; Niech żyje Linux!

Przy okazji, cecha, którą bardzo lubię, w CL, nazwa może składać się z czegokolwiek, co nie pomyli się z innym symbolem, tak więc nawiasów, dwukropka, cudzysłowu, apostrofu, przecinka, średnika i oczywiście białych znaków (chyba, że poprzedzimy je ukośnikiem w tył). Mogę nawet zdefiniować symbol o nazwie 1st, a nawet zażółć\ gęślą\ jaźń. Tu akurat użyta jest konwencja, zgodnie z którą zmienne globalne otaczane są gwiazdkami. Też funkcja format jest dosyć ciekawa. Pierwszy powód jest taki, że może być jednocześnie używana do pisania do pliku, do strumienia wyjścia (2 parametr t) i do zmiennej (2 parametr nil). Poza tym, jest to funkcja znacznie bardziej zaawansowana niż w C czy Javie. Parametr  A wystarczy, żeby wypisać wartość dowolnego typu. Natomiast posiada wiele innych właściwości, np. przepisy na wypisywanie list.

Wracając do funkcji, ktoś mógłby wpaść na pomysł, użyć makr by zaimplementować przeciążanie funkcji. Dałoby się, ale po co, skoro już są narzędzia do tego? Zanim do tego jednak dojdę, zobaczmy jak działają listy z argumentami nazwanymi, przydadzą nam się niebawem:

(defun a (&key foo bar baz)
    (if foo (print foo))
    (if bar (print bar))
    (if baz (print baz)))

(a)            ;; Nie zrobi nic
(a :foo 42)        ;; 42
(a :baz 11)        ;; 11
(a :foo 'boo :baz :and)    ;; BOO
            ;; :AND

Symbole zaczynające się od dwukropka to słowa kluczowe (keywords), nie trzeba ich nigdzie deklarować, zawsze można zamiast wartości zwrócić takie słowo kluczowe. Najbardziej typowym zastosowaniem jest właśnie w parametrach nazwanych. Również są przydatne do zaznaczania sytuacji wyjątkowych.

Paradygmat obiektowy: metody

Common Lisp wspiera programowanie obiektowe. Właściwie CLOS, pierwotnie istniejący niezależnie, później włączony do standardu Common Lispa, jest jedną z pierwszych implementacji programowania obiektowego. Jest to również trochę inne programowanie obiektowe niż to w Javie czy C++. Podobno jest bardziej jak Smalltalk (którego jednak nie znam), jest bardziej zgodny z tym co Alan Kay próbował wyrazić określeniem programowanie obiektowe. Jak sam wspomina w swoim wykładzie na OOPSLA 1997, mógł znaleźć lepszą nazwę.

W Javie i C++ pokutuje podejście, w którym klasy są w centrum zainteresowania. Klasy opisują typy danych, wraz z dołączonym zestawem metod, którymi można te dane uzyskać i manipulować nimi. W CL zaś, w centrum zainteresowania są metody, które implementują funkcje uogólnione (generic methods). Poszczególne metody jednej funkcji uogólnionej różnią się typami parametrów, które przyjmują:

(defgeneric sum(what))

(defmethod sum ((lst list))
    (if lst (+ (sum (first lst))
            (sum (cdr lst)))
        0))

(defmethod sum (x) x)

(print (sum '(1 2 3)))            ;; 6
(print (sum '(1 10 (15 (-10 2)) 42)))    ;; 60

Co ciekawe, to pierwszy raz, w którym używam funkcji cdr, która zwraca listę elementów listy poza pierwszym. Typowa dla funkcji rekursywnych jak ta. Oczywiście mógłbym napisać jedną funkcję:

(defun sum (x)
    (cond ((eql x nil) 0)
        ((listp x) (+ (sum (first x)) (sum (cdr x))))
        (t x)))

Skutek będzie taki sam, jednak pierwsza opcja jest łatwiejsza w zrozumieniu. Poza tym, co jeśli chciałbym wspierać ciągi znaków? Oczywiście mogę powiększyć funkcję, a mogę:

(defmethod sum ((x string))
    (sum (read-from-string x)))

(print (sum '(1/2 1/4 "3/4" "10"))) ;; 23/2

To dosyć proste, zasadniczo wiele się nie różni od innych języków. W dalszej części pokażę bardziej zaawansowane mechanizmy funkcji uogólnionych. W tym miejscu zaznaczę jednak fakt, że z tego podejścia wynika to, że 100% enkapsulacji w CL nie uzyskamy (w tym modelu, da się zaimplementować model obiektowy z pełną enkapsulacją), bo wszystkie metody mają równe prawa. Może się to nie podobać purystom, ale mi to nie przeszkadza. Taka jest filozofia tego język, do programisty należy nie robić głupstw.

Z drugiej strony takie podejście ma bardzo zasadniczy wpływ na kod jaki się pisze. Bardziej naturalne staje się grupowanie ze sobą implementacji tej samej funkcji uogólnionej, a nie metod tej samej klasy. Bardziej odpowiada to mojemu sposobu myślenia — to co chcę osiągnąć przede wszystkim, a dostępne środki na drugim miejscu. Co więcej, duże ilości podobnych metod mogą skłaniać do generowania ich za pomocą makr. Umieszczanie ich w klasach byłoby tu wysoce niepraktyczne.

Klasy

Oczywiście funkcje uogólnione, w pełni nabierają mocy gdy definiujemy różne klasy, które pozwalają automatycznie wybrać metodę dla określonej wartości. Klasę definiujemy przy użyciu konstrukcji defclass:

(defclass source ()
    ((file_name :initarg :name :initform "new.txt" :reader name)
    (lines :initarg :lines :initform '() :reader lines)))

(let ((instance (make-instance 'source :name "foo.lisp")))
    (format t "~a: ~a, ~a~%" instance (name instance) (lines instance)))

;; #<SOURCE {1003564B93}>: foo.lisp, NIL

Pusty nawias w pierwszej linii oznacza, że nie wskazujemy klasy nadrzędnej, czyli dziedziczymy tylko z klasy Standard-object, która jest klasą nadrzędną wszystkich innych, zdefiniowanych przez użytkownika. Ta zaś jest podklasą T, która jest nadrzędna klasą wszystkich pozostałych.

Zadeklarowaliśmy dwa pola: file_name i lines. Za pomocą :initarg wskazujemy słowo kluczowe, które może być użyte przy tworzeniu instancji, by wskazać wartość początkową pola. :initform ustala domyślną wartość pola. :reader nakazuje stworzyć metodę odczytu pola o danej nazwie. Żaden z tych atrybutów nie jest obowiązkowy i nie są to wszystkie możliwe.

Aby nadać temu bardziej użyteczny kształt, zdefiniuję dwie standardowe metody:

(defmethod initialize-instance :after ((this source) &key)
    (with-slots (file_name lines) this
    (if (not lines)
        (with-open-file (input file_name
            :direction :input :if-does-not-exist nil)
        (if input
            (setf lines (loop for l = (read-line input nil :eof)
                    until (eql l :eof)
                    collect l))
            (setf lines '(:does-not-exists)))))))

(defmethod print-object ((this source) out)
    (with-slots (file_name lines) this
    (format out "<SOURCE: \"~a\" (~a LOC)>" file_name (length lines))))

(let ((instance (make-instance 'source :name "x.lisp")))
    (format t "~a: ~a~% ~a~%" instance (name instance) (lines instance)))

;; <SOURCE: "x.lisp" (21 LOC)>: x.lisp
;; ( (defclass source ()
;; dalej cała treść pliku...

Metoda initalize-instance to standardowa metoda, zapewniająca inicjalizację, jest ona generowana automatycznie przez defclass. Słowo kluczowe :after oznacza, że nasz kod wykona się po standardowym konstruktorze. Gdyby go nie było, nadpisalibyśmy oryginalny. Jest to standardowa cecha metod: pod każdą nazwą, dla zestawu typów możemy mieć :before, :after i :around, które pozwalają na uzupełnienie zachowania metody, tak jak w tym przypadku. Nowa też jest konstrukcja (with-slots (<nazwy pól>) <objekt> …, jest to jeden z dwóch sposobów na dostęp do pola bez metody dostępu. W jej obrębie możemy się odnosić do pól po nazwie. Oczywiście mam zdefiniowaną metodę odczytu, ale i tak ta konstrukcja jest wygodniejsza.

W initialize-instance, sprawdzamy czy podano zawartość pliku (pole lines), jeśli nie, otwieramy plik do odczytu (ustaw input na nil jeśli plik nie istnieje). Używamy funkcji read-line aby wczytać w pętli całą zawartość pliku. Domyślnie, niepowodzenie kończy się błędem, dlatego oprócz nazwy strumienia podajemy nil :eof, co sprawi, że po natrafieniu na koniec pliku, funkcja zwróci :eof, co jest warunkiem stopu pętli.

Natomiast metoda print-object określa standardową reprezentację obiektu, jaka będzie używana przez funkcje takie format i print. Podobnie tak samo będą wyświetlane w debuggerze.

Składanie metod :around

Jak już wspomniałem, w CL możemy użyć metod pobocznych aby zmieniać zachowanie istniejących. Z jednej strony, pozwala to na dostosowywanie metod generowanych automatycznych, takich jak initialize-instance czy metody dostępu. Z drugiej, jest to pierwszy przejaw praktyki bardzo częstej w CL — składania metod. Oznacza to, że efektywna metoda składa się z kilku mniejszych. Dodanie :before i :after pozwala uzupełnić działanie metody, nie wpływając jednak na wartość zwracaną przez metodę.

Metoda :around zaś pozwala, w zależności od przypadku wykonać zupełnie inny kod, lub ten dotąd zdefiniowany. Ta opcja :around zadziałała najbardziej na moją wyobraźnię, bo daje bardzo ciekawe możliwości. Weźmy na przykład metodę make-instance. Często może się okazać, że wartości podane nas nie zadowalają i chciałbym wówczas zwrócić nil czy inną wartość. Dajmy na to, implementuję szachy i chcę mieć punkt na szachownicy:

(defclass chess-point ()
    ((x :initarg :x :reader x)
    (y :initarg :y :reader y)))

(defmethod make-instance :around ((class (eql (find-class 'chess-point)))
                    &key x y)
    (if (and (integerp x) (integerp y) (> x 0) (> y 0) (< x 9) (< y 9))
        (call-next-method)))

(defmethod print-object ((this chess-point) out)
    (format out "<CHESS-POINT: ~A, ~A>" (x this) (y this)))

(doList (case '((1 2) (2 2) (-1 1) (8 2) ("foo" "bar") () (1)))
    (let ((X (first case)) (Y (second case)))
    (format t "~A,~A -> ~A~%" X Y (make-instance 'chess-point :X x :y Y))))

;; 1,2 -> <CHESS-POINT: 1, 2>
;; 2,2 -> <CHESS-POINT: 2, 2>
;; -1,1 -> NIL
;; 8,2 -> <CHESS-POINT: 8, 2>
;; foo,bar -> NIL
;; NIL,NIL -> NIL
;; 1,NIL -> NIL

Rozszerzam metodę make-instance, która na ogół wywołuje initialize-instance Ta druga jednak ma na celu ustawienie stanu obiektu i jej wartość zwracana jest ignorowana. W liście parametrów metody pojawia się kolejna konstrukcja: (class(eql…)). Pozwala ona stworzyć metodę nie dla określonego typu parametru, ale wręcz określonej wartości. W ciele metody zaś pojawia się funkcja call-next-method i ona służy do przekazania kontroli kolejnej metodzie, która kwalifikuje się do wykonania. W tym wypadku będzie to metoda główna lub :before. Tej funkcji można użyć również w metodzie głównej i wówczas wywoła ona metodę zdefiniowaną dla klasy nadrzędnej. W wielu przypadkach znaczy ona tyle co super() w Javie, ale jest to mechanizm bardziej ogólny. Dzieje się tak dlatego, że klasa może dziedziczyć z kilku klas, które mają definicje dla tej metody. Wreszcie, możemy mieć metodę, która ma więcej parametrów z ustalonym typem. Ta właśnie metoda pozwala nam na składanie metod.

Innym ciekawym przypadkiem, jaki od razu przyszedł mi do głowy to automatyczne zwrócenie właściwej podklasy. Na przykład:

(defclass text ()
    ((text :initarg := :reader text)))

(defclass parsable (text)
    ((value :initarg :val :reader value)))

(defmethod value ((txt text)) (text txt))

(defmethod make-instance :around ((class (eql (find-class 'text))) &key =)
    (let ((value (read-from-string =)))
    (if (not (typep value 'symbol))
        (make-instance 'parsable := = :val value)
        (call-next-method))))

(defmethod print-object ((txt text) out)
    (format out "<~A: ~A>" (type-of txt) (value txt)))

(doList (case '("foo" "123" "(1 2 3)" "2/3" "value"))
    (print (make-instance 'text := case)))

;; <TEXT: foo> 
;; <PARSABLE: 123> 
;; <PARSABLE: (1 2 3)> 
;; <PARSABLE: 2/3> 
;; <TEXT: value> 

Wreszcie możemy w ten sposób wymusić konkretny typ parametru. Ma to jednak tylko sens, jeśli dopisujemy taki warunek później, bo przecież można samemu nadpisać funkcję dostępu:

(defclass <3integers ()
    ((value :initarg := :accessor value)))

(defmethod (setf value) (val (obj <3integers))
    (setf (slot-value obj 'value) (+ 9000 val)))

(defmethod (setf value) :around (val (obj <3integers))
    (with-slots (value) obj
    (setf value (if (integerp val) (call-next-method) 0))))

(defmethod initialize-instance ((this <3integers) &key =)
    (setf (value this) =))

(print (value (make-instance '<3integers := "not-a-number"))) ;; 0
(print (value (make-instance '<3integers := "32")))          ;; 0

(let ((inst (make-instance '<3integers)))
    (print (value inst))    ;; 0
    (setf (value inst) 42)
    (print (value inst))    ;; 42
    (setf (value inst) "52")
    (print (value inst))    ;; 0
    (with-slots (value) inst (setf value "foo"))
    (print (value inst))    ;; foo
    (setf (slot-value inst 'value) "bar")
    (print (value inst)))    ;; bar

Definicja metody zapisu ma dziwną składnię, ale można przywyknąć. To co jest bardzo istotne, ta metoda nie jest używana w domyślnej implementacji initialize-instance, dlatego też nadpisuję ją tak, by została użyta. Co więcej, jak widzimy, zawsze jest pozostawiona furtka dla ominięcia tego mechanizmu w postaci with-slots i slot-value. Moje zdanie na ten temat znacie.

Żeby było jeszcze ciekawiej, metody :around poprzedzają wszystkie metody podstawowe:

(defmethod print-object :around (x out)
    (format out "have a nice day,")
    (call-next-method)
    (format out "!~%"))

(print "foo")    ;; have a nice day,"foo"!
(print 123)    ;; have a nice day,123!

Składanie metod: dziedziczenie

A teraz proponuję taki mały eksperyment, który pokaże jak możemy składać metody w wielokrotnym dziedziczeniu. Jak można się spodziewać, Common Lisp daje możliwość zmiany tej kolejności, ale domyślnie jest tak:

(defclass master () ())
(defclass bro() ())

(defmacro cnm () `(if (next-method-p) (call-next-method)))

(defgeneric foo (x))
(defmethod foo ((x master))
     (format t "FOO!~%") (cnm))

(defmethod foo ((x bro))
    (format t "BRO!~%") (cnm))


(defmacro subclass (name &body parents)
    `(progn (defclass ,name (,@parents) ())
    (defmethod foo ((x ,name))
        (format t "foo for ~A!~%" ',name)
        (cnm))
    (defmethod foo :before ((x ,name))
        (format t "FOO 'fore for ~A!~%" ',name))
    (defmethod foo :after ((x ,name))
        (format t "FOO 'fter for ~A!~%" ',name))
    (defmethod foo :around ((x ,name))
        (format t "FOO 'round for ~A!~%" ',name)
        (cnm))))

(subclass woo master)
(subclass moo bro)
(subclass boo woo moo)

(foo (make-instance 'boo))

;; FOO 'round for BOO!
;; FOO 'round for WOO!
;; FOO 'round for MOO!
;; FOO 'fore for BOO!
;; FOO 'fore for WOO!
;; FOO 'fore for MOO!
;; foo for BOO!
;; foo for WOO!
;; FOO!
;; foo for MOO!
;; BRO!
;; FOO 'fter for MOO!
;; FOO 'fter for WOO!
;; FOO 'fter for BOO!

Jak widać wszystkie :round są przed :before, a te przed wszystkimi metodami głównymi. Wywołanie :before nie może zatrzymać ciągu wykonywania metod. Jednak to nie jest tak, że jeśli wywoła się :before to jeszcze nie znaczy, że wywoła się metoda główna. Jeśli usunąć wywołanie cnm z foo(master) to zobaczymy, że jeśli wywoła się metoda główna to wykona się metoda :after. Jak widzimy, metody :after wywołują się w odwrotnej kolejności.

Makra leksykalne

Ciekawym, niewątpliwie potężnym narzędziem w Common Lipie są makra leksykalne (reader macros). Przykładem takiego makra, jak już wspomniałem, jest np. znak ', który sprawia, że poprzedzony nim symbol nie jest interpretowany. Takie makra polegają na przypisaniu niestandardowej funkcji dowolnemu znakowi na etapie analizy leksykalnej.

Jest to narzędzie, którego trzeba używać z rozwagą, bo KAŻDE wystąpienie znaku będzie podlegać specjalnemu znaczeniu, o ile nie będzie on wczytany inaczej, innym makrem, w ciągu znaków, itp. Implementacja zaś takiego makra jest bardzo prosta, musimy tylko utworzyć funkcję, która otrzyma strumień wejściowy i pierwszy znak (gdybyśmy chcieli jedną funkcję przypisać kilku znakom). Funkcja ma zawracać symbol. Możemy odczytać ze strumienia dowolną ilość znaków.

Jak dotąd przydały się one do implementacji wyrażeń regularnych. Pisanie za każdym razem nazwy funkcji dopasowującej znak(i) do wzorca byłoby uciążliwe. Dlatego uznałem, że wygodnie będzie napisać makro leksykalne, które na zasadzie podobnej do klasycznych wyrażeń regularnych, będą budować odpowiednie funkcje:

(defun regex-atom-reader (stream char)
    (declare (ignore char))
    (let* ((c (read-char stream))
        (cc (hash[] c +char-classes+))
        (rs (hash[] (peek-char nil stream) +rep-specs+)))

    (unless cc (error "Unknown character class: ~S." c))

    (if rs (progn (read-char stream)
        (lambda (str pos)
            (apply rs (list str pos cc))))
        cc)))

(set-macro-character #\/ 'regex-atom-reader)

(apply /d+ '("1234 foo" 0)) ;; 4
(apply /d+ '("1234 foo" 5)) ;; NIL

Pełen kod można znaleźć na githubie. Przytoczona tutaj funkcja nie zawiera definicji stałych słowników +char-classes i +rep-specs+ i makra hash[]. To ostatnie tylko pobiera wartość ze słownika, z małą modyfikacją.

Zatem odczytuję pierwszy znak i szukam go w słowniku +char-classes+, podglądam kolejny by dowiedzieć się czy znajdę go w +rep-specs+. W zależności od sytuacji albo używa funkcji modyfikatora powtórzeń albo zwraca samą funkcję znaku. Bardzo proste, Efekt potężny.

Żeby było ciekawiej (i jeszcze zwiększyć możliwości zagmatwania kodu), Możemy te makra dowolnie zmieniać w czasie programu. Możemy, na przykład używać danego makra tylko w określonym kontekście. W tym artykule, implementowane jest makro pozwalające podawać wartości w formacie JSON bezpośrednio w kodzie. Możecie w nim zobaczyć, że na czas działania makra dodaje kilka kolejnych.

Podsumowanie

Opisałem tu najważniejsze, i najciekawsze cechy języka. Jak widać, jest to duży język z wieloma różnymi ciekawymi narzędziami i praktycznie nieskończonymi możliwościami tworzenia nowych. Widać również, że siła wielu z nich musi być używana z rozwagą, bo inaczej zamotamy się we własnym kodzie. Jedyną cechę, która odgrywa bardzo ważną rolę, a nie poświęciłem jej uwagi, to tryb interaktywny.

Myślę, że trzeba chwilę popróbować z nim, żeby zrozumieć, kiedy czego użyć, bo doprawdy wybór jest ogromny. Również te próby są konieczne, by dotarło do nas w pełni, co tak naprawdę dostajemy, jak to wpływa na naszą pracę. Dla mnie ten wpływ jest bardzo pozytywny, pisząc w Common Lispie robię znacznie mniej błędów, a praca jest bardzo przyjemna, z uwagi na dużą elastyczność rozwijania kodu i ciekawych możliwości, które odkrywa.