Czym urzeka Common Lisp?

Programuję nie od wczoraj i próbowałem wielu języków. Przez długi czas, języki programowania były dla mnie mniej lub lepszym rozwiązaniem problemu abstrakcji (nie licząc oczywiście assemblera, który jest fascynujący, ale mało praktyczny). Polubiłem C, bo dawał bardzo przewidywalną abstrakcję; polubiłem Perla za operacje na tekście i bardzo elastyczną składnię; polubiłem Scalę za świetne połączenie programowania funkcyjnego, obiektowego i składni możliwie podobnej do C/C++/Java, dzięki temu był łatwy do ogarnięcia, a jednocześnie wprowadzał jakość języka, który naprawdę wspiera paradygmat funkcyjny; polubiłem basha/zsh za to że jest moją powłoką.

Jednak dopiero Common Lisp zachwycił mnie tym jak jest pomyślany, jak kształtuje spojrzenie na problem. Bardzo go lubię za to jak potężne abstrakcje dzięki niemu tworzę, to uczucie gdy wprowadzenie nowej funkcjonalności często liczy się w symbolach, nie linijkach. Tak, wklepuję parę dodatkowych symboli i voilà! To naprawdę działa! Nie raz zapomnę o paru nawiasach, ale to pikuś ;-). Pod wpływem pierwszej fascynacji napisałem artykuł przedstawiający cechy tego języka, które najbardziej na mnie wpłynęły. W tym artykule chcę przejść do wniosków i wyjaśnić, czemu Common Lisp jest wspaniały (pomimo swoich podchwytliwych cech), a nie jest to zadanie łatwe, w końcu i ja potrzebowałem kilku podejść, żeby załapać. Celowo unikam wklejania kodu, do analizy, by było bardziej przystępnie. Jeśli chcesz wiedzieć, jak zrobić to o czym piszę, w tym drugim artykule o tym piszę.

W tym artykule:

  1. Czym Common Lisp (niesłusznie) odpycha?
  2. Dlaczego warto?
  3. Podsumowanie

Czym Common Lisp (niesłusznie) odpycha?

Myślę, że łatwiej docenić język jeśli się zrozumie, co właściwie jest w nim takiego odpychającego i dlaczego nie jest to argument za tym, żeby z niego zrezygnować. Przede wszystkim…

Składnia

(defmethod ~format ((input string-iterator) &optional
                (spechars *default-spechars*))
    (let ((n (next input)))
    (if n (let ((spechar (find n spechars :test #'=~)))
        (if spechar (~format (apply (action spechar)
                        `(,input)) spechars)
            (~format input spechars)))
        (value input))))

Gdy się spojrzy pierwszy raz: antyteza czystego kodu. Szczerze mówiąc i teraz bardzo ubolewam nad estetyką kodu w tym języku. Z drugiej strony wiem, że nawiasy w tym języku pełnią bardzo ważną funkcję, więc je toleruję. Po drugie, zdaję sobie sprawę, że to co dotąd uważałem za czysty kod, było w dużej mierze czystym kodem w Javie czy C, natomiast trzeba się nauczyć Lispa, żeby zrozumieć czym jest czysty kod w Lispie. I rzeczywiście, kod zawsze polega na pewnych konwencjach i podstawowych konstrukcjach, znając je, ten kod jest już znacznie mniej straszny.

Wreszcie ostatnie, może najważniejsze, Lisp jest jak Linux starej szkoły (czyli bardziej jak Arch niż Mint): domyślnie niezbyt piękny, ale bardzo elastyczny. Natomiast gdy się go dostosuje do swoich potrzeb, niezastąpiony. Cały czas dochodzę do tego jak pisać w nim co raz lepszy kod, jak tworzyć bardziej zwięzłe i zrozumiałe konstrukcje. Dotąd byłem uwarunkowany bardzo sztywną składnią do jakiej przyzwyczaiły mnie inne języki. Lisp zaś daje wolność. Przyjęcie wolności zawsze jest początkowo trudne. Znając już trochę lepiej jakie są możliwości wiem, że w Lispie można pisać piękny kod, ale to wymaga więcej prób.

Komunikaty o błędach

W tym komunikacie interesują mnie 2 linijki…

Unhandled UNBOUND-VARIABLE in thread #<SB-THREAD:THREAD "main thread" RUNNIN
G
                                        {10005E85B3}>:
  The variable CHAR is unbound.

Backtrace for: #<SB-THREAD:THREAD "main thread" RUNNING {10005E85B3}>
0: (POS-NOT-ESCAPED-IF #<FUNCTION WHITE?> #<(SIMPLE-ARRAY CHARACTER (706)) 
zachwycił mnie tym jak jest pomyślany,
jak kształtuje spojrzenie na problem. Bardzo go lubię za to jak potężne abst
rakcje
dzięki niemu tworzę, to uczucie gdy wprowadzenie nowej funkcjonalności częst
o... {1004B1AA7F}> 402)
1: ((LAMBDA (ITERATOR) :IN "/home/lwik/tekst/home/doc.lisp") <SPLIT-STRING-I
TERATOR @395 '(#1# #2# #7# #8# #9#'>)
2: ((:METHOD ~FORMAT (STRING-ITERATOR)) <SPLIT-STRING-ITERATOR @395 '(#1# #2
# #7# #8# #9#'> (<SPECHAR:'<'> <SPECHAR:'>'> <SPECHAR:'\'> <SPECHAR:'$'> <SP
ECHAR:'~'> <SPECHAR:'`'> <SPECHAR:'%'>)) [fast-method]
3: ((:METHOD >TAGS (PARAGRAPH)) <PARAGRAPH 'Programuję nie od wc'/0>) [fast-
method]
4: ((:METHOD >TAGS (KEYVAL-LINE)) <KEYVAL-LINE '(KEYWORDS programowanie, com
mon lisp)'/2>) [fast-method]
5: ((:METHOD >TAGS (KEYVAL-LINE)) <KEYVAL-LINE '(DATE 21.06.2019)'/3>) [fast
-method]
6: ((:METHOD WITH-CONTENT (DOCUMENT CHUNK)) #<DOCUMENT {10048E25A3}> <BLANK 
''/4>) [fast-method]
7: ((:METHOD INITIALIZE-INSTANCE :AFTER (DOCUMENT)) #<DOCUMENT {10048E25A3}>
 :FROM-FILE "it/lisp/why-lisp.txt" :BY-VAL NIL) [fast-method]
8: ((LAMBDA (SB-PCL::|.P0.| SB-PCL::|.P1.| SB-PCL::|.P2.|) :IN "/home/lwik/t
ekst/home/make.lisp") #<unavailable argument> #<unavailable argument> #<unav
ailable argument>)
9: (BUILD-DOC "it/lisp/why-lisp.txt" <TAG "a"  href="it/index.html": więcej 
artykułów>)
10: (BUILD-DOC "it/index.txt" NIL)
11: ((LAMBDA NIL :IN "/home/lwik/tekst/home/make.lisp"))
12: (SB-INT:SIMPLE-EVAL-IN-LEXENV (LET ((ARGV (CDR *POSIX-ARGV*))) (IF (EQL 
ARGV NIL) (PROGN (FORMAT NIL "Missing parameter") (QUIT))) (DEFUN GET-TITLE 
(CONTENT) (IF CONTENT (LET (#) (IF # # #)))) (DEFUN HEAD-FOR (FILE) (!+ (QUO
TE TAG) := "a" :& (SB-INT:QUASIQUOTE ("href" #)) :< "więcej artykułów")) (DE
FUN BUILD-DOC (F &OPTIONAL (HEADER NIL)) (LET ((DOC #)) (FORMAT T "build ~A~
%" F) (>F (OUTF F) (HTML DOC)) (IF (REFS DOC) (DOLIST # #)))) (DOLIST (F ARG
V) (LET ((*PWD* #)) (BUILD-DOC F)))) #<NULL-LEXENV>)
13: (EVAL-TLF (LET ((ARGV (CDR *POSIX-ARGV*))) (IF (EQL ARGV NIL) (PROGN (FO
RMAT NIL "Missing parameter") (QUIT))) (DEFUN GET-TITLE (CONTENT) (IF CONTEN
T (LET (#) (IF # # #)))) (DEFUN HEAD-FOR (FILE) (!+ (QUOTE TAG) := "a" :& (S
B-INT:QUASIQUOTE ("href" #)) :< "więcej artykułów")) (DEFUN BUILD-DOC (F &OP
TIONAL (HEADER NIL)) (LET ((DOC #)) (FORMAT T "build ~A~%" F) (>F (OUTF F) (
HTML DOC)) (IF (REFS DOC) (DOLIST # #)))) (DOLIST (F ARGV) (LET ((*PWD* #)) 
(BUILD-DOC F)))) 2 NIL)
14: ((LABELS SB-FASL::EVAL-FORM :IN SB-INT:LOAD-AS-SOURCE) (LET ((ARGV (CDR 
*POSIX-ARGV*))) (IF (EQL ARGV NIL) (PROGN (FORMAT NIL "Missing parameter") (
QUIT))) (DEFUN GET-TITLE (CONTENT) (IF CONTENT (LET (#) (IF # # #)))) (DEFUN
 HEAD-FOR (FILE) (!+ (QUOTE TAG) := "a" :& (SB-INT:QUASIQUOTE ("href" #)) :<
 "więcej artykułów")) (DEFUN BUILD-DOC (F &OPTIONAL (HEADER NIL)) (LET ((DOC
 #)) (FORMAT T "build ~A~%" F) (>F (OUTF F) (HTML DOC)) (IF (REFS DOC) (DOLI
ST # #)))) (DOLIST (F ARGV) (LET ((*PWD* #)) (BUILD-DOC F)))) 2)
15: ((LAMBDA (SB-KERNEL:FORM &KEY :CURRENT-INDEX &ALLOW-OTHER-KEYS) :IN SB-I
NT:LOAD-AS-SOURCE) (LET ((ARGV (CDR *POSIX-ARGV*))) (IF (EQL ARGV NIL) (PROG
N (FORMAT NIL "Missing parameter") (QUIT))) (DEFUN GET-TITLE (CONTENT) (IF C
ONTENT (LET (#) (IF # # #)))) (DEFUN HEAD-FOR (FILE) (!+ (QUOTE TAG) := "a" 
:& (SB-INT:QUASIQUOTE ("href" #)) :< "więcej artykułów")) (DEFUN BUILD-DOC (
F &OPTIONAL (HEADER NIL)) (LET ((DOC #)) (FORMAT T "build ~A~%" F) (>F (OUTF
 F) (HTML DOC)) (IF (REFS DOC) (DOLIST # #)))) (DOLIST (F ARGV) (LET ((*PWD*
 #)) (BUILD-DOC F)))) :CURRENT-INDEX 2)
16: (SB-C::%DO-FORMS-FROM-INFO #<CLOSURE (LAMBDA (SB-KERNEL:FORM &KEY :CURRE
NT-INDEX &ALLOW-OTHER-KEYS) :IN SB-INT:LOAD-AS-SOURCE) {1001B7157B}> #<SB-C:
:SOURCE-INFO {1001B71533}> SB-C::INPUT-ERROR-IN-LOAD)
17: (SB-INT:LOAD-AS-SOURCE #<SB-SYS:FD-STREAM for "file /home/lwik/tekst/hom
e/make.lisp" {1001B66923}> :VERBOSE NIL :PRINT NIL :CONTEXT "loading")
18: ((FLET SB-FASL::THUNK :IN LOAD))
19: (SB-FASL::CALL-WITH-LOAD-BINDINGS #<CLOSURE (FLET SB-FASL::THUNK :IN LOA
D) {7FFFF69A769B}> #<SB-SYS:FD-STREAM for "file /home/lwik/tekst/home/make.l
isp" {1001B66923}>)
20: ((FLET SB-FASL::LOAD-STREAM :IN LOAD) #<SB-SYS:FD-STREAM for "file /home
/lwik/tekst/home/make.lisp" {1001B66923}> NIL)
21: (LOAD #<SB-SYS:FD-STREAM for "file /home/lwik/tekst/home/make.lisp" {100
1B66923}> :VERBOSE NIL :PRINT NIL :IF-DOES-NOT-EXIST T :EXTERNAL-FORMAT :DEF
AULT)
22: ((FLET SB-IMPL::LOAD-SCRIPT :IN SB-IMPL::PROCESS-SCRIPT) #<SB-SYS:FD-STR
EAM for "file /home/lwik/tekst/home/make.lisp" {1001B66923}>)
23: ((FLET SB-UNIX::BODY :IN SB-IMPL::PROCESS-SCRIPT))
24: ((FLET "WITHOUT-INTERRUPTS-BODY-3" :IN SB-IMPL::PROCESS-SCRIPT))
25: (SB-IMPL::PROCESS-SCRIPT "home/make.lisp")
26: (SB-IMPL::TOPLEVEL-INIT)
27: ((FLET SB-UNIX::BODY :IN SAVE-LISP-AND-DIE))
28: ((FLET "WITHOUT-INTERRUPTS-BODY-36" :IN SAVE-LISP-AND-DIE))
29: ((LABELS SB-IMPL::RESTART-LISP :IN SAVE-LISP-AND-DIE))

unhandled condition in --disable-debugger mode, quitting

Za to między innymi nie lubiłem C++. Jedna literówka w szablonie potrafiła wygenerować kilka ekranów nic nie mówiących błędów. To była też jedna z niewielu rzeczy, jakie nie podobały mi się w scali. W Lispie jednak to filozofia jak sobie pościelesz tak się wyśpisz. To tak naprawdę dobrze, że interpreter zasypuje mnie długim i wyczerpującym backtracem, mówi przez to: „stary zaniedbujesz swój kod, czemu nie łapiesz wyjątków na bieżąco?”. W przypadku dopasowywania typów w C++ i Scali chyba za bardzo nie można nic poradzić, a w Javie można dostać bólu głowy, jeśli w każdej funkcji tworzyć blok try-catch. Jednak w Lispie nie jest to problem, ponieważ kilka linijek zajmuje napisanie makra, które stworzy funkcję z wbudowaną obsługą błędów. Nazwę je dajmy na to defun* i w efekcie taka gwiazdka w definicji funkcji będzie oznaczać, że jest to funkcja warta wspomnienia w komunikacie o błędzie.

Jednak znowu — Common Lisp jest językiem bardzo złożonym, praktycznie nie możliwe jest od razu pisać w nim kod-majstersztyk. Należy jednak pamiętać, że właściwie każde zachowanie interpretera można zmodyfikować, trzeba tylko wiedzieć jak. Dlatego też, na początek, trzeba trochę cierpliwości. Niebawem zrozumiesz, że w 16 linijek kodu zmieścisz interpreter Lispa działający na potokach. Ten akurat napisałem do integracji z vimem i działa jak należy:

(print bla)
The variable BLA is unbound.

Natomiast pomyśl, że możesz zagnieździć taki interpreter w obsłudze wyjątku. Dla Lispa to jednak zachowanie domyślne (choć jak zwykle, domyślnie jest bardzo nieprzyjemne). Natomiast jeśli zauważysz, że i te czynności są powtarzalne, i je możesz oskryptować. I to jest duch Lispa: wielokrotne nakładanie abstrakcji. Zasadniczo, nie ma w tym języku działania, którego nie dałoby się umieścić w abstrakcji. I również prawdą jest, że w innych dynamicznych językach, jak Perl czy JavaScript też jest to możliwe, jednak ze względu na właściwości Lispa jest to łatwiejsze (o tym w rozdziale o makrach).

Jest językiem-środowiskiem

Większość języków programowania działa w trybie napisz-skompiluj-uruchom, podobnie działa też większość języków interpretowanych. Takie języki wyrabiają silne przywiązanie do idei kodu, który mamy otwarty w edytorze i on jest bazą naszego zrozumienia programu nad którym pracujemy. Zauważyłem, że ta właśnie cecha sprawia, że takie (skąd inąd wartościowe i bardzo ciekawe) języki jak Smalltalk, Prolog czy właśnie Lisp odpychają, tylko dlatego, że kierują się trochę inną filozofią. Może Lisp nie jest jeszcze tak skrajnym przypadkiem, ale wiele jego blasków docenia się dopiero wtedy gdy zaczniemy korzystać z przystosowania języka do działania w trybie REPL(Read-eval-print loop). Wówczas możemy, na przykład, załadować nie do końca działający program i krok po kroku przeanalizować problem i wypróbować różne rozwiązania.

Innym ciekawym zastosowaniem REPL może być dynamiczne dostosowywanie programu w locie. Dajmy na to, że mam zaimplementowaną aplikację HTTP i chcę dostosować style. Popularny moduł zapewniający serwer HTTP, hunchentoot domyślnie uruchamia serwer w odrębnym wątku, dzięki czemu może działać równolegle z REPL. Domyślny arkusz styli umieściłem w zmiennej globalnej i napisałem sobie proste makra do manipulacji nim. Oczywiście można się kłócić, że przecież łatwo jest otworzyć plik stylu. No tak, tylko, że arkusze styli lubią się rozrastać i gmatwać, a tu wystarczy napisać np.:

(+css "h1" "font-size" "2em")

Jeśli tego jeszcze było mało, wszelkie stałe w skryptach JS też możemy tak zmienić. Można się nawet zrobić tak, że jedną komendą przełączasz wszystkie skrypty albo wybraną funkcję w tryb szukania błędów. Oczywiście, że nic nie stoi na przeszkodzie wejść głębiej, zmieniać poszczególne funkcje, klasy, wszystko. Innymi słowy możemy wykonać migrację do nowej wersji w locie. Możemy też uruchomić tryb interaktywny w dowolnym miejscu w kodzie, aby zobaczyć co się tam właściwie dzieje.

Nie zabrania

Współczesne języki programowania przyzwyczajają do tego, że konstrukcja języka dokłada wszelkich starań, żeby możliwie duża część zachowań potencjalnie niebezpiecznych była zabroniona lub co najmniej bardzo utrudniona. Z tego co dotąd odkryłem, Lisp zabrania tylko nadpisywać definicje symboli zdefiniowanych przez język (choć zasadniczo i to ograniczenie dałoby się pewnie obejść). Poza tym, mamy pełne pole do popisu jeśli chodzi o zaciemnianie kodu lub głupie błędy. Przedefiniować funkcję? Bez problemu, każda kolejna definicja nadpisze starą. Zmienne globalne? Zmienny stan? No pewnie, mówiąc szczerze, Lisp wyleczył mnie z alergii na nie. Spójrzcie na słowo kluczowe :around! Przecież on pozwala na modyfikację zachowania KAŻDEJ metody jaka jest, w dowolnym miejscu programu! W tym samym rozdziale pokazuję, że Common Lisp zasadniczo nie zapewnia pełnej enkapsulacji, bo pozwala na dostęp do dowolnego pola dowolnej klasy z pominięciem wszelkich metod dostępu z nimi związanych. To, znaczy, domyślna implementacja na to nie pozwala. Nic nie stoi na przeszkodzie, żeby zaimplementować paradygmat obiektowy na swój własny sposób, który taką enkapsulację zapewni.

To wszystko dlatego, że Lisp jest językiem dla doświadczonych programistów, którzy wiedzą co robią, którym nie stoi nad głową janusz z batem i nie piszą kolejnego CRUDa, tylko rozwiązują poważne problemy, które wymagają poważnego języka. Takim językiem jest właśnie Lisp. Dzięki temu, że nie zabrania, pozwala robić rzeczy niezwykłe, budować potężne abstrakcje, kosztem dyscypliny jaką programista sam na siebie nakłada. Nie ulega pokusie zepsucia pięknego czysto funkcyjnego kodu zmienną globalną. Poza tym, jeśli programista nie rozumie co robi, w Lispie zagmatwa kod tak jak trudno zagmatwać w innym języku. Jak rozumiem, są podstawowe przyczyny dla których Lisp nie jest popularny, w korporacji pod dużymi naciskami taki język nigdy się nie sprawdzi.

Trochę niespójny i przestarzały

Jest to język stary i można zauważyć, że starsze elementy języka nie są do końca spójne z resztą. W starszej części mamy typy i funkcje. Model obiektowy dodaje klasy i funkcje uogólnione umożliwiające statyczne typowanie i polimorfizm. Zasadniczo, nie widzę przeszkód, żeby wszystkie funkcje stały sie uogólnionymi. Przecież mogę napisać metodę, która będzie przyjmować wszystkie możliwe typy argumentów (jak to jest w funkcji), a zatem funkcja jest podzbiorem metody uogólnionej. Byłoby to duże udogodnienie, jeśli moglibyśmy integrować nasze klasy z elementarnymi funkcjami. Niestety, te funkcje już na zawsze pozostaną funkcjami. Język pewnie wiele by zyskał, gdyby stał się w pełni obiektowy i nie było takiego rozdźwięku.

Innym przykładem zachowania, który dałoby się łatwo zaimplementować, a nie występuje domyślnie jest currying. W przypadku podania za małej ilości parametrów mogłaby być po prostu zwrócona funkcja ze stałymi parametrami, które podano. Nie stałoby też nic na przeszkodzie, gdyby można było podać specjalne słowo kluczowe zamiast parametru, żeby zaznaczyć, że to ten parametr powinna przyjąć taka funkcja pochodna. Takie zachowanie da się stosunkowo prosto zaimplementować, ale nie dla funkcji wbudowanych i, generalnie, tylko na żądanie.

Piszę to, żeby zwrócić uwagę na zjawisko, zachować obiektywność. Znaczy to głównie tyle, że Common Lisp nie jest językiem idealnym, jednak nie umniejsza to znacząco jego potęgi. Z resztą, słynie on z metaprogramowania, więc stosunkowo małym wysiłkiem pewnie dałoby się zaimplementować dialekt, który takie zmiany wprowadza, na podstawie istniejącego już języka. Natomiast kwestia zajętych nazw jest o tyle łatwa do obejścia, że nazwy w Lispie mogą zawierać znaki specjalne, więc możemy po prostu dostawić jeden z nich, na znak, że to nasza własna implementacja.

Dlaczego warto?

W rzeczy samej, Lisp jest językiem skazanym na niszowość, prawdopodobnie zawsze będzie narzędziem programisty z co najmniej dekadą doświadczenia i generalnie będzie mniej więcej tak popularny jak assembler. Z drugiej strony, choć nie każdy może osiągnąć biznesową efektywność programowania w Lispie, uważam, że każdy programista może wiele zyskać na doświadczeniu go i zmienić swoje spojrzenie na programowanie na zawsze. Jest to tym bardziej prawdziwe, że współczesne języki programowania co raz bardziej zbliżają się swoimi możliwościami do Lispa, chociaż tylko Lisp ma tak szaloną, liberalną składnię, która pokazuje programowanie od zupełnie innej strony i to nowe spojrzenie można zastosować właściwie każdym języku programowania ogólnego przeznaczenia.

Dynamiczne typowanie pod kontrolą

Na rozgrzewkę, prosta rzecz, typowanie. Generalnie, Lisp jest językiem bardzo dynamicznym, w tym, dynamicznie typowanym. Wartości mają swoje typy, lecz zasadniczo żaden fundamentalny mechanizm języka się tym nie interesuje. Zmienna to zmienna, funkcja to funkcja, klasa to klasa i to jedyna kontrola typu, która działa na najniższym poziomie i wynika głównie stąd, że te elementy mają odrębne przestrzenie nazw, a składnia określa jaki element jest oczekiwany w danym miejscu. Oczywiście, większość funkcji oczekuje konkretnych typów, jednak możliwie elastycznie, żeby możliwie często funkcja działająca na tekście mogła działać też na listach i innych kolekcjach. Mamy, oczywiście też funkcje sprawdzające typy, abyśmy też mogli kontrolować typy. Zasadniczo, jak w każdym języku dynamicznie typowanym.

Jednak do tego mamy jeszcze funkcje uogólnione, które pozwalają rozdzielić implementację dla każdego typu osobno (te implementacje nazywamy metodami). Jest to bardzo potężny mechanizm pozwalający, w zależności od potrzeb, regulować poziom restrykcji typów. Nie raz zdarza się pisać funkcje tak ogólne, że będą działać na wielu różnych typach; czasami dzielimy jedną funkcję na dziesiątki metod, by mogła wspierać wszystkie możliwe typy, a jednocześnie nie tworzyć zbyt dużych funkcji. W skrajnym wypadku możemy zaimplementować odrębną metodę dla szczególnej wartości parametru, ale o tym komponowaniu metod w rozdziale następnym.

Common Lisp Object System

Aby dać rozsądną kontrolę nad typami, Common Lisp posiada bardzo ciekawą implementację paradygmatu obiektowego. Co ciekawe, jest to trochę inna obiektowość niż ta, którą znamy z popularnych języków. O ile dobrze rozumiem jest to model bliższy temu co Alan Kay miał na myśli tworząc to pojęcie i temu co zaimplementował w Smalltalku. Różnica zasadniczo jest niewielka, jednak ma bardzo zasadnicze konsekwencje.

Mianowicie, metody nie są własnością klas. Klasa jest jedynie definicją struktury danych i relacji w stosunku do klas nadrzędnych, natomiast metody z nimi powiązane nie zyskują większego dostępu do ich pól. Natomiast metody implementują funkcje uogólnione (ang. generic functions) i wywołując nazwę takiej funkcji, odpowiednia metoda zostanie wywołana na podstawie typów parametrów. Można tak zrobić w C++, ale jednak konwencja podpowiada zawrzeć funkcję w klasie aby można było zabezpieczyć jej wewnętrzny stan i skorzystać ze specjalnej składni dla metod.

Robiłem tak wielokrotnie i zawsze myślałem, że na tym polega programowanie obiektowe. Common Lisp pokazał mi, że ma to swoją cenę: ogranicza to użycie polimorfizmu. I to na dwa różne sposoby. Po pierwsze, często używam następującej konstrukcji:

(defmethod foo ((a list)) ;; metoda foo dla a typu listowego
    (loop for item in a
        collect (foo item)))

Wyraża to dokładnie tyle, że wywołanie funkcji na liście oznacza zastosowanie jej dla każdego jej elementu z osobna. Znów, inne języki na to pozwalają, jednak nie jest to rzecz powszechna czy polecana. Natomiast w Lispie jest to coś co nasuwa się od razu (prawdopodobnie również przez to, że jest językiem dynamicznie typowanym).

Druga sprawa jest taka, że pisząc w Common Lispie, naturalną sprawą jest umieszczać razem implementacje tej samej funkcji uogólnionej, podczas gdy w innych językach akcentuje się ich odrębność poprzez umieszczanie ich w odrębnych klasach, często innych plikach.

Wygodnie jest mieć wszystkie implementacje tej samej funkcji w jednym miejscu, bo w dobrym kodzie, zmiana dotycząca danego mechanizmu wykona się w obrębie tej jednej funkcji uogólnionej. W efekcie, zmiana obejmuje jeden plik, jeden blok kodu wręcz. Mniej szukania, łatwiejsze do ogarnięcia.

Jest to składnia nastawiona na czytelność, gdy chcę zrozumieć co symbol (nazwa funkcji) oznacza, mam wszystkie możliwości w jednym miejscu! Poza tym, w takiej sytuacji łatwiej redukować powtórzenia. Jeśli mamy w kilkunastu klasach (w sensie C++/Java) jedną powtórzoną linijkę to nie kiwniemy nawet palcem, w Common Lipie takie powtórzenia, występując jedno po drugim aż rażą w oczy i od razu narzuca się użyć któregoś z rodzajów komponowania metod, aby usunąć powtórzenia. W rzeczy samej, programowanie w Common Lipie pokazuje, ile duplikacji kodu się robi i daje cały wachlarz różnych rozwiązań dla tego problemu. Co ciekawe, gdy zastosujemy je, okaże się, że przecież można to zrobić również w innych językach.

To co dotąd opisałem to część, która jest dostępna we właściwie każdym języku obiektowym. Teraz czas na to co wyróżnia Common Lisp: Składanie metod, już wspomniane wcześniej. Są dwa podstawowe sposoby składania metod.

Najbardziej podstawowy sposób składania metod jest dostępny w innych językach i polega na wywołaniu klasy nadrzędnej. W CLOS jest to jednak mechanizm bardziej złożony i potężny. Przede wszystkim, Common Lisp wspiera wielokrotne dziedziczenie, dlatego w przypadku Common Lispa mówimy o wywołaniu następnej metody, która pasuje do danych typów parametrów. Najpierw są rozpatrywane metody operujące na klasach bardziej szczegółowych, a potem kolejne klasy nadrzędne (w przypadku wielokrotnego dziedziczenia, sprawa się trochę komplikuje). Pomimo tej drobnej różnicy, idea jest podobna — tworzymy klasy pochodne, by zróżnicować zachowanie metody. Tylko tu, mamy mamy model bardziej ogólny, w którym poszczególne metody nie polegają na sobie, lecz na wszystkich razem. Takie patrzenie na sprawę ułatwia późniejsze dodanie nowej metody.

Jednak czasami, chcemy zaimplementować sytuację odwrotną: wspólne działanie dla wszystkich podklas, choćby w przypadku gdy chcemy zapisać jakąś wartość, ale od podklasy zależy jaką. Wówczas mamy do dyspozycji metody :before, :after i :around. Dwa pierwsze pozwalają wykonać kod przed i po każdym wywołaniu, które pasuje do wymienionych typów parametrów (zwracana wartość jest ignorowana). Trzecia zaś pozwala zagnieździć kod z klasy dziedziczącej w kodzie z klasy nadrzędnej.

Oczywiście, to samo można zrobić w innych językach dzieląc metodę, tworząc interfejs w którym wywoływana jest metoda z klasy nadrzędnej i ona wywołuje metodę definiowaną przez podklasy (np. metody start i run w klasie Thread z Javy). Z mojej jednak obserwacji, bezpośredniość narzędzi proponowanych przez Common Lisp sprawiają, że rozwiązanie jest bardziej ewidentne, estetyczne i elastyczne.

Poza tym, w żadnym innym języku nie sortowałem implementacji tej samej metody, żeby znaleźć dobrą abstrakcję. W Javie, która zachęca do umieszczania każdej klasy w odrębnym pliku, nawet o tym bym nie pomyślał. W Common Lispie wyszło naturalnie. Tak samo, zauważyłem, że stworzenie klasy w Javie czy C++ nie przychodzi tak łatwo jak w Common Lispie. Może to przez zasady właściwego kodowania obiektowego, może przez składnię, z jakiegoś powodu w tych językach, tworzenie klasy dla zdefiniowania jednej metody nie wydawało mi się właściwe (choć w Scali już się zdarzało). To czego zaś nie można zrobić (albo można w ograniczony sposób) w statycznych językach — to dostosować istniejące klasy do współpracy z naszą, w obrębie ich metod.

Makra

Przeważnie makra wymienia się, bardzo słusznie, jako rzecz najbardziej potężną i wyróżniającą Lispa spośród innych. Jednak postanowiłem zacząć od rzeczy bardziej przypominającej inne języki, żeby dać przedsmak tego jak programowanie w Lispie wygląda. To dlatego, że choć pełnia podobną funkcję, są czymś zasadniczo innym niż makra C czy szablony (ang. templates) w Javie i C++. Ich piękno i potęga polega na tym, że makra w Lispie są napisane w Lispie. Są to zasadniczo funkcje (z paroma dodatkowymi konstrukcjami), które mogą wywoływać inne funkcje i makra (również rekurencyjnie). Są w pewnym sensie diametralnie inne od funkcji, bo są wykonywane w czasie kompilacji, przez co, między innymi, symbole podawane im w parametrach są przekazywane takie jakie są (nie zaś ich wartości jak w przypadku funkcji). Tak czy inaczej mamy do dyspozycji pełną moc języka, żeby wygenerować kawałek kodu.

Możemy na przestrzeni całego kodu zbudować listę wystąpień danego makra i używać potem w kodzie. Takie zachowanie moglibyśmy wykorzystać do rozdzielenia większej implementacji na mniejsze kawałki. Nie musielibyśmy wówczas myśleć o dodaniu nowej funkcji w jakimś wyrażeniu, żeby była rozpatrywana. Od razu nasuwa się przeciążanie funkcji, wzorzec fabryki, itp.

W tym momencie dochodzimy do roli nawiasów w tym wszystkim. Nawiasy oznaczają listę, lista jest podstawowym typem danych w Lispie i każde wyrażenie ma formę listy. Dlatego mówimy, że Lisp jest homoikoniczny. Jest znacznie łatwiej napisać funkcję, która pisze program, jeśli ten składa się z podstawowego typu danych. Dzięki temu też prawidłowe makro w Lispie nie ma prawa wygenerować kodu leksykalnie niepoprawnego (jeden z mankamentów makr w C), może co najwyżej umieścić symbol danego typu w złym miejscu.

Nawiasy mają też inną cechę, którą zauważyłem. Dosyć specyficznie kształtują rozumienie kodu. Sprawiają, ze składnie listy, wyrażenia, definicji zmiennej, funkcji czy klasy są na pierwszy rzut oka takie same. Oczywiście, pierwszy element listy zawsze wskazuje, co dana lista znaczy, jednak przypomina — nie ważne co to jest, nie jest to niczym wyjątkowe w tym, że podlega możliwości automatycznego wygenerowania.

W nowoczesnych językach można zwrócić funkcję anonimową, ale nie klasę. Prawda, większość języków pozwala tworzyć szablony klas i funkcji, jednak to tylko najbardziej podstawowa rzecz, jaką można zrobić makrem. Na przykład, za pomocą makra stworzyłem następującą składnię dla testu jednostkowego:

(test-case ("12345678" equal '("123" "4" "5678"))
    (value (push-back (split-text (!+ 'string-iterator :< input) 3) "4")))

W pierwszej linii, dane wejściowe, funkcja porównująca i oczekiwany wynik, dalej następuje testowane wyrażenie. W tym przypadku jest to wyrażenie dzielące napis na listę jego części. Jednocześnie, bezpośrednie nawiązanie do definicji funkcji. Oczywiście nic nie stoi na przeszkodzi, żeby takie makro zdefiniowało jednocześnie funkcję przeprowadzającą test, ale też dodało ją do listy, która jeszcze na etapie kompilacji byłaby umieszczona w funkcji zbierającej wszystkie testy razem i uruchamiała je. Przykładową implementację tego makra możecie znaleźć w drugim artykule.

Tak naprawdę, wciąż odkrywam ten język i możliwości makr. Mój kod wciąż jest daleki od ideału, jednak powołując się na „Beating the Averages” Paula Grahama: Kod źródłowy edytora Viaweb w około 20-25% składał się z makr. Makra pisze się trudniej niż zwykłe funkcje w Lispie i zasadniczo uważa się je za błąd stylistyczny, jeśli nie są konieczne. W związku z tym każde z nich było tam, bo musiało być. Innymi słowy, około 20-25% tego programu nie dałoby się łatwo napisać w innym języku.

Jest kompozycją

To co czyni Lisp językiem niezwykłym i bardzo pouczającym jest to, że w odróżnieniu od innych języków, jest skomponowany z niewielkich, podstawowych części. Prawdopodobnie wynika to stąd, że Lisp jest tak stary, ale został zbudowany na genialnych podstawach, dzięki czemu, 60 lat później, wciąż jest najpotężniejszym językiem programowania, bo z łatwością adaptował nowe koncepcje.

Można to łatwo zaobserwować na innych językach. Konstrukcje takie jak klasy czy szablony są wbudowane w kompilator (interpreter, maszynę wirtualną), a zatem cechy te są granicą ich możliwości. Można to dobrze zobaczyć na przykładzie prób zaimplementowania paradygmatu funkcyjnego w Javie. Próbowałem, to jest jakiś żart. Zdarza mi się pisać kod w Javie, ale nigdy nie używam tej pomyłki, szkoda mi nerwów. Ten język jest do szpiku kości imperatywny i już chyba nigdy się tego nie zmieni. Tym bardziej, że scala świetnie rozwiązuje ten problem.

Oczywiście nie wszystkie języki są dotknięte tym problemem w równy sposób, np. Perl5 jest bardzo elastycznym językiem i za to go lubiłem, programowanie obiektowe w nim nie jest żadnymi czarami tle innych jego właściwości. Z drugiej strony, w porównaniu z Lispem są to rozwiązania bardzo proste. Poza tym, ten język chyba jest za prosty, żeby być tak potężnym. Przez to jest chyba najbardziej znienawidzonym obecnie językiem.

Nie jestem wprawdzie ekspertem wnętrzności języka, programuję w nim od kilku tygodni, jednak fakt, że już mniej więcej układa mi się w głowie jak zaimplementować w nim paradygmat obiektowy z pełną enkapsulacją jest dla mnie dostatecznym świadectwem, jest to w rzeczy samej proste, a sposobów bardzo wiele. Pewnie nie trudno się domyśleć, że wszystko dzięki makrom. Ciekawe jest to, że w Lispie wszystkie pętle są zaimplementowane jako makra, w tym pętlę loop o imponujących mozliwościach.

Zaś u jego podstaw, oprócz makr, są przede wszystkim bardzo ciekawe operatory specjalne. W szczególności zaintrygowały mnie operatory tagbody i go. Kiedy dowiedziałem się, że pętle są makrami zastanowiło mnie to. Ale jak? goto w Lispie? Ale jak? Odpowiedź brzmi: bardzo elegancko:

(tagbody
    test (if (string= (read-line) "foo") (go foo) (go bar))
    foo (format t "this is foo~%") (go test)
    bar (format t "no fooo! Bye~%"))

Przy czym, jak widać nie pozwala skoczyć gdziekolwiek, co jest raczej dobrą decyzją projektową. Całkiem ciekawy jest też operator load-time-value, który spowoduje uzyskanie wartości w czasie ładowania programu (innymi słowy, tylko raz).

Podsumowanie

Common Lisp jest zdecydowanie językiem, który trzeba dostosować do swoich potrzeb, by dobrze pełnił swoją funkcję. Jako, że jest jednym z najstarszych języków w ciągłym użytku (lata 50-te, chyba tylko Fortran pochodzi z tych czasów, BASIC jest niewiele młodszy), a mimo to, z łatwością pozwala zaimplementować najnowsze koncepcje w programowaniu, niewątpliwie świadczy to o dobrej jego konstrukcji i potędze metaprogramowania. Z uwagi na tę konstrukcję, wspomaga dobrą organizację kodu, redukującą powtórzenia do minimum. Zdecydowanie zmienia spojrzenie na programowanie w ogóle.

Większość rzeczy, których nauczyłem się programując w Lispie mogę wykorzystać właściwie w dowolnym języku programowania. W dzisiejszych czasach, potęga Lispa już nie znaczy tyle, bo inne języki znacznie się rozwinęły, proponują większość cech, które są potrzebne przy większości projektów. To czego, jednak, żaden inny język nie daje, to nieograniczone możliwości w tworzeniu abstrakcji, co jest świetnym narzędziem dydaktycznym i nieskończonym źródłem radości dla programisty-perfekcjonisty. Poza tym, doświadczenie języka-środowiska z pewnością poszerzy horyzonty każdemu, kto nigdy nie programował w ten sposób.

Oczywiście, potęga Lispa ma swoja cenę i nie bez przyczyny jest językiem niszowym. W dużej mierze, jego siła wiąże się z tym, że Lisp mało czego zabrania, pozwala programować w każdym paradygmacie. Natomiast jego unikalne cechy, bardzo przydatne w budowaniu dobrej abstrakcji, użyte niewłaściwie mogą posłużyć niesamowitemu zagmatwaniu kodu. Dlatego też Lisp, jako narzędzie pracy, nadaje się dla doświadczonego programisty, który nie potrzebuje by dyscyplina pracy była mu narzucona przez język. Jednak, by poczuć tę moc, polecam każdemu nauczyć się w nim programować.

Komentarze? Pytania? Sugestie? Współpraca? Napisz.