Clojure to funkcyjny język programowania ogólnego przeznaczenia bazujący na modelu Lisp–1. Jego wzorcowa implementacja działa pod kontrolą JVM, ale istnieją też wydania pracujące w innych środowiskach, na przykład popularny ClojureScript zaimplementowany w JavaScripcie. Clojure jest Lispem, który powstał z myślą o przetwarzaniu współbieżnym i korzystaniu z ekosystemu Javy.
Język Clojure został stworzony w roku 2007 przez Richa Hickeya, który oparł go na czterech założeniach:
- Język (nie tylko) funkcyjny, akcentujący niezmienność danych.
- Dialekt Lispu.
- Przystosowany do wykonywania współbieżnego.
- Działający na platformie gospodarza.
Wzorcowa implementacja Clojure działa pod kontrolą maszyny wirtualnej Javy (ang. Java Virtual Machine, skr. JVM) i w tym przypadku jego tzw. platformą gospodarza (ang. host platform) jest JVM. Oznacza to, że programując, mamy dostęp do wszystkich wbudowanych klas i bibliotek tego środowiska, a postać źródłowa programu jest przekształcana do kodu bajtowego.
Clojure jest napisany w Javie, chociaż pewne specyficzne konstrukcje po kompilacji nie dają się bezpośrednio przełożyć na jej kod źródłowy ze względu na zastosowane optymalizacje.
Zaczniemy od zapoznania się z cechami protoplasty rodziny języków, do której należy Clojure, czyli od Lispu, aby płynnie przejść do opisów specyficznych dla dialektu, któremu poświęcony jest podręcznik.
Lisp
Clojure jest dialektem języka Lisp (kiedyś zapisywanego jako LISP). Nazwa tego ostatniego to akronim od angielskiego wyrażenia List Processor (pol. procesor list), ponieważ w języku tym najczęściej używaną strukturą danych jest lista (ang. list). Stworzony został w roku 1958 przez Johna McCarthiego z pomocą ze strony Steve’a Russella.
Clojure przynależy do rodziny Lisp–1. Oznacza to, że mamy do czynienia z jednym rodzajem tak zwanej przestrzeni nazw. Przestrzenie nazw będą szczegółowo omówione później, ale ogólnie rzecz ujmując, są to struktury służące do przechowywania odwzorowań identyfikatorów (nazywających np. funkcje czy zmienne) na odpowiadające im pamięciowe obiekty (np. podprogramy czy wartości). Jest to jeden ze sposobów zarządzania widocznością nazw w komputerowych programach.
Gdy język programowania obsługuje tylko jeden rodzaj przestrzeni nazw, to w przyjętym
kontekście leksykalnym ta sama nazwa może bez konfliktów wskazywać wyłącznie na
pamięciowy obiekt jednego rodzaju. Jeżeli przypiszemy jakiejś zmiennej identyfikator
(np. x
), to – zależnie od języka – nazwanie tym samym identyfikatorem również
funkcji spowoduje albo powstanie błędu, albo nadpisanie poprzedniej wartości. Z taką
właśnie sytuacją będziemy mieli do czynienia w Clojure, gdzie w konkretnej
przestrzeni nazw (nazwanej np. user
) rezydują zarówno odwzorowania
funkcji, jak i tzw. zmiennych globalnych. Istnieją
jednak dialekty Lispu, w których funkcje i zmienne znajdują się w przestrzeniach
różnych rodzajów (Lisp–2), a nawet takie, gdzie rodzajów przestrzeni tych jest
znacznie więcej (Lisp–3, Lisp–4 itd.). Peter Norvig w książce
„Paradigms of Artificial Intelligence Programming”
przywołuje aż 7 możliwych rodzin przestrzeni.
Lisp jest jednym z najstarszych języków programowania, które są aktywnie używane po dziś dzień; jest pod tym względem drugi po Fortranie. Powstał, aby wykonywać obliczenia matematyczne, przetwarzać język naturalny i badać zagadnienia związane ze sztuczną inteligencją w tzw. modelu symbolicznym (logika rozmyta, algorytmy genetyczne, wnioskowanie na bazie kolekcji doświadczeń, symulacje konkretnych procesów).
Lisp to język zwięzły i ekspresywny, chociaż wymaga dłuższego myślenia nad problemem, zanim przystąpi się do jego sformułowania w postaci kodu. Zwięzłość oznacza, że w porównaniu do innych języków programowania jego konstrukcje składniowe zajmują mniej miejsca, a ekspresywność, że gramatyka pozwala na opisanie rozwiązania problemu w mniejszej ich liczbie.
Cechy
Rachunek lambda bez typów
Lisp bazuje na tzw. rachunku lambda bez typów (ang. untyped lambda calculus) – systemie formalnym stworzonym przez matematyków: Alonzo Churcha i Stephena Cole’a Kleene’ego. Jest to matematyczna notacja, która pozwala wyrażać obliczenia z użyciem wyrażeń będących jednoargumentowymi funkcjami. Zarówno wartością zwracaną każdej takiej funkcji, jak i przyjmowanym przez nią argumentem są też jednoargumentowe funkcje. Bardziej skomplikowane (np. wieloargumentowe) funkcje możliwe są do wyrażenia z użyciem operacji rozwijania (ang. currying). Jeżeli jakiś algorytm da się wyrazić korzystając z rachunku lambda, to z pewnością można stworzyć jego implementację na maszynie Turinga, a więc w postaci programu komputerowego.
Church chciał, aby rachunek lambda stanowił alternatywę dla teorii mnogości w kwestii specyfikowania podstaw matematyki, jednak tak się nie stało. Nieoceniona jest jednak jego przydatność w teorii obliczeń i projektowaniu funkcyjnych języków programowania.
Warto zaznaczyć, że Lisp z założenia nie był i nie jest dokładną komputerową implementacją rachunku lambda, w którym wszystko jest funkcją. W Lispie mamy do czynienia z wyrażeniami, które efektywnie mogą być funkcjami lub wartościami stałymi.
Kolejną różnicą jest strategia wartościowania wyrażeń. W rachunku lambda obliczenia są realizowane z zachowaniem tzw. porządku normalnego (ang. normal order), w którym najbardziej zewnętrzne, redukowalne wyrażenia są zawsze skracane, a funkcje są stosowane (przeliczane) zanim dojdzie do wartościowania ich argumentów. Operują więc na przekazywanych jako argumenty funkcjach, a nie zwracanych przez nie wartościach. W Lispie z kolei obowiązuje przekazywanie przez wartość (ang. call by value), gdzie najpierw dochodzi do rekursywnego obliczania wartości każdego z podanych argumentów, a dopiero one są przekazywane do wywołania.
Można więc powiedzieć, że Lisp to implementacja rachunku lambda bez typów z obsługą wartości stałych i przekazywaniem przez wartość.
Język funkcyjny
Lisp jest językiem wieloparadygmatowym z silnym akcentem funkcyjnym – podstawowymi elementami służącymi do budowania programów są funkcje lub konstrukcje zachowujące się jak funkcje (podprogramy przyjmujące argumenty i zwracające wyniki). Jak wspomniano wcześniej, nacisk kładzie się na obliczanie ich wartości, czyli wartościowanie (ang. evaluation).
Warto zauważyć, że dialekty Lispu nie są językami czysto funkcyjnymi, tzn. znajdziemy w nich konstrukcje, które poza obliczaniem wyników pozwalają wywoływać tzw. efekty uboczne, np. wyświetlać coś na ekranie, wymieniać dane pochodzące z urządzeń wejścia–wyjścia czy dokonywać bezpośrednich modyfikacji pamięciowych struktur. Na programiście spoczywa odpowiedzialność za rozróżnianie operacji funkcyjnie czystych od takich, których działanie zależy nie tylko od przekazanych argumentów, ale również od otoczenia.
Wywołania funkcji w Lispie są często zagnieżdżone lub operują na innych funkcjach, przekazywanych jako argumenty. Programy nie wykonują się więc od góry do dołu, jak ma to miejsce w modelu imperatywnym, ale raczej w głąb. Znane z innych języków operatory, pętle czy instrukcje warunkowe mają w Lispie funkcyjne odpowiedniki. Pozwala to zachować prostą składnię i ułatwia uczenie przez rozumienie, a nie przez zapamiętywanie.
Dane niemutowalne
W Clojure, podobnie jak w językach czysto funkcyjnych, a w odróżnieniu innych dialektów Lispu, dąży się do tego, aby umieszczone w pamięci dane były niemutowalne (ang. immutable). Gdy dokonujemy obliczeń na jakiejś pojedynczej wartości lub złożonej strukturze, to rezultatem nie będzie zmiana w zajmowanym przez nie obszarze pamięci, lecz powstanie zupełnie nowego obiektu. Ten ostatni jest niemodyfikowalną wartością (ang. value), a nie zmienną strukturą przypisaną do stałego miejsca.
Rezultaty wywołań funkcji możemy przechwytywać i nazywać – mamy wtedy do czynienia z tzw. powiązaniami (ang. bindings), a nie ze zmiennymi (ang. variables). Powiązania to w najprostszym wariancie symboliczne nazwy, które identyfikują wartości umieszczone w pamięci.
Są jednak takie przypadki, że potrzeba skorzystać z danych mutowalnych (ang. mutable), czyli takich, których zawartość można i trzeba bezpośrednio zmieniać. Czysto funkcyjny program byłby kalkulatorem, który przyjmuje na początku pracy jakieś dane wejściowe, przekazuje je do głównej funkcji jako argumenty, a ta wywołuje kolejne podprogramy, aż do uzyskania wartości stanowiącej rezultat obliczeń. Problemy przed jakimi stają programiści są nieco bardziej złożone i często pojawia się konieczność reprezentowania w programach zmieniających się stanów, które identyfikowane powinny być stałymi nazwami. Ilość dostępnych środków na rachunku bankowym, pozycja i liczba punktów gracza, reprezentacja aktualnie przetwarzanej ścieżki dźwiękowej – to przykłady stałych tożsamości, które z momentu na moment mogą przyjmować różne stany wyrażane niezmiennymi wartościami.
Powyższą potrzebę można zrealizować, wprowadzając zmienne, ale wtedy ryzykujemy, że byłyby one nadużywane i rezygnujemy z założenia, że w domyśle wszystkie wartości są stałe. Taka niekonsekwencja doprowadziłaby do zamieszania jeszcze większego, niż traktowanie wszystkich danych jako potencjalnie podlegających zmianom. Z tego też powodu w Clojure mamy do czynienia z tzw. typami referencyjnymi. Ich instancje same nie przechowują wartości, ale odnoszą się do istniejących. Przypomina to znane z języka C wskaźniki czy obecne w C++ referencje. Gdy w rezultacie wykonania operacji (np. wywołania funkcji) pojawia się nowa wartość, dochodzi do aktualizacji odniesienia, a nie do nadpisania poprzednich danych. Te ostatnie nadal rezydują w pamięci, chociaż bez wyraźnej tożsamości – obiekt referencyjny wskazuje już na nowy wynik. W efekcie elementy programu, które w danym czasie jeszcze operują na poprzedniej wartości, mogą działać w sposób przewidywalny. Programista nie musi stosować konstrukcji wytwarzających lokalne kopie czy zakładających blokady, jak ma to miejsce na przykład w konwencjonalnym modelu programowania wielowątkowego.
Zauważmy, że większość użytkowanych obecnie języków programowania zakorzenionych jest w paradygmacie imperatywnym, w którym język stanowi zaawansowany etap ewolucji programowania w języku maszynowym, tzn. bazuje na sposobie, w jaki działa sprzęt. W pewnym sensie mamy do czynienia z mniej lub bardziej wyabstrahowanymi interfejsami pamięci operacyjnej i centralnej jednostki obliczeniowej. Dane – którymi mogą być pojedyncze wartości, struktury czy inne obiekty – umieszczane są w komórkach pamięciowych identyfikowanych nazwami zmiennych, a szeregi instrukcji odczytują z tych obszarów wyniki obliczeń i modyfikują je, umieszczając rezultaty nowych. Aplikacja wpływa na to, co znajdzie się określonych, dostępnych jej lokalizacjach RAM-u.
Powyższe nie wydaje się niczym niezwykłym i dla wielu jest oczywistym (jeżeli nie jedynym) podejściem do rozwiązywania problemów z użyciem komputera. Skoro wyposażony jest on w pamięć, więc programowanie powinno polegać na dostępie do niej; na wpisywaniu, odczytywaniu i zastępowaniu obecnych tam informacji. Z pewnością będzie to dobra strategia przy projektowaniu systemów operacyjnych, sterowników obsługi urządzeń czy lokalnych baz danych – wszędzie tam, gdzie program musi bezpośrednio operować na pamięciowych strukturach z uwagi na wydajność i oszczędność zasobów. Umieszczony w określonym miejscu obiekt może być wtedy szybko zmodyfikowany lub zastąpiony zupełnie innym. Jednak „z wielką mocą wiąże się wielka odpowiedzialność” – poza opracowywaniem rozwiązania problemu programista musi mieć na uwadze to, że na przykład jednoczesny zapis do tej samej przestrzeni przez więcej niż jeden podprogram może spowodować trudną do wykrycia usterkę w działaniu.
Nie chodzi tu nawet o konieczność ręcznego zarządzanie procesami, którymi powinien zajmować się kompilator czy interpreter języka, ale o niski poziom abstrakcji. Każdy problem modelowany imperatywnie musi zostać dostosowany do rzeczywistości, w której większość obiektów może ulegać zmianom, a gdy już dojdzie do zmian zaplanowanych, poprzednie struktury przypisane do konkretnych miejsc nie są pamiętane, lecz nadpisywane. Wyjątkiem będą sytuacje, gdzie programista obsłuży takie przypadki i zadba np. o tworzenie historii zmian czy kopii niektórych danych.
Niestety świat nie działa dokładnie tak, jak komputer, a większość rozwiązań problemów, do których używamy języków programowania, można wyrazić bez konieczności bezpośredniego operowania na modyfikowalnych obszarach pamięci.
Programowanie mocno zakorzenione w paradygmacie funkcyjnym pozwala nie przejmować się zarządzaniem pamięciowymi zasobami – nie tylko przydzielaniem czy zwalnianiem miejsca (z którym mamy do czynienia np. w językach C czy C++), ale nawet tym, że obiekty zajmują jakiekolwiek, określone miejsce! Uniezależnienie tożsamości informacji od jej umiejscowienia oznacza mniej kłopotów, szczególnie w kontekście przetwarzania współbieżnego.
Zwłoczność
Inną cechą języków funkcyjnych jest zwłoczność (ang. laziness) obliczania wartości wyrażeń. Cecha ta oznacza, że do wartościowania nie dochodzi natychmiast, ale dopiero wtedy, gdy pojawi się żądanie odczytu ostatecznego rezultatu. Dialekty Lispu nie są zwłoczne z natury (mamy do czynienia z przekazywaniem przez wartość), ale Clojure obsługuje tzw. leniwe sekwencje – abstrakcyjne struktury danych, które pozwalają na zwłoczny dostęp do wartości elementów kolekcji lub wartości zwracanych przez rekurencyjnie wywoływane funkcje generujące. Odkładanie pojedynczych obliczeń w czasie możemy też zrealizować z wykorzystaniem funkcji wyższego rzędu lub niektórych typów referencyjnych.
Innowacje
Lisp miał duży wpływ na kształt wielu języków programowania. Mechanizmy, które raptem kilka czy kilkanaście lat temu wprowadzono w tzw. nowoczesnych językach, i określanych jako przełomowe i innowacyjne, zaimplementowano w Lispie już w latach 60–80.
Z Lispu pochodzą m.in. takie technologie jak:
- odśmiecanie pamięci (ang. garbage collection),
- konstrukcja eval (ang. eval),
- rekurencja (ang. recursion),
- instrukcje warunkowe (ang. conditionals),
- typ funkcyjny (ang. function type),
- funkcje pierwszej kategorii (ang. first-class functions),
- funkcje wyższego rzędu (ang. higher-order functions),
- domknięcia (ang. closures),
- symbole (ang. symbols),
- dowiązywanie zmiennych (ang. variable binding),
- wyrażenia (ang. expressions),
- makra (ang. macros),
- drzewo składniowe (ang. syntax tree),
- jednoznaczność (homoikoniczność, ang. homoiconicity),
- metaprogramowanie (ang. metaprogramming),
- języki dziedzinowe (ang. domain-specific languages, skr. DSL).
Popularność
Można zapytać czemu język o takiej sile wyrazu i możliwościach nie zdominował rynku i nie stał się najpopularniejszym sposobem rozwiązywania problemów z użyciem komputerów? Powody nie są stricte techniczne.
Po pierwsze Lisp musiał najpierw zmierzyć się z marketingiem Uniksów oraz języka C i walkę tę przegrał. Istniały co prawda komputery wyspecjalizowane w uruchamianiu lispowego kodu, wyposażone w systemy operacyjne stworzone w Lispie, jednak były to ekosystemy mniej otwarte i mniej przenośne niż Uniksy.
Po drugie, gdy po latach miał szansę „powitać się z gąską”, pojawił się boom na języki obiektowe (C++ i Java), a następnie na technologie webowe i nowoczesne języki wieloparadygmatowe, takie jak Ruby czy Python i ich środowiska Ruby On Rails czy Django. [Osobiście zainteresowałem się Lispami, gdy moje kody w Rubym zaczęły być coraz bardziej funkcyjne i deklaratywne – przyp. aut.]
Lista jako podstawowa struktura danych i działający Garbage Collector, to nie tylko większe zużycie RAM-u, ale też nieco większe obciążenie procesora. Namnażanie struktur pamięciowych (i usuwanie nieużywanych) wymaga przecież pewnej pracy. Programy napisane w Lispie mogą być nawet 15 procent powolniejsze, niż ich odpowiedniki stworzone w statycznie typizowanych językach imperatywnych. Kiedyś było to przeszkodą, lecz teraz większość centralnych jednostek obliczeniowych wyposaża się w wiele rdzeni. Dla programów zorientowanych imperatywnie oznacza to konieczność stosowania obejść, aby móc współbieżnie realizować operacje. Wynika to z mnogości stosowanych tam struktur, które ulegają zmianie. W przeciwieństwie do imperatywnego, funkcyjne podejście bardzo dobrze sprawdza się w tej domenie, a obciążenie pojedynczego rdzenia przestaje być już tak istotne; szczególnie, gdy pod uwagę weźmiemy koszt utrzymania i zarządzania zmianami w oprogramowaniu.
Kolejny powód „hibernacji” Lispów jest taki, że choć składnia jest tam prostsza niż w większości innych języków (mniej reguł, mniej gotowych konstrukcji do zapamiętania, mniej operatorów i specjalnych instrukcji o specyficznej syntaktyce, praktycznie brak składni), to odzwierciedlanie problemów może być kłopotliwe dla początkujących. Dzieje się tak, ponieważ w wielu przypadkach należy porzucić sekwencyjne, imperatywne myślenie, w którym wykonujemy instrukcje, a wyniki poprzednich przekazujemy następnym z użyciem zmiennych. Zamiast tego budujemy niewielkie funkcje lub makra, które wizualnie przypominają bardziej korzenie drzewa, niż drabinę. Często zamiast pętli korzystamy z rekurencji, a zamiast przechowywać tymczasowy rezultat wykonania funkcji w zmiennej przekazujemy dalej po prostu tę funkcję!
Dlaczego Lisp, dlaczego Clojure?
Niektórzy uważają, że języki funkcyjne lub wieloparadygmatowe z dobrą obsługą mechanizmów funkcyjnych (jak np. Scala), są przyszłością branży i staną się bardzo popularne. Nie podzielam tego optymizmu, gdy chodzi o dialekty Lispów. Mimo że korzystanie z nich może dać przewagę konkurencyjną, mogą one okazać się za trudne. Nie chciałbym tu nikogo urazić – nie chodzi o potencjalną zdolność do ich zrozumienia, ale raczej o potrzebną do tego determinację i czas. Nauka języka funkcyjnego dla kogoś, kto większość życia spędził programując imperatywnie może być nieco szokująca i wymagać odpowiednich warunków. Idealnie byłoby, gdyby te warunki panowały również w miejscu pracy.
Aby Clojure stał się popularny, musi być więc użytkowany w biznesie, a tam może okazać się, że taniej będzie zatrudnić dwie czy trzy osoby o niewygórowanych oczekiwaniach oraz umiejętnościach, zamiast jednego programisty, który zrobi to lepiej w Clojure. Problemy, z którymi przychodzi mierzyć się statystycznej większości programistów w dzisiejszych czasach, nie są kwestiami takiego kalibru, aby zespoły IT, które nad nimi pracują, ogłaszały zmianę paradygmatu i użytkowanej technologii.
Jeżeli jednak nie jesteśmy korporacją, lecz małym lub średnim zespołem, a w dodatku pracujemy nad projektem, który ma szanse na dłuższy cykl rozwojowy, to Clojure może bardzo nam pomóc. Chodzi o modyfikowalność (ang. changeability) programów, która bezpośrednio przekłada się na produktywność. Oprogramowanie tworzone w stylu funkcyjnym jest mniej zagmatwane. Nie mamy tu na myśli struktury kodu źródłowego, lecz pasującą do funkcyjnego stylu zasadę rozdziału zagadnień (ang. separation of concerns, skr. SoC).
Społeczność programistów Clojure kładzie duży nacisk na to, aby poszczególne komponenty systemów oprogramowania nie były ze sobą splecione (ang. complected), tzn. aby w efekcie zmiana dokonana w jednym nie skutkowała koniecznością dokonywania zmian we wszystkich innych. Na początku podejście takie wymaga gruntownego przemyślenia architektury systemu, lecz potem odwdzięcza się oszczędnościami setek bądź tysięcy roboczogodzin, które musiałyby być przeznaczone na testy i poprawki.
Obserwując programy w Clojure umieszczone w Sieci możemy zauważyć, że korzysta się tam bardzo często z kluczy, map i wektorów. Dla kogoś przychodzącego ze świata programowania zorientowanego obiektowo może to być obraz ubóstwa i wiecznego eksperymentu. Nic bardziej mylnego. Po pierwsze mapy i wektory w Clojure to struktury bazujące na pierwszej na świecie praktycznej implementacji wysokowydajnych drzew Trie odwzorowywanych tablicami mieszającymi (ang. Hash Array Mapped Trie, skr. HAMT), zwanych w skrócie idealnymi drzewami mieszającymi typu Trie (ang. Ideal Hash Tries). Po drugie społeczność Clojure hołduje zasadzie sformułowanej przez Alana Perlisa, pierwszego zdobywcy Nagrody Turinga, który powiedział:
Lepiej mieć 100 funkcji operujących na jednej strukturze danych,
niż 10 funkcji operujących na 10 strukturach.
Dzięki wdrożeniu w życie powyższej dewizy możemy programować bardziej generycznie i przez lata rozszerzać bazę użytecznych funkcji bez względu na biznesową czy (do pewnego stopnia) implementacyjną specyfikę projektów, które będą pojawiały się i znikały. Rezygnujemy w ten sposób z tworzenia bezsensownych słowników klas i metod, których każdy uczestniczący w pracach nad projektem musi się uczyć, chociaż zwykle realizują one podobne operacje na danych o podobnych charakterystykach, a różnią się nazwami danych biznesowych poddawanych przetwarzaniu.
Warto nauczyć się Clojure (lub innego dialektu języka Lisp) również ze względu na jego formalizm i siłę wyrazu. Już na wstępie, pisząc proste, przykładowe programy, wchodzi się w bezpośredni kontakt z wiedzą dotyczącą inżynierii oprogramowania, która jest uniwersalna i pozwala rozszerzyć perspektywę opracowując rozwiązania problemów.
Zaczynając przygodę z Perlem, PHP, Rubym czy nawet z językiem C, jesteśmy w stanie przez jakiś czas nie przejmować się tym, w jaki sposób interpreter czy kompilator przetwarza kod źródłowy, aby realizować zadania programu. Z czasem możemy zgłębiać wiedzę o tym, jak dokładnie działa wybrany język i stosować bardziej zaawansowane konstrukcje, na przykład wykorzystując obiekty funkcyjne (czy wskaźniki do funkcji w języku C), domknięcia, typy referencyjne czy pisząc podprogramy, które będą realizowane w sposób zwłoczny. Ucząc się coraz więcej, możemy też zechcieć programować bardziej elegancko, z uwzględnieniem sterowania widocznością i zasięgiem identyfikatorów, odpowiednio dobierając typy i struktury danych, a w końcu przystosowując aplikację do użytkowania większej liczby procesorów czy rdzeni. W Lispach sporo z tej wiedzy potrzebujemy już na początku, aby nie programować w ciemno, niczym ktoś, kto pierwszy raz pisząc w C, dostawia i usuwa symbole gwiazdki przy zmiennych wskaźnikowych, żeby program przestał awaryjnie zakańczać pracę z powodu naruszenia ochrony pamięci.
Rzecz nie jest nawet w tym, że Lisp jest wymagający i skłania do zgłębienia szczegółów działania języka. Chodzi raczej o to, co dzieje się z programistą przy okazji. Będąc zmuszanym przez jego charakter do lepszego zrozumienia pewnych mechanizmów, zupełnie inaczej patrzy się na programowanie także w innych językach.
Poza powodami intersubiektywnymi warto używać Lispu z obiektywnych przyczyn technicznych. Poza zaakcentowaną obsługą współbieżności (w przypadku Clojure) Lisp jest językiem, który dzięki rozbudowanemu systemowi makr i cesze zwanej jednoznacznością (homoikonicznością) pozwala tworzyć syntaktyczne warstwy abstrakcji, dopasowane do rozwiązywania konkretnych problemów i przyspieszające prace. Oznacza to, że mamy realny wpływ na to, jak poradzimy sobie z obsługą złożoności, mogąc dodawać do języka konstrukcje, które nie będą tylko procedurami grupującymi wywołania już istniejących, ale elementami zmieniającymi zasady jego działania.
Składniowa elastyczność Lispów sprawia, że korzystając z makr
i funkcji wyższego rzędu możemy dodawać zupełnie nowe konstrukcje
(pętle, wyrażenia warunkowe, operatory), które w innych językach musiałyby być
zaimplementowane przez ich twórców. Wtedy programista, któremu zależałoby na jakimś
potrzebnym mechanizmie, mógłby co najwyżej zaproponować zmiany i czekać na wydanie
nowej wersji kompilatora lub interpretera. W Clojure wiele powszechnie używanych
konstrukcji językowych jest w istocie zaprogramowanych właśnie jako makra,
np. when
, or
czy and
.