Sytuacja kobiet w IT w 2024 roku
24.11.20199 min
Michał "phoe" Herda

Michał "phoe" Herda

Czym jest Lisp?

Dowiedz się, co sprawia, że jeden z najstarszych języków programowania jest wyjątkowy.

Czym jest Lisp?

Lisp jest bardzo starym i wpływowym językiem, a właściwie rodziną języków. Powstał w roku 1958 - wtedy, kiedy Amerykanie wystrzelają na orbitę swoich pierwszych sztucznych satelitów, Nikita Chruszczow zostaje premierem Związku Sowieckiego, a Carrefour i Pizza Hut otwierają pierwsze placówki na świecie. Na przestrzeni dekad odgałęziło się wiele dialektów Lispu. Praktycznie wszystkie w dzisiejszych czasach uważa się za pozostające poza głównym nurtem programowania.

Lisp był jednym z pierwszych języków wspierających programowanie symboliczne - czyli operacje na abstrakcyjnych obiektach i strukturach danych zamiast zmiennych liczbowych i tablic z wartościami numerycznymi. Za pomocą list i symboli można było budować drzewa i inne skomplikowane struktury. Eksperymentowano też ze wczesnymi systemami obiektowymi. Mówimy tu o czasach, w których o strukturze kontroli IF pisało się prace doktorskie, a języki programowania z zasady nie wspierały tekstu. Nie bez powodu Lisp szybko stał się jednym z ulubieńców wśród badań nad sztuczną inteligencją. Jego autor, John McCarthy, był ważną postacią w świecie sztucznej inteligencji (łącznie z wybraniem nazwy dla dziedziny).

Za najpopularniejsze w dzisiejszych czasach dialekty uważa się Common Lisp, w którym się specjalizuję, Racket i Clojure bazujący na JVM. Istnieją też różne jego odmiany: Scheme, PicoLisp, Emacs Lisp do skryptowania edytora Emacs, uLisp do programowania mikrokontrolerów, LFE osadzone na maszynie wirtualnej Erlanga, Hy bazujący na Pythonie, czysto funkcyjny Shen zaimplementowany w Common Lispie, oraz wiele dialektów Lispu pisanych w Haskellu.

Wiele z wyżej wymienionych dialektów bazuje na na innych językach programowania. Łatwość tworzenia nowych dialektów Lispu „na wierzchu” już istniejących języków wynika z tego, że sama idea Lispu jest prosta. Główną osią praktycznie wszystkich dialektów Lispu jest REPL (skrót od read-eval-print loop). W najprostszej formie oznacza ona pętlę, która w każdej iteracji wczytuje wejście ze stringu do postaci struktury danych, wykonuje na tej strukturze ewaluację, po czym wypisuje wynik tej ewaluacji na ekran. W ten sposób zaimplementować można podstawowy sposób interaktywnej komunikacji z językiem programowania.

O Lispie często mówi się, że kod to dane, a dane to kod. Cecha ta zwana jest homoikonicznością. Polega na tym, że dane i kod są jednym i tym samym. Wiąże się to z faktem, że kod źródłowy programu w Lispie - w przeciwieństwie do większości języków - nie jest tekstem, tylko strukturą danych. Słynne nawiasy - czyli symbolic expressions - to wbudowany w język format służący do serializacji danych. W rezultacie przy ewaluacji kodu źródłowego nie mamy czynienia z parsowaniem, a manipulacja kodem z poziomu programu jest bardzo prosta. Prowadzi to do makr, które są zwykłymi funkcjami manipulującymi listami.

Kod Lispu zapisany jest w tych samych strukturach danych, którymi ten język się posługuje. Pozwala to na wykorzystanie już istniejącego języka do implementacji małego podzbioru funkcjonalności. Wystarcza on do opisania struktur danych, podstaw operacji na nich i opisania działania ewaluacji. Na tej niewielkiej podstawie można już w tym momencie zaimplementować sporą część języka wraz ze swoim interpreterem. Istnieje nawet projekt pokazujący ten sam prosty interpreter Lispu, napisany w wielu różnych językach programowania wraz z dobrą dokumentacją całego procesu.

Common Lisp

Common Lisp powstał w celu unifikacji licznych, komercyjnych i wzajemnie niekompatybilnych dialektów używanych w latach osiemdziesiątych. Osiągnął swoją dojrzałość i został ustandaryzowany przez ANSI w 1994 roku. Uznaje się, że potem „umarł" z powodu zimy AI. Utrzymywany przy życiu przez stosunkowo niewielką, ale wierną mu społeczność, od około dziesięciu lat na nowo zyskuje popularność.

Standard CL nie zmienił się od roku 1994. Z tego powodu Common Lisp jest często określany jako stary. Nie jest jednak w żadnej mierze przestarzały - z powodu możliwości jego rozszerzania, wbudowanych w sam język. W ten sposób modyfikacje, które w innych językach wymagałyby zmian w standardach, w Lispie przybierają często formę bibliotek ładowanych do systemu.

Lisp jest językiem dynamicznym. Nieodłączną jego częścią jest czytnik (przetwarzający tekst, jak na przykład kod źródłowy na dane lispowe) oraz kompilator (przetwarzający cały czas obecne w pamięci dane lispowe na wykonywalny kod). Ich obecność pozwala na modyfikowanie programu w trakcie jego działania oraz programowanie przyrostowe. Każdy nowo napisany fragment kodu możemy od razu wczytać i wykonać w już działającym obrazie Lispu. W połączeniu z wielowątkowością i introspekcją pozwala to choćby na podłączanie się REPL-em do działającego obrazu Lispu, podmianę pojedynczych funkcji i danych w żywym, działającym programie, obserwowanie działania modyfikowanego programu w trakcie jego wykonywania czy interaktywne odpluskwianie go.

Wchodząc nieco w technikalia, w Lispie mamy możliwość modyfikowania sposobu interpretacji składni (makra czytnika), jaki kod jest tworzony jako wejście dla kompilatora (makra językowe) oraz jaki kod jest generowany przez kompilator (makra kompilatora). Na wszystkich tych etapach przetwarzania kodu mamy do dyspozycji pełen zasób języka.

Taka konstrukcja języka pozwala na dokładanie do niego nowych funkcjonalności nie tylko poprzez pisanie nowych funkcji i tworzenie nowych klas jak w innych językach, ale również dodawanie i modyfikowanie składni języka oraz wykonywanie dowolnych obliczeń w trakcie kompilacji. Wykorzystano to, aby do w pełni standardowego Common Lispu dołożyć między innymi wyrażenia regularne oraz pattern matching (1) (2). Samo makro LOOP w standardowym Common Lispie często bywa implementowane w ten sam sposób.

Implementacje i platformy

Common Lisp jako język jest standardem ANSI. Programy zgodne z tym standardem - zdolne do wykonywania kodu Common Lispu - określa się implementacjami. Praktycznie każda z nich rozszerza język o dodatkowe funkcjonalności nieobjęte standardem ANSI, takie jak wielowątkowość, Unicode, czy obsługę sieci. Jednolity interfejs do tych funkcjonalności zapewniają biblioteki zgodnościowe tworzące współczesny standard de-facto Common Lispu.

Common Lisp ma wiele implementacji - zarówno własnościowych (Allegro CL, LispWorks), jak i będących wolnym oprogramowaniem. Z tych mamy do dyspozycji bardzo popularny Steel Bank Common Lisp oraz Clozure Common Lisp. Dostępne są dwie implementacje bootstrapowane z czystego C - Embeddable Common Lisp oraz CLISP. Istnieje również Armed Bear Common Lisp działający na maszynie wirtualnej Javy.

Common Lisp jest obecnie bardzo wieloplatformowy. Różne implementacje Lispu są dostępne na najpopularniejsze systemy operacyjne - czyli Linuksa, *BSD macOS, Windowsa i Solarisa, w wariantach 32- i 64-bitowych. Dostępne są również implementacje na procesory ARM, SPARC, Itanium, PowerPC i te w dzisiejszych czasach bardziej egzotyczne.

Paradygmaty

CL czasem błędnie nazywa się językiem funkcyjnym, a jest tak naprawdę językiem wieloparadygmatowym.

Bardzo charakterystycznym dla Lispu paradygmatem jest programowanie wykorzystujące wcześniej wspomnianą homoikoniczność, w którym traktujemy dane Lispu jako kod do wykonania:

(defvar *data* '(1 2 3 4 5)) ;; zmienna *DATA* będzie przechowywać argumenty do naszych funkcji

CL-USER> (cons '+ *data*) ;; skonstruujmy wywołanie funkcji +
(+ 1 2 3 4 5)
CL-USER> (eval (cons '+ *data*)) ;; ...i wykonajmy je
15
CL-USER> (cons '* *data*) ;; skonstruujmy wywołanie funkcji *
(* 1 2 3 4 5)
CL-USER> (eval (cons '* *data*)) ;; ...i wykonajmy je
120
CL-USER> (cons 'max *data*) ;; skonstruujmy wywołanie funkcji MAX
(MAX 1 2 3 4 5)
CL-USER> (eval (cons 'max *data*)) ;; ...i wykonajmy je
5


Można pisać w nim proceduralnie, wykorzystując choćby wcześniej wspomniane makro LOOP:

(defun greatest-common-divisor (a b)
  (loop for x = a then y
        and y = b then (mod x y)
        until (zerop y)
        finally (return x)))

CL-USER> (greatest-common-divisor 2345 5432)
7


Możemy też zejść poziom niżej i programować niestrukturalnie:

(defun hello (person)
  (let ((greet-again-p nil))
    (tagbody
     10 (format t ";; Hello, ~A!~%" person)
     20 (setf greet-again-p (yes-or-no-p ";; Should I greet you again?"))
     30 (when greet-again-p (go 10)))))

CL-USER> (hello "phoe")
;; Hello, phoe!
;; Should I greet you again? (yes or no) YES
;; Hello, phoe!
;; Should I greet you again? (yes or no) NO
NIL


Jako, że funkcje są typami pierwszej klasy, w Lispie można używać programowania funkcyjnego - choćby posługując się funkcjami jako argumentami do innych funkcji:

CL-USER> (sort (list 1 8 3 6 4 10 9 2 7 5) #'<) ;; #'< - funkcja oznaczona symbolem <
(1 2 3 4 5 6 7 8 9 10)


albo tworząc domknięcia:

(defvar *adder*
  (let ((count 0))             ;; tworzymy zmienną COUNT
    (lambda () (incf count)))) ;; ...i domykamy ją funkcją anonimową

CL-USER> (funcall *adder*) ;; zwiększamy zmienną COUNT z domknięcia i ją zwracamy
1
CL-USER> (funcall *adder*)
2
CL-USER> (funcall *adder*)
3
;; ...


System obiektowy Common Lispu - CLOS - jest systemem obiektowym zaimplementowanym w samym Lispie. Pozwala on na wielodziedziczenie wraz z rozwiązaniem problemu diamentowego:

(defclass foo () ())         ;; klasa FOO nie dziedziczy po żadnej klasie
(defclass bar (foo) ())      ;; klasa BAR dziedziczy po FOO
(defclass baz (foo) ())      ;; klasa BAZ dziedziczy po FOO
(defclass quux (bar baz) ()) ;; klasa QUUX dziedziczy po BAR i BAZ

CL-USER> (make-instance 'quux)
#<QUUX {1003563C63}>


W CLOS-ie metody nie należą do klas, a do funkcji generycznych. CLOS odchodzi w tym miejscu od systemu obiektowego C++, w którym wywołanie instancja.metoda(x) jest w rzeczywistości wywołaniem metoda(instancja, x), ze specjalizacją wyłącznie na pierwszym argumencie. Podejście CLOS-a pozwala na specjalizację na wielu klasach naraz.

Załóżmy, że mamy napisać bibliotekę oferującą rozszerzalny interfejs do serializacji. W Lispie możemy napisać:

(defgeneric serialize (object destination))


... i iść na piwo.

Klienci tej funkcji generycznej - aby umożliwić serializację dla swoich klas - potrzebują jedynie zdefiniować dla nich metody. Dla przykładowych klas obiektowych POST i COMMENT oraz możliwości serializacji do bazy danych (DATABASE-CONNECTION) i sieci (NETWORK-CONNECTION) deklaracja metod wygląda następująco:

(defmethod serialize ((object post) (destination network-connection)) ...)

(defmethod serialize ((object post) (destination database-connection)) ...)

(defmethod serialize ((object comment) (destination network-connection)) ...)

(defmethod serialize ((object comment) (destination database-connection)) ...)


W C++ czy chociażby w Javie nie mamy multimetod, czyli możliwości specjalizacji na wielu klasach jednocześnie. Nie możemy toteż napisać metody, która będzie dotyczyć zarówno obiektu serializowanego, jak i celu serializacji. Musimy tworzyć nowe klasy, które będą ten problem omijać.

To, co w Lispie było jedną funkcją generyczną, w Javie kończy się na menażerii interfejsów, fabryk, menedżerów i innych klas, a wszystko podlane jest równie wielkimi bibliotekami. I w ten sposób dostajemy kolejnego Hibernate'a.

Dodatkowym problemem jest to, że gdy chcesz dodać serializację dla klasy, to musisz ją zmodyfikować. Może to stanowić problem, bo ta klasa może nie być Twoja. Lisp pozwala programiście na definiowanie nowych metod na dowolnych klasach - w tym klasach systemowych i należących do innych bibliotek. Oznacza to w szczególności, że metody możemy definiować z dala od funkcji generycznej.

CLOS jest systemem obiektowym napisanym w Lispie, co pozwala na jego modyfikowanie. Został w tym celu rozszerzony przez protokół metaobiektowy, pozwalający na jeszcze większą introspekcję w mechanizmy jego działania i dodatkowe możliwości jego rozszerzania o nowe funkcjonalności. Przykładem jest możliwość dostosowania sposobu, w jaki pola instancji wskazanej klasy (zwane w Lispie slotami) są alokowane w pamięci. Jeśli mamy obiekty z niewielką listą slotów, z których wszystkie są zawsze wypełnione, możemy skorzystać „pod spodem" z alokacji tablicowej. Jeśli mamy bardzo wiele slotów, z których wypełnionych jest niewiele, możemy pokusić się o skorzystanie z tablicy hashującej. Innym przykładem jest implementacja silnego typowania w klasach lispowych. Przykładowo, próba przypisania liczby 2 do slotu oznaczonego typem string w przypadku takiej instancji zwróci błąd.

W końcu - Lisp nie jest ograniczony do powyższych paradygmatów. Ze względu na opisaną powyżej programowalność stosunkowo łatwo wchłania nowe paradygmaty - programowanie logiczne i ograniczeniowe, kontekstowe, asynchroniczne, z pamięcią transakcyjną, tablicowe, rozproszone, współbieżne jedno- i wielomaszynowe.

Przykładem na swoją programowalność jest biblioteka ITERATE, czyli rozszerzalny konstrukt iteracyjny, napisany w pełni Common Lispie. Przykładowy kod wyszukujący najdłuższą listę:

(iter (for elt in list-of-lists)
      (finding elt maximizing (length elt)))

Społeczność

Społeczność zgromadzona wokół różnych dialektów Lispu jest niewielka, ale zróżnicowana. Przynajmniej w kręgach Common Lispu, w których się obracam, można znaleźć studentów, akademików, matematyków, biochemików, osoby pracujące w branży wytwarzania oprogramowania, nowicjuszy, starych wyjadaczy, maintainerów implementacji Common Lispu, autorów kompilatorów, backendowców, frontendowców, web developerów, gamedevowców i nie tylko.

Jesteś zaciekawiony? Chcesz spróbować bawić się którymś dialektem Lispu bądź poznać polskich lisperów? Szukaj nas na kanale #lisp-pl w sieci IRC Freenode oraz na lispowym serwerze Discorda.

<p>Loading...</p>