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.
Wzorcowa implementacja Clojure jest napisana częściowo w Javie, a częściowo w Clojure, i kompiluje formy Clojure do kodu bajtowego JVM. Powstałe klasy nie muszą mieć użytecznego, jednoznacznego odpowiednika w kodzie źródłowym Javy.
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. „Przestrzeń nazw” oznacza w tej
terminologii domenę rozpoznawania nazw: symbol użyty w wyrażeniu zajmuje jedną
pozycję wartości, niezależnie od tego, czy wskazana wartość jest funkcją, czy czymś
innym. Nie należy tego utożsamiać z konkretnymi obiektami Namespace
w Clojure, które utrzymują odwzorowania symboli na Vary i klasy.
Jeden symbol w danym kontekście leksykalnym nie może więc oznaczać osobnej wartości
podczas wywołania i innej podczas zwykłego odczytu. Clojure może powiązać x
z funkcją lub z inną wartością, lecz oba użycia są rozpoznawane w tej samej
przestrzeni wartości. Dialekty zwane Lisp–2 utrzymują osobne przestrzenie nazw
funkcji i wartości, a niektóre systemy rozróżniają jeszcze więcej domen
rozpoznawania. 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ł jako notacja do obliczeń na wyrażeniach symbolicznych i stał się ważnym językiem wczesnych badań nad sztuczną inteligencją, m.in. automatycznym dowodzeniem twierdzeń i przetwarzaniem języka naturalnego.
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
Na Lisp silnie wpłynął tzw. rachunek lambda bez typów (ang. untyped lambda calculus) – system formalny opracowany przez Alonzo Churcha, a następnie badany m.in. przez Stephena Cole’a Kleene’ego. Jego termami są zmienne, abstrakcje i aplikacje. Abstrakcja wiąże jeden parametr, natomiast wiele parametrów można reprezentować przez zagnieżdżanie, zwykle nazywane rozwijaniem (ang. currying). Rachunek lambda i maszyny Turinga opisują tę samą klasę funkcji obliczalnych, chociaż żaden z tych formalizmów nie jest sam przez się wykonywalnym środowiskiem programistycznym.
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.
Lisp nie jest dosłowną implementacją rachunku lambda. Dialekty Lispu dodają typy danych, nazwane powiązania, konstrukcje sterujące, wejście i wyjście, stan oraz jawne reguły wartościowania. Sam rachunek lambda nie narzuca jednej strategii redukcji; porządek normalny i aplikacyjny są możliwościami alternatywnymi. Clojure zwykle wartościuje operator i argumenty wywołania funkcji przed jej uruchomieniem, natomiast formy specjalne i makra definiują odmienne reguły. Bezpieczniej zatem powiedzieć, że Lisp zaczerpnął z rachunku lambda centralne idee abstrakcji i aplikacji funkcji, niż że jest po prostu rachunkiem lambda uzupełnionym o stałe.
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. Wartościowanie nadal przebiega w określonym porządku: podwyrażenia są obliczane według reguł formy, która je otacza. Zagnieżdżona składnia uwidacznia przepływ danych, ale nie zastępuje sekwencjonowania nieformalnym wykonywaniem „w głąb”. Znane z innych języków operatory, pętle czy instrukcje warunkowe mają w Lispie funkcyjne odpowiedniki.
Dane niemutowalne
W Clojure, podobnie jak w językach czysto funkcyjnych, a w odróżnieniu od innych dialektów Lispu, dąży się do tego, aby zwykłe wartości były niemutowalne (ang. immutable). Operacje na trwałych kolekcjach zwracają wartość reprezentującą żądany wynik, zamiast modyfikować kolekcję wejściową. Implementacje zwykle współdzielą niezmienioną część struktury, a operacja może nawet zwrócić pierwotny obiekt, jeżeli wynikowa wartość pozostaje taka sama. Niemutowalność jest kontraktem semantycznym, a nie obietnicą wykonania pełnej kopii.
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 koordynują dostęp do bieżącej wartości stanu. Pośrednictwo to przypomina znane z języka C wskaźniki czy obecne w C++ referencje, lecz typy referencyjne Clojure dostarczają dodatkowo jawnej semantyki współbieżności. Gdy powstaje nowy stan, odniesienie jest aktualizowane, aby wskazywało tę wartość, zamiast modyfikować poprzednią. Elementy programu, które nadal dysponują wcześniejszą wartością, mogą używać jej w sposób przewidywalny. Gdy jednak nie sięga do niej żadne żywe odniesienie, odśmiecacz pamięci może ją odzyskać: trwałość nie tworzy automatycznej historii zmian. Mechanizmy te ograniczają potrzebę jawnego blokowania, ale nie eliminują projektowania współbieżności.
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 kodowi aplikacji rozumować przede wszystkim w kategoriach wartości, a nie miejsc ich składowania. Odśmiecacz zarządza czasem życia przydzielonej pamięci, zaś niemutowalne wartości ograniczają liczbę miejsc, w których trzeba jednocześnie uwzględniać tożsamość, czas i lokalizację. Czas życia zasobów i wydajność nadal mają znaczenie, zwłaszcza w przypadku plików, gniazd, pamięci natywnej i dużych zachowanych struktur.
Zwłoczność
Niektóre języki funkcyjne używają wartościowania zwłocznego (ang. lazy evaluation), w którym obliczenie jest odkładane do chwili zażądania jego wyniku. Dialekty Lispu nie są zwłoczne z natury, a Clojure zwykle wartościuje argumenty gorliwie, ale 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.
Lisp był pionierem, popularyzatorem lub jednym z pierwszych praktycznych środowisk dla mechanizmów takich jak:
- odśmiecanie pamięci (ang. garbage collection),
- konstrukcja eval (ang. eval),
- powszechne użycie rekurencji (ang. recursion) i wyrażeniowych warunków (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; domknięcia leksykalne pojawiły się w dialekcie Scheme),
- symbole (ang. symbols),
- jawne, symboliczne powiązania zmiennych (ang. variable binding),
- makra (ang. macros),
- reprezentowanie programów jako danych o kształcie list, zwykle opisywane jako homoikoniczność (ang. homoiconicity),
- metaprogramowanie (ang. metaprogramming),
- rozszerzalne języki dziedzinowe (ang. domain-specific languages, skr. DSL).
Jest to historia wpływu, a nie twierdzenie, że Lisp wynalazł każdy z tych elementów w izolacji. Część idei miała matematycznych lub programistycznych poprzedników, Lisp zaś uczynił ich połączenie wyjątkowo podatnym na programowanie.
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.]
Koszt wykonania zależy od implementacji, obciążenia, reprezentacji danych i kompilatora. Trwałe struktury oraz odśmiecanie pamięci mają swój koszt, ale współdzielają również strukturę, w niektórych przepływach ograniczają kopiowanie i upraszczają bezpieczną współbieżność. Program w Lispie może być w konkretnym zadaniu wolniejszy lub szybszy od imperatywnego odpowiednika; sama rodzina języka nie przesądza o zużyciu procesora ani pamięci. Praktyczny bilans obejmuje również czas wytwarzania, poprawność i koszt koordynowania mutowalnego stanu.
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 mieszające i zbiory w Clojure są implementowane z użyciem trwałych drzew Trie odwzorowywanych tablicami mieszającymi (ang. Hash Array Mapped Trie, skr. HAMT), natomiast wektory korzystają z pokrewnego, szerokiego drzewa trwałego. Struktury te rozwijają wcześniejsze prace nad idealnymi drzewami mieszającymi typu Trie (ang. Ideal Hash Tries) i dostosowują je do semantyki wartości Clojure. 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.