Clojure, jak większość dialektów Lispu, wyposażono w interaktywną konsolę, dzięki której możemy na bieżąco eksperymentować i sprawdzać naszą wiedzę. Zanim więc przejdziemy do teoretycznych podstaw języka, pozwolimy sobie na praktyczny kontakt z jego mechanizmami.
Pierwsze kroki
Clojure jest Lispem, który działa pod kontrolą maszyny wirtualnej Javy (ang. Java Virtual Machine, skr. JVM). Jego wzorcowa implementacja łączy kod napisany w Javie i Clojure, a kompilator przekształca formy Clojure w kod bajtowy JVM. Clojure korzysta więc z platformy wykonawczej Javy, ale nie jest po prostu odmienną składnią języka Java.
W pewnych kontekstach Clojure zachowuje się jak język interpretowany, ponieważ spora część kodu źródłowego programów jest kompilowana do kodu bajtowego w czasie ładowania przez kompilator Clojure, a nie z wyprzedzeniem. Powstały kod bajtowy może być następnie dalej optymalizowany do kodu maszynowego przez kompilator JIT maszyny wirtualnej (ang. just-in-time compiler). Taki model kompilacji w czasie uruchamiania pozwala językowi w pełni korzystać z dobrodziejstw dynamicznego typizowania i polimorfizmu.
Instalacja
Do wykonania przykładów potrzebne są aktualne środowisko uruchomieniowe Javy oraz
oficjalny Clojure CLI. Procedura instalacji zależy od systemu operacyjnego
i zmienia się w czasie, dlatego źródłem kanonicznym dla GNU/Linuksa, macOS-a
i Windows jest oficjalny podręcznik instalacji. Warto zwrócić
uwagę, że pakiet dystrybucyjny nazwany clojure może zawierać jedynie skrypt
uruchamiający clojure.main, bez opisanych tutaj poleceń zarządzania zależnościami.
CLI i deps.edn
Clojure CLI dostarcza trzy blisko powiązane elementy:
- polecenia
clojuredo uruchamiania programów, - polecenia
cljdo interaktywnej pracy, - pliku
deps.edndo specyfikowania zależności konkretnego projektu.
Środowisko uruchomieniowe języka i CLI są osobnymi warstwami: projekt wybiera wersję Clojure jako zależność, natomiast CLI rozwiązuje zależności i uruchamia żądany program. Rozróżnienie to jest użyteczne, gdy kilka projektów na tej samej maszynie wymaga różnych wersji języka.
W tym rozdziale korzystamy również z Leiningena –
utrwalonego narzędzia automatyzacji projektów i zarządzania zależnościami. Leiningen
zapisuje dane projektu w pliku project.clj, potrafi tworzyć szkielet projektu
i udostępnia zadania takie jak repl, run czy uberjar. Aby dosłownie powtórzyć
odpowiednie części ćwiczenia, należy zainstalować go według jego bieżącej instrukcji;
pojęcia językowe nie zależą od wyboru Leiningena zamiast Clojure CLI.
Mechanizm bazujący na deps.edn różni się od innych popularnych narzędzi służących
do określania zależności tym, że plik konfiguracyjny zawiera wyłącznie dane, a nie
wywołania funkcji bądź makr.
Rozszerzenie edn to skrócona nazwa formatu danych EDN (Extensible Data
Notation). Stworzyły go osoby zaangażowane w rozwój Clojure, a jego literały celowo
pokrywają się z częścią składni Clojure zorientowaną na dane. Możemy myśleć o EDN
jak o formacie zapisywania i wymiany danych: wczytanie EDN tworzy wartości, ale nie
wartościuje dowolnych form Clojure.
Zobacz także:
- https://clojure.org/guides/install_clojure
- https://clojure.org/guides/deps_and_cli
- https://github.com/edn-format/edn
REPL
W Clojure tworzenie oprogramowania polega najczęściej na pracy z interaktywną konsolą języka zwaną REPL. Jest to w istocie pętla (ang. loop) trzech następujących po sobie procesów:
- wczytywania (ang. read) kodu źródłowego,
- wartościowania (ang. evaluate) znalezionych wyrażeń i
- wypisywania (ang. print) rezultatów obliczeń.
W języku angielskim nosi ona nazwę read–eval–print loop (skr. REPL), a najbliższe znaczeniowo polskie określenie to pętla wczytaj–przelicz–wypisz.
Dzięki REPL możemy tworzyć testowe programy i odpluskwiać (ang. debug) już istniejące, badając Vary, funkcje, dane i klasy Javy osiągalne z bieżącego środowiska uruchomieniowego i przestrzeni nazw.
W dialektach języka Lisp REPL często służy do rozwijania oprogramowania. Mówimy wtedy o tzw. programowaniu interaktywnym (ang. interactive programming).
Standardowym wejściem, wyjściem i wyjściem diagnostycznym REPL nie musi być tylko urządzenie prawdziwego lub wirtualnego terminalu, ale także gniazdo dziedziny Uniksa czy TCP/IP. Dzięki protokołowi nREPL popularne edytory, takie jak Emacs (za pośrednictwem CIDER), Vim (Conjure lub vim-fireplace) czy VS Code (Calva), mogą być zintegrowane z działającą jako demon konsolą REPL.
Aby uruchomić sesję REPL, możemy użyć Leiningena w tym trybie (polecenie lein repl)
lub wbudowanego klienta Clojure (polecenie clj).
Zobacz także:
Wczytywanie i wartościowanie
Gdyby z REPL usunąć element interaktywny, proces uruchamiania programu w Clojure można podzielić na dwa główne etapy (szczegółowo omówione w kolejnym rozdziale):
W pierwszym etapie znaki są pobierane z pliku, łańcucha lub strumienia wejściowego i sprawdzane według składni czytnika Clojure. Czytnik (ang. reader) zmienia je w zwykłe wartości w pamięci: liczby, łańcuchy, symbole, listy, wektory, mapy itd.
Wartościowanie jest osobną operacją na tych wartościach. W Clojure dla JVM obejmuje ono analizę kompilatora i wykonanie kodu bajtowego; rozwinięcie makra może wcześniej wytworzyć kolejną formę przeznaczoną do kompilacji. Zachowanie tej granicy wyjaśnia zarówno możliwość operowania na kodzie Clojure jak na danych, jak i to, dlaczego samo wczytanie danych ich nie wykonuje.
Praca z konsolą
Spróbujmy zaprzyjaźnić się z interaktywną konsolą języka korzystając z polecenia
lein repl lub clj:
lein repl
lein repl
Po uruchomieniu powinniśmy ujrzeć na wyjściu rezultat podobny do zaprezentowanego poniżej:
nREPL server started on port 50718 on host 127.0.0.1 - nrepl://127.0.0.1:50718
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.12.5
Java HotSpot(TM) 64-Bit Server VM 10.0.2+13
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=>
nREPL server started on port 50718 on host 127.0.0.1 - nrepl://127.0.0.1:50718
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.12.5
Java HotSpot(TM) 64-Bit Server VM 10.0.2+13
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=>
Ostatnia linia (user=>) to tzw. monit REPL (ang. REPL prompt). Oznacza gotowość
do przyjmowania tekstowych wersji wyrażeń ze standardowego wejścia. Napis user to
nazwa bieżącej przestrzeni nazw – obiektu zarządzającego odwzorowaniami symboli na
Vary i klasy Javy, o którym jeszcze wspomnimy.
Prosty przykład
Spróbujmy zacząć najprościej, jak się da, wpisując w konsoli REPL tekst z poniższego listingu:
123 ; literał liczbowy
;=> 123
123 ; literał liczbowy
;=> 123
Wpisaliśmy liczbę 123 , a w rezultacie otrzymaliśmy tę samą wartość. Z pozoru mało się wydarzyło, lecz konsola REPL wykonała pełen cykl pętli. Prześledźmy, co się stało:
-
Wprowadziliśmy linię tekstu zawierającą kod źródłowy.
-
Zatwierdzając linię klawiszem ENTER sprawiliśmy, że została wysłana na standardowe wejście czytnika (komponentu odpowiedzialnego za analizę składniową tekstu programu), który zaczął ją przetwarzać.
-
Czytnik rozpoznał literał liczbowy
123, a średnik i następujące po nim znaki pominął jako komentarz. Nie znalazł niekompletnej konstrukcji, która wymagałaby kolejnej linii wejścia. -
Czytnik stworzył pamięciową reprezentację kodu źródłowego, w której wyrażenie
123reprezentowane jest daną typujava.lang.Longprzechowującą wartość 123. -
Kontrolę nad dalszym uruchamianiem przejął ewaluator (proces odpowiedzialny za nadanie formie znaczenia).
-
Ewaluator zaczął wartościować obiekt umieszczony w pamięci przez czytnik.
-
Reprezentowane typem
Longwyrażenie 123 zostało rozpoznane jako forma samowartościująca: rezultatem wartościowania jest sama liczba. -
Ewaluator przekazał rezultat przeliczania do funkcji wypisującej rezultaty obliczeń.
-
Funkcja drukująca zmieniła wartość całkowitą 123 na jej tekstową reprezentację, czyli literał liczbowy, i wraz ze znakiem nowej linii wysłała na standardowe wyjście konsoli REPL.
Spróbujmy czegoś bardziej wyrafinowanego:
(print 123) ; wywołanie funkcji
; >> 123
(print 123) ; wywołanie funkcji
; >> 123
Możemy zauważyć zapis, który kojarzymy z innych języków programowania: konstrukcję
print, która służy do wyświetlania wartości na ekranie.
W Clojure (print 123) składniowo jest tzw. symbolicznym wyrażeniem
(ang. symbolic expression), a dokładniej listowym wyrażeniem symbolicznym, w którym
print jest nazwą funkcji, a 123 wartością argumentu przekazywanego do jej
wywołania. Nawiasy służą do zaznaczania początku i końca listy elementów, na które
składają się nazwa i argumenty (operator i operandy).
Żeby wywoływać funkcje korzystamy z tzw. formy wywołania funkcji. Forma (ang. form) to takie wyrażenie, które przedstawia poprawny kod programu w Lispie. Poprawny kod to taki, który da się wykonać, aby obliczyć jego wartość. Forma wywołania funkcji, jak sama nazwa mówi, jest więc konstrukcją, która służy do wywoływania istniejących funkcji w celu uzyskania wyników obliczeń i/lub efektów ubocznych (jak w tym przypadku: wypisywania na urządzeniu wyjściowym).
Sam termin „forma” zakorzeniony jest we wczesnych dialektach Lispu i wskazuje, że mamy do czynienia z daną (ang. datum), która może być poddana wartościowaniu. W Lispach kod źródłowy jest również (wczytywanymi) danymi, ale nie wszystkie z tych danych będą wymagały przeliczania.
Wracając do poziomu składniowego, w naszym przykładzie elementami listy wyrażającej kod programu będą:
- symbol
print, - literał liczbowy
123.
Zanim jednak Clojure rozpozna, że mamy do czynienia z wywołaniem funkcji,
przeprowadzi kilka czynności, dzięki którym będzie w stanie zbadać, gdzie szukać
podprogramu identyfikowanego symboliczną nazwą print i czym on jest. (Poza
funkcjami wywoływane mogą być również tzw. makra i formy specjalne).
Napis print, składniowo rzecz ujmując, jest symbolem – reprezentowanym wewnętrznie,
tuż po wczytaniu tekstu programu do pamięci, wartością typu
clojure.lang.Symbol. Z kolei literał liczbowy 123 jest wartością całkowitą
(wyrażaną wewnętrznie daną typu java.lang.Long).
Gdy kompilator napotyka listę zawierającą na pierwszym miejscu symboliczną etykietę, próbuje zbadać z jakiego rodzaju wywołaniem ma do czynienia. W tym celu stara się potraktować umieszczony na pierwszym miejscu symbol jako tzw. formę symbolową.
Forma symbolowa to taki symbol, który nazywa (identyfikuje) jakąś wartość lub
podprogram (np. funkcję). Przypomina nazwę zmiennej bądź funkcji z innych
języków. Składniowo jest czytelną etykietą, która odnosi się do jakiejś istniejącej
konstrukcji, ponieważ została z nią wcześniej powiązana. W tym przypadku sprawdzane
jest, co kryje się pod nazwą print. Forma symbolowa będzie więc takim symbolem,
który w procesie rozpoznawania nazwy uda się zamienić w jakiś inny obiekt
(niekoniecznie funkcję).
Wyrażona symbolicznie nazwa print będzie zatem poszukiwana w kilku wewnętrznych
obszarach, żeby poznać identyfikowany nią obiekt. Jeżeli to się powiedzie,
a dodatkowo print okaże się funkcją, konstrukcja nadrzędna będzie uznana za formę
jej wywołania.
W naszym przypadku przyporządkowanie symbolicznej nazwy print do obiektu funkcji
znalezione zostanie wśród odwzorowań utrzymywanych przez bieżącą przestrzeń
nazw. Kluczem będzie dana typu clojure.lang.Symbol o wartości print,
a przypisaną do niej wartością specjalny obiekt typu clojure.lang.Var. Jego
powiązaniem głównym jest wygenerowany przez kompilator obiekt funkcji implementujący
clojure.lang.IFn.
Dlaczego print nie odnosi się bezpośrednio do obiektu funkcji? Var zapewnia stały,
nazwany poziom pośrednictwa. Jego powiązanie główne jest współdzielone i może zostać
zastąpione podczas pracy interaktywnej; Var oznaczony jako dynamiczny może mieć
dodatkowo powiązania lokalne dla wątku. Sam Var nie izoluje zwykłej wartości głównej
osobno dla każdego wątku.
Gdy kompilator pozna, że print odwołuje się do kodu funkcji, całe listowe
S-wyrażenie potraktowane zostanie właśnie jak jej wywołanie. Oznacza to, że każdy
następny element listy (w naszym przypadku 123) stanie się argumentem, którego
wartość należy podczas wywoływania przekazać.
Warto wspomnieć, że kiedy Clojure oblicza wartość symbolicznego wyrażenia, które
okazuje się być formą wywołania funkcji, dokonuje gorliwego wartościowania każdego
przekazywanego argumentu. W tym przypadku wartość 123 (typu java.lang.Long)
wartościuje się do siebie samej, ponieważ literały liczbowe są formami
samowartościującymi. Cytowanie jest innym mechanizmem: forma specjalna
quote zwraca swój argument bez wartościowania go jako wywołania lub odniesienia
symbolowego.
W tym momencie znamy około 80% składni Clojure i 90% składni Lispów w ogóle. Brawo!
Uwaga: Wczytywanie i wartościowanie pozostają semantycznie odrębne nawet wtedy, gdy REPL wykonuje je bezpośrednio po sobie. Czytnik tworzy dane i może stosować makra czytnika, ale nie wykonuje zwykłych wywołań funkcji. Kompilator analizuje następnie powstałą formę, w razie potrzeby rozwija makra, emituje kod bajtowy JVM i wykonuje ten kod. JVM może później optymalizować gorące fragmenty za pomocą kompilatora JIT, nie zacierając tych granic na poziomie języka.
Komentarze
Patrząc na podane wcześniej przykłady możemy zauważyć, że używając średnika (;)
jesteśmy w stanie opatrywać tekstową postać programu komentarzami. Komentarze są
ignorowane podczas wykonywania programów i pozwalają ilustrować kod zrozumiałymi
objaśnieniami.
W dalszych przykładach będziemy korzystać z komentarzy zawierających strzałkę, aby pokazywać rezultaty obliczeń wyświetlane przez REPL, a komentarze rozpoczęte podwójnym znakiem większości będą pokazywały dane wyświetlane przez programy.
; =>– rezultaty obliczeń,; >>– standardowe wyjście.
Poziomy abstrakcji
Podczas omawiania wcześniejszego przykładu używamy różnych słów w odniesieniu do tego samego fragmentu, zależnie od badanej warstwy. Warstwy te są powiązane, ale nie należy zlewać ich w jedno wyobrażone drzewo składniowe:
| Warstwa | Co istnieje na tej warstwie |
|---|---|
| Tekst | Znaki takie jak (+ 2 2) |
| Składnia czytnika | Ograniczniki, symbole, literały liczbowe, komentarze, makra czytnika |
| Wynik czytnika | Dane Clojure: lista zawierająca jeden Symbol i dwie wartości Long |
| Analiza kompilatora | Znaczenie nadane w kontekście: wywołanie, rozpoznany Var, argumenty |
| Wykonanie | Wartość zwrócona przez skompilowaną formę, tutaj 4 |
Czytnik wytwarza więc listę reprezentowaną przez (+ 2 2); nie zastępuje w niej
symbolu + symbolem clojure.core/+. Podczas analizy kompilator rozpoznaje symbol
w bieżącym kontekście leksykalnym i przestrzeni nazw, a listę klasyfikuje jako
wywołanie. Obie liczby są samowartościującymi argumentami. Wykonanie wywołuje
rozpoznaną funkcję i daje 4.
Słowo forma jest celowo kontekstowe: oznacza wartość Clojure przedstawioną do wartościowania. Ta sama lista może pozostać zwykłą daną, gdy zostanie zacytowana, zapisana, przekształcona przez makro lub jedynie wypisana. Ta niewielka granica między danymi a znaczeniem stanowi zawias, na którym obraca się znaczna część Lispu.
Tworzenie projektu aplikacji
Spróbujmy napisać prosty program, który będziemy mogli uruchomić zarówno pod kontrolą konsoli REPL, jak i bez niej. Z pomocą przyjdzie nam Leiningen, który wyposażono w odpowiednie polecenie służące do tworzenia szkieletów aplikacji.
Zamiast samodzielnie tworzyć strukturę projektu i opisywać zależności plikiem
deps.edn, skorzystamy z narzędzia Leiningen, ponieważ analizując jego ustawienia
dowiemy się więcej o powszechnie stosowanych strukturach danych i konstrukcjach
języka Clojure.
Wydajmy następujące polecenie w linii komend interaktywnej powłoki:
lein new app zakupy
lein new app zakupy
W bieżącym katalogu powstanie podkatalog zakupy zawierający pliki projektu, a na
ekranie zobaczymy komunikat:
Generating a project called zakupy based on the 'app' template.
W katalogu znajdziemy następujące pliki i katalogi:
| Nazwa | Przeznaczenie |
|---|---|
CHANGELOG.md |
Plik tekstowy w formacie Markdown służący do prowadzenia dziennika zmian |
LICENSE |
Informacje licencyjne dotyczące projektu w formie tekstowej |
README.md |
Plik tekstowy w formacie Markdown opisujący aplikację |
project.clj |
Plik z kodem Clojure zawierający ustawienia narzędzia Leiningen |
doc |
Katalog dokumentacji |
resources |
Katalog na dodatkowe zasoby (np. pliki CSV, obrazki itp.) |
src |
Katalog zawierający źródła aplikacji |
test |
Katalog zawierający źródła automatycznych testów aplikacji |
Na początku plik project.clj będzie wyglądał mniej więcej tak:
project.clj
1(defproject zakupy "0.1.0-SNAPSHOT"
2 :description "FIXME: write description"
3 :url "http://example.com/FIXME"
4 :license {:name "Eclipse Public License"
5 :url "http://www.eclipse.org/legal/epl-v10.html"}
6 :dependencies [[org.clojure/clojure "1.12.5"]]
7 :main ^:skip-aot zakupy.core
8 :target-path "target/%s"
9 :profiles {:uberjar {:aot :all}})
(defproject zakupy "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.12.5"]]
:main ^:skip-aot zakupy.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all}})
Łatwo zauważyć, że mamy do czynienia z kodem źródłowym w Clojure. Jakie jest jego przeznaczenie? Gdy Leiningen wykonuje pewne zadania (np. uruchamia interaktywną konsolę REPL czy buduje pakiet z oprogramowaniem) musi zostać chociaż w minimalnym stopniu skonfigurowany. Konfiguracja ta będzie obejmowała np. takie podstawowe parametry jak nazwa aplikacji bądź warunki licencyjne. Twórcy narzędzia postanowili, że zamiast przechowywać ustawienia w jakimś specyficznym formacie posłużą się składnią języka Clojure. Plik ustawień jest więc wczytywany z katalogu danego projektu podczas sesji pracy Leiningena.
Zanim przejdziemy dalej, otwórzmy plik project.clj w edytorze tekstowym
i dostosujmy nieco jego zawartość:
project.clj
1(defproject zakupy "1.0.0"
2 :description "Lista zakupów"
3 :url "https://randomseed.pl/"
4 :license {:name "GPL"
5 :url "https://www.gnu.org/licenses/gpl.html"}
6 :dependencies [[org.clojure/clojure "1.12.5"]]
7 :main ^:skip-aot zakupy.core
8 :target-path "target/%s"
9 :profiles {:uberjar {:aot :all}})
(defproject zakupy "1.0.0"
:description "Lista zakupów"
:url "https://randomseed.pl/"
:license {:name "GPL"
:url "https://www.gnu.org/licenses/gpl.html"}
:dependencies [[org.clojure/clojure "1.12.5"]]
:main ^:skip-aot zakupy.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all}})
Powszechne konstrukcje
Mając przed oczyma użyteczny fragment kodu źródłowego w Clojure, spróbujemy użyć go do zapoznania się z podstawowymi konstrukcjami językowymi.
Listowe S-Wyrażenia
Rozpoczynająca się nawiasem pierwsza linia otwiera tzw. listowe S-wyrażenie, czyli symbolicznie wyrażoną listę elementów. Oto prostszy przykład takiego symbolicznie zapisanego, listowego wyrażenia:
(+ 4 6 8)
(+ 4 6 8)
Podczas wartościowania niezacytowanej listy jej pierwszy element wyznacza operację. Może nazywać formę specjalną lub makro albo być wyrażeniem, którego wartość da się wywołać. Lista zacytowana pozostaje natomiast daną i niczego nie uruchamia.
Wyrażenia symboliczne mogą być zagnieżdżane:
(+ 4 6 (* 2 4))
(+ 4 6 (* 2 4))
Warto wspomnieć, że możemy mówić również o tzw. atomowych S-wyrażeniach, które nie są
kolekcjami elementów, lecz pojedynczymi konstrukcjami. W przykładowym zapisie powyżej
+ jest atomowym S-wyrażeniem (w skr. atomem), podobnie jak literały liczbowe 2
i 2. Z kolei w przykładzie z pliku project.clj takim atomowym S-wyrażeniem będzie
m.in. defproject.
Z uwagi na umiejscowienie na liście defproject zostanie potraktowany jako nazwa
operacji, którą – jak możemy się domyślać – zdefiniowano w narzędziu Leiningen
w postaci makra.
Symbole
Napis defproject jest tzw. symbolem. Symbole w Clojure są tym samym, co słowa
kluczowe, nazwy funkcji i nazwy zmiennych w innych językach. Służą
do identyfikowania wartości i innych obiektów, do nadawania im nazw.
Podczas wczytywania defproject będzie reprezentowany wartością typu
clojure.lang.Symbol. W trakcie analizy kompilator zinterpretuje ten sam symbol
w kontekście: tutaj, na pozycji operatora, rozpozna Var przechowujący makro
Leiningena. Jest to rozpoznawanie nazwy, a nie tekstowe zastępowanie symbolu na liście
zwróconej przez czytnik.
Symbole są traktowane specjalnie dopiero podczas analizowania form przeznaczonych do wartościowania. Symbol zacytowany, zapisany w kolekcji lub przekazany do makra bez wartościowania pozostaje zwykłą daną, dopóki późniejsza operacja jawnie go nie rozpozna lub nie zwartościuje.
W podanym przykładzie nie tylko defproject jest symbolem, jest nim również napis
zakupy, chociaż ich role są nieco różne.
Dzięki symbolom możemy nazywać przeróżne obiekty, lecz później przekonamy się, istnieją również sposoby na to, aby używać ich w roli zwykłych wartości, a nie identyfikatorów. W takich postaciach znajdą zastosowanie przede wszystkim podczas tworzenia makr składniowych, które przed kompilacją przekształcają formy reprezentowane jako dane Clojure.
Argumenty wywołań
Kolejne elementy listowego S-wyrażenia (aż do zamykającego nawiasu) są
argumentami, które będą przekazane do wywołania defproject. Pierwszym jest
również symbol, ale jaką wartość będzie wyrażał?
W naszym przypadku symbol zakupy zostanie użyty w sposób literalny, aby nazwać
projekt. Nie będzie rozpoznany jako forma symbolowa, lecz potraktowany jak
specyficznie wyrażona nazwa projektu.
Funkcje i makra
Na tym etapie możemy być pewni, że defproject jest makrem. Dlaczego?
W Clojure możemy wyróżnić dwie podstawowe konstrukcje pozwalające na tworzenie globalnie dostępnych podprogramów dokonujących obliczeń: funkcje i makra.
W zwykłym wywołaniu funkcji formy operandów są wartościowane przed uruchomieniem. Funkcja otrzymuje wynikowe wartości i zwraca jedną wartość, która sama może być kolekcją lub inną złożoną strukturą. Symbol na pozycji argumentu jest więc rozpoznawany przed przekazaniem jego wartości do funkcji.
Makra również przyjmują argumenty, ale w przeciwieństwie do funkcji wartości tych argumentów nie są obliczane przed przekazaniem. Oznacza to, że makro operuje na strukturach danych reprezentujących S-wyrażenia umieszczone w miejscach argumentów, a nie na wartościach tych wyrażeń. Przetwarza dane opisujące fragmenty kodu źródłowego.
Jeżeli argumentem makra jest symbol, przekazana zostanie dana typu Symbol, a nie
wartość wskazywana tym konkretnym symbolem. Nic nie stoi na przeszkodzie, aby
wewnątrz makra samodzielnie rozpoznać symbol lub zwartościować inne wyrażenie, lecz
zwykle wiąże to rozwinięcie z otaczającym stanem kompilatora i wymaga ostrożności.
Rozwijanie makr odbywa się podczas kompilowania, ładowania lub przekazywania formy do
eval, niekoniecznie przed uruchomieniem procesu.
Makro zwraca formę, która zastępuje jego wywołanie w dalszej analizie i kompilacji. Lepiej rozumieć to jako przekształcanie kodu źródłowego reprezentowanego danymi Clojure niż jako nieograniczoną modyfikację działającego programu.
Rozważmy dwa wywołania – funkcji funkcja i makra makro:
(funkcja wlazł (kotek na płotek))
(makro wlazł (kotek na płotek))
(funkcja wlazł (kotek na płotek))
(makro wlazł (kotek na płotek))
Pierwsze z wywołań jest poprawne składniowo, ale nie znaczeniowo – próba uzyskania
wartości, nawet gdy funkcja funkcja istnieje, zakończy się błędem. Dlaczego?
Symbole wlazł, na i płotek nie zostały wcześniej powiązane z żadnymi
wartościami, a symbol kotek nie identyfikuje żadnej funkcji bądź makra, chociaż
znajduje się na pierwszej pozycji listowego S-wyrażenia. Nie jest to więc lispowa
forma.
Drugie z wywołań ma dużą szansę wykonać się poprawnie, ponieważ wartościami
argumentów przekazywanych do makra makro będą pamięciowe reprezentacje S-wyrażeń:
symbol wlazł (reprezentowany obiektem typu Symbol) oraz lista symboli kotek,
na i płotek (reprezentowana obiektem typu PersistentList).
Gdyby defproject z naszego pliku konfiguracyjnego był funkcją, to musiałoby
wcześniej istnieć powiązanie symbolu zakupy z jakąś wartością. Można więc z dużym
prawdopodobieństwem powiedzieć, że mamy do czynienia z makrem.
Łańcuchy znakowe
Kolejny argument wywołania defproject to łańcuch znakowy 1.0.0. Jego literał
rozpoznamy po otaczających cudzysłowach. W Clojure łańcuchy znakowe reprezentowane są
obiektami typu java.lang.String.
Tu łańcuch znakowy określa wersję naszego projektu.
Klucze
Dalsze argumenty przekazywane do wywołania makra defproject cechuje pewna
konsekwencja: nieparzyste elementy są poprzedzone znakami dwukropka (:). Znak ten
ma w Clojure specjalne znaczenie leksykalne i zapisane w ten sposób etykiety stają
się słowami kluczowymi (ang. keywords), zwanymi potocznie kluczami
(ang. keys).
Klucz w Clojure to obiekt typu Keyword. Przypomina symbol, lecz służy
do identyfikowania w programach elementów struktur użytkowych, a nie wartości
w kodzie źródłowym. Symbole są w procesie ewaluacji programu rozpoznawane jako formy
symbolowe i przekształcane na powiązane z nimi wartości, a słowa kluczowe
nie. Wartością klucza po wartościowaniu jest on sam. Clojure na JVM internalizuje
klucze, lecz logika programu powinna polegać na ich równości wartościowej i semantyce
tożsamości, a nie na szczególe przydziału obiektów.
W przypadku pliku project.clj słowa kluczowe pełnią rolę nazw parametrów
konfiguracyjnych projektu, a sparowane z nimi elementy wyrażają wartości tych
parametrów:
| Identyfikator parametru | Znaczenie |
|---|---|
:description |
Zwięzły opis |
:url |
URL strony WWW aplikacji |
:license:name:url |
Mapa informacji licencyjnych Nazwa licencji URL licencji |
:dependencies |
Wektor zależności |
:main |
Funkcja główna |
:target-path |
Ścieżka docelowa |
:profiles:uberjar:aot |
Mapa profili Mapa profilu uberjar Ustawienie kompilacji AOT |
Wektory
W tabeli pojawiły się dwa ciekawe określenia: wektor i mapa. Zacznijmy od wektora
(ang. vector). Jest to struktura danych (typu PersistentVector) przypominająca
znane z innych języków programowania tablice, tzn. mamy do czynienia z kolekcją,
której elementy są uporządkowane i indeksowane z użyciem liczb naturalnych. Możemy
więc szybko odwoływać się do wskazanego podając jego numer (począwszy od 0).
W przypadku parametru :dependencies, oznaczającego zależności, wartością jest
wektor, który w Clojure można wyrażać z użyciem literału składającego się
z kwadratowych nawiasów. W takiej formie składniowej będziemy mogli też tego typu
zapis nazwać wektorowym S-wyrażeniem.
Oto prostszy przykład wektorowego S-wyrażenia, które jest symboliczną reprezentacją wektora:
[2 4 6 8]
[2 4 6 8]
W naszym pliku opisującym projekt wektor ma tylko jeden element, którym jest inny
wektor. W REPL cytujemy ten fragment, ponieważ poza otaczającym go makrem
defproject współrzędna zależności zostałaby potraktowana jak symbol do
rozpoznania:
'[[org.clojure/clojure "1.12.5"]]
'[[org.clojure/clojure "1.12.5"]]
Wynika to z faktu, że zależności może być wiele. Każda z nich będzie wyrażona parą wartości w wektorowym S-wyrażeniu.
W naszym przykładzie pierwszy i ostatni zagnieżdżony wektor ma dokładnie dwa
elementy. W ten sposób komunikujemy, że nasza aplikacja do poprawnej pracy wymaga
komponentu org.clojure/clojure w wersji 1.12.5.
Warto zauważyć, że pierwszy element to również symbol, który pełni funkcję użytkową, a nie składniową – określa on nazwę pakietu oprogramowania z sieciowego repozytorium Maven Central Repository, aby Leiningen mógł go pobrać. Pakietem tym jest język Clojure w podanym wydaniu. Jeżeli jesteśmy ciekawscy, możemy samodzielnie przeszukać repozytorium Mavena korzystając z interfejsu WWW.
Mapy
Kolejną konstrukcją wartą wzmianki jest mapa (ang. map). Mapa to asocjacyjna
struktura danych reprezentowana przez implementacje takie jak PersistentArrayMap
i PersistentHashMap. Małe literały często rozpoczynają życie jako mapy tablicowe,
a wraz ze wzrostem mogą zmienić implementację; kontrakt mapy nie zależy od tego
wyboru. Mapa pozwala kojarzyć klucze i wartości dowolnych typów.
Elementy map używane do indeksowania nazywamy zwyczajowo kluczami, a przypisane do nich obiekty wartościami. Mapy przypominają tablice asocjacyjne i słowniki znane z innych języków programowania:
{:a 2 :b 4 "x" 6 "y" 8}
{:a 2 :b 4 "x" 6 "y" 8}
W wywołaniu defproject mapa pojawia się w formie mapowego S-wyrażenia,
reprezentowanego literałem złożonym z klamrowych nawiasów, wewnątrz których
umieszczono pary elementów:
{:name "Eclipse Public License"
:url "https://www.gnu.org/licenses/gpl.html"}
{:name "Eclipse Public License"
:url "https://www.gnu.org/licenses/gpl.html"}
W naszej definicji projektu mapa używana jest po to, aby wyrazić informacje
licencyjne i ustawienia tzw. profili. Pierwsza para przypisuje klucz :name do
łańcucha znakowego z nazwą licencji, a druga klucz :url do łańcucha
reprezentującego URL, pod którym znajdziemy tekst z warunkami ponownego
wykorzystania.
W dalszej części wywołania defproject możemy też zauważyć, że wartościami
umieszczanymi w mapach mogą być również inne mapy – mamy wtedy do czynienia ze
strukturami zagnieżdżonymi.
Główny plik programu
W podkatalogu src szkieletu projektu stworzonego przez narzędzie Leiningen
znajdziemy podkatalog zakupy, w którym umieszczony został plik core.clj
z następującą zawartością:
src/zakupy/core.clj
1(ns zakupy.core
2 (:gen-class))
3
4(defn -main
5 "I don't do a whole lot ... yet."
6 [& args]
7 (println "Hello, World!"))
(ns zakupy.core
(:gen-class))
(defn -main
"I don't do a whole lot ... yet."
[& args]
(println "Hello, World!"))
Pierwsze dwie linie to S-wyrażenie, w którym mamy do czynienia z wywołaniem
wbudowanego w Clojure makra identyfikowanego symbolem ns. Do wywołania przekazywane
są dwa argumenty:
zakupy.core– symbol,(:gen-class)– listowe S-wyrażenie zawierające słowo kluczowe.
Przestrzeń nazw
Makro ns służy do ustawiania bieżącej przestrzeni nazw (ang. namespace)
i tworzenia jej, jeżeli jeszcze nie istnieje.
Przestrzenie nazw umożliwiają grupowanie globalnie widocznych identyfikatorów w celu eliminowania konfliktów. Gdybyśmy w programie chcieli użyć dwóch zewnętrznych bibliotek, w których zdefiniowano dwie funkcje o takiej samej nazwie, lecz zupełnie różnym przeznaczeniu, to wystąpiłby konflikt lub przesłonięcie identyfikatora pochodzącego z biblioteki załadowanej wcześniej. Przestrzenie nazw rozwiązują ten problem, bo możemy odwoływać się do funkcji podając nie tylko jej nazwę, ale również przestrzeń nazw, do której tę nazwę przypisano.
W Clojure przestrzeń nazw jest nazwanym obiektem clojure.lang.Namespace, który
utrzymuje odwzorowania symboli na Vary i zaimportowane klasy Javy. Do wartości
i funkcji docieramy zwykle przez główne lub bieżące powiązanie odwzorowanego Vara;
nie są one przechowywane bezpośrednio jako dowolne wpisy przestrzeni.
W naszym programie makro ns tworzy przestrzeń nazw zakupy.core i czyni ją
bieżącą podczas ładowania pliku. W trakcie analizy kompilatora niekwalifikowany
symbol, który nie jest formą specjalną ani powiązaniem leksykalnym, może być wtedy
rozpoznany za pomocą odwzorowań tej przestrzeni. Symbole kwalifikowane, importowane
klasy i składowe gospodarza podlegają własnym regułom.
Vary z innej przestrzeni można odnieść pod niekwalifikowanymi symbolami, a klasy
Javy – zaimportować. Domyślnie ns odnosi publiczne Vary z clojure.core, chyba że
zażądamy inaczej. Dzięki temu możemy użyć (+ 2 2) zamiast
(clojure.core/+ 2 2); druga postać używa symbolu z dookreśloną przestrzenią nazw.
Formy specjalne Clojure są rozpoznawane przez kompilator i nie są Varami
importowanymi z clojure.core.
Zapis :gen-class z linii nr 2 jest jedną z dyrektyw sterujących makrem ns.
Podczas kompilacji AOT żąda wygenerowania klasy Javy zgodnie z kontraktem
gen-class. W tym szablonie aplikacji klasa udostępnia statyczny punkt wejścia Javy
main i deleguje go do funkcji Clojure -main. Początkowy łącznik jest konwencją
nazewniczą funkcji implementujących metody wybrane przez ten kontrakt; sam przez się
nie udostępnia każdej takiej funkcji jako publicznej metody Javy.
Funkcja główna
W linii nr 4 widzimy kolejne S-wyrażenie:
src/zakupy/core.clj
(defn -main
"I don't do a whole lot ... yet."
[& args]
(println "Hello, World!"))
Wywoływanym podprogramem jest w nim defn, który w Clojure jest wbudowanym makrem
służącym do definiowania funkcji nazwanych. Efektem wywołania jest:
- Stworzenie funkcyjnego obiektu.
- Internalizacja Vara pod danym symbolem w bieżącej przestrzeni nazw, z obiektem funkcji jako jego powiązaniem głównym.
Pierwszy argument defn to nazwa funkcji, która tutaj określona jest symbolem
-main. Wraz z ustawieniem aplikacji :gen-class ta konwencjonalna nazwa dostarcza
implementację punktu wejścia Javy main.
W naszym przypadku przestrzenią bieżącą jest zakupy.core. W programie będziemy
mogli więc identyfikować funkcję z użyciem formy symbolowej -main lub
zakupy.core/-main.
Kolejny argument makra to łańcuch znakowy. Umieszczony na tej pozycji staje się
tzw. łańcuchem dokumentującym, czyli zrozumiałym dla człowieka opisem, który
można wyświetlać w REPL z użyciem makra doc.
Następne zagnieżdżone S-wyrażenie przekazywane do wywołania defn to literalna
reprezentacja wektora, która przedstawia listę argumentów przyjmowanych przez
definiowaną funkcję. Z perspektywy wnętrza funkcji nazwiemy je parametrami,
a konstrukcję wektorem parametrycznym.
W tym przypadku wektor zawiera dwa symbole: & i args. Pierwszy z nich ma
specjalne znaczenie i pozwala potraktować następny jako tzw. parametr
wariadyczny, czyli strukturę, w której znajdą się wartości nieobowiązkowych
argumentów przekazywanych do wywołania. W naszym przypadku argumenty obowiązkowe nie
istnieją, więc w kolekcji identyfikowanej symbolem args znajdziemy wartości
wszystkich przekazanych argumentów.
Ostatnie wyrażenie symboliczne (println "Hello, World!") to ciało funkcji,
czyli kod uruchamiany za każdym razem, gdy dojdzie do wywołania funkcji
i zaaplikowania listy argumentów. W ciele funkcji możemy korzystać z symboli podanych
w wektorze parametrycznym i przeprowadzać na nich obliczenia. Jeżeli ciało funkcji
zawiera więcej niż jedno wyrażenie, wartością zwracaną będzie wartość ostatniego.
Wbudowana funkcja println zwraca zawsze wartość pustą, ale używamy jej ze względu
na powodowany efekt uboczny, jakim jest wyświetlenie w tekstowej postaci wartości
podanych argumentów i zakończenie znakiem nowej linii.
Możemy zmienić ciało funkcji, aby wyświetlany był inny tekst:
src/zakupy/core.clj
1(defn -main
2 "Wyświetla powitanie"
3 [& args]
4 (println "Witaj, Lispie!")
5 (println "Moje argumenty:")
6 (println args))
(defn -main
"Wyświetla powitanie"
[& args]
(println "Witaj, Lispie!")
(println "Moje argumenty:")
(println args))
Uruchamianie programu
Narzędzie Leiningen pozwala nam uruchamiać stworzony program bez wywoływania interaktywnej konsoli. Wejdźmy do katalogu projektu i wydajmy następujące polecenie:
lein run
lein run
Na ekranie powinniśmy ujrzeć:
Witaj, Lispie!
Moje argumenty:
nil
Linia wyświetlająca przekazane wartości argumentów zawiera napis nil. Clojure używa
nil do reprezentowania braku wartości; tutaj jest to również pusta sekwencja
argumentów resztowych, ponieważ nasza funkcja nie otrzymała ani jednego argumentu.
Spróbujmy raz jeszcze, ale tym razem przekażmy do polecenia jakieś parametry:
lein run raz dwa trzy
lein run raz dwa trzy
Tym razem zobaczymy:
Witaj, Lispie!
Moje argumenty:
(raz dwa trzy)
Nawiasy są wypisaną reprezentacją kolekcji sekwencyjnej. Parametr wariadyczny
otrzymuje sekwencję; wywołujący nie powinien polegać na konkretnej implementacji
listy. Argumenty wywołania metody main z klasy zakupy.core pochodzą z parametrów
wywołania lein run i są przekazywane przez Leiningena jako łańcuchy znakowe.
Budowanie archiwum Javy
Jednym ze sposobów dystrybuowania aplikacji Javy – a taką po wstępnej kompilacji staje się nasz program – jest umieszczanie wszystkich potrzebnych komponentów w pliku archiwum Javy (ang. Java archive, skr. JAR). Aby zbudować takie archiwum musimy wydać następujące polecenie:
lein uberjar
lein uberjar
Zobaczymy komunikaty diagnostyczne podobne do poniższych:
Compiling zakupy.core
Created zakupy/target/uberjar/zakupy-1.0.0.jar
Created zakupy/target/uberjar/zakupy-1.0.0-standalone.jar
Możemy teraz przejść do podkatalogu target/uberjar i spróbować uruchomić program
tak, jakby był samodzielną aplikacją Javy:
cd target/uberjar
java -classpath zakupy-1.0.0-standalone.jar zakupy.core
Parametr -classpath określa tu ścieżkę dostępu do zdefiniowanych klas, natomiast
zakupy.core jest klasą Javy odpowiadającą przestrzeni nazw z naszego
programu. Efekt wywołania nie powinien różnić się od poprzedniego.
Samodzielne archiwum utworzone przez to zadanie zawiera zależności projektu, w tym
wybrane środowisko uruchomieniowe Clojure. Dokładna zawartość źródeł i klas jest
własnością konfiguracji budowania Leiningena, a nie konsekwencją dynamicznej natury
Clojure. W tym przykładzie profil :uberjar żąda kompilacji AOT wszystkich
przestrzeni nazw projektu. Inne aplikacje mogą kompilować przestrzenie podczas
ładowania lub pakować inną mieszaninę źródeł i klas; zamiast zakładać jeden
uniwersalny układ, należy zbadać archiwum.
Interaktywna praca z projektem
Interaktywna konsola REPL może nam posłużyć nie tylko do testowania pomysłów, ale
również interaktywnego rozwijania istniejących programów. Jeżeli wykonamy polecenie
lein repl w katalogu projektu, zobaczymy komunikat podobny do poniższego:
REPL server started on port 58947 on host 127.0.0.1 - nrepl://127.0.0.1:58947
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.12.5
Java HotSpot(TM) 64-Bit Server VM 10.0.2+13
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
zakupy.core=>
Pierwsze, co rzuca się w oczy, to nieco inny monit REPL, niż w przypadku
wcześniejszej sesji. Teraz wskazuje on bieżącą przestrzeń zakupy.core. Sprawdźmy,
czy mamy dostęp do umieszczonego w tej przestrzeni powiązania symbolu -main
z funkcją:
1(fn? -main)
2; => true
3
4(-main)
5; >> Witaj, Lispie!
6; >> Moje argumenty:
7; >> nil
8; => nil
9
10(-main 1 2 3)
11; >> Witaj, Lispie!
12; >> Moje argumenty:
13; >> (1 2 3)
14; => nil
(fn? -main)
; => true
(-main)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> nil
; => nil
(-main 1 2 3)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> (1 2 3)
; => nil
W pierwszym wyrażeniu kompilator rozpoznaje symbol -main jako odwołanie do Vara,
a wartościowanie pobiera z bieżącego powiązania Vara obiekt funkcji. Predykat fn?
potwierdza typ tej wartości bez polegania na zależnej od wersji reprezentacji
tekstowej wygenerowanej klasy funkcji.
Linia czwarta również zawiera ten sam symbol, jednak jest on częścią formy wywołania
funkcji, ponieważ znajduje się na pierwszej pozycji listowego S-wyrażenia. Ujrzymy
więc rezutat wykonania funkcji -main.
Ostatnie wywołanie funkcji (linia nr 10) zawiera też wartości, które zostaną zaaplikowane. W rezultacie ujrzymy tekstową reprezentację ich struktury.
Przeładowywanie kodu
Pracując z konsolą REPL w obrębie projektu możemy czasem chcieć, aby środowisko
odzwierciedlało zmiany wprowadzone w plikach źródłowych. Nie dzieje się to
automatycznie, a najprostszym sposobem jest zakończenie sesji i ponowne wywołanie
lein repl. Może to być frustrujące, ponieważ start maszyny wirtualnej Javy
i załadowanie rdzenia języka Clojure są procesami czasochłonnymi. Na szczęście
istnieją inne, bardziej efektywne rozwiązania.
W tym prostym przykładzie możemy posłużyć się wbudowaną funkcją require. Ładuje ona
wskazaną bibliotekę bez odnoszenia wszystkich jej publicznych odwzorowań do
przestrzeni bieżącej. Pierwszym argumentem jest literalny symbol z nazwą biblioteki.
Opcja :reload powoduje ponowne ładowanie nawet wtedy, gdy kod został już wcześniej
załadowany.
Jeżeli konsola REPL nie została jeszcze uruchomiona, użyjemy lein repl w katalogu
projektu. Jednocześnie otwórzmy plik src/zakupy/core.clj w edytorze i dokonajmy
małej modyfikacji, aby nasza funkcja główna wyglądała nieco inaczej, na przykład tak:
src/zakupy/core.clj
(defn -main
"Wyświetla powitanie"
[& args]
(println "Witaj, Lispie!"))
Gdy teraz w REPL wywołamy funkcję, nie zobaczymy jeszcze zmian:
(-main)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> nil
; => nil
(-main)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> nil
; => nil
Spróbujmy jeszcze raz, lecz teraz poprzedźmy wywołanie naszej funkcji wywołaniem
require:
(require 'zakupy.core :reload)
; => nil
(-main)
; >> Witaj, Lispie!
; => nil
(require 'zakupy.core :reload)
; => nil
(-main)
; >> Witaj, Lispie!
; => nil
Udało się! require spowodował ponowne wczytanie i wartościowanie pliku
src/zakupy/core.clj. Powiązanie główne istniejącego Vara zakupy.core/-main
przechowuje teraz nowy obiekt funkcji.
Skąd require wiedział, gdzie szukać pliku? Skorzystał z konwencji,
według której należy przeszukać podkatalog src, aby znaleźć ścieżkę podkatalogów
nazwanych tak samo jak oddzielone znakami kropki części nazwy, za wyjątkiem jej
ostatniego fragmentu, który wskazuje na nazwę pliku z rozszerzeniem clj. Dla
symbolu zakupy.core poszukiwaną ścieżką była więc zakupy/core.clj.
Warto wspomnieć o zapisie 'zakupy.core w pierwszej linii. Umieszczony przed nazwą
symbolu apostrof sprawia, że zapis nie zostanie potraktowany jako forma symbolowa,
której wartością jest powiązany z symbolem obiekt, lecz jako symbol literalny. Jest
to konieczne, ponieważ require jest funkcją i chcąc przekazać jej
symbol zamiast powiązanej z nim wartości musimy wyłączyć jego specjalne znaczenie
składniowe. Zabieg ten nazywamy cytowaniem (ang. quote) i będzie dokładniej
omówiony później.
W głównej funkcji, poza usunięciem kilku linii, dokonaliśmy również zmiany łańcucha dokumentującego. Sprawdźmy, czy tu także widać zmianę:
(doc -main)
; >> -------------------------
; >> zakupy.core/-main
; >> ([& args])
; >> Wyświetla powitanie
; => nil
(doc -main)
; >> -------------------------
; >> zakupy.core/-main
; >> ([& args])
; >> Wyświetla powitanie
; => nil
Podsumowanie
Podstawowe pojęcia
W rozdziale tym pojawiło się kilka ważnych terminów. Niektóre są wspólne dla wszystkich języków programowania, a inne specyficzne dla Clojure. Przypomnijmy je sobie:
- Czytnik
- komponent zmieniający znaki zgodne ze składnią czytnika Clojure w zwykłe wartości Clojure
- Ewaluator
- proces analizowania formy w kontekście, kompilowania jej i uzyskiwania jej wartości
- Forma
- wartość Clojure przedstawiona do wartościowania
- Kod źródłowy
- czytelna postać programu komputerowego
- Lista
- struktura danych składająca się z połączonych ze sobą elementów; wykorzystywana głównie do reprezentowania w pamięci listowych S-wyrażeń odzwierciedlających wyrażenia kodu źródłowego po wczytaniu
- Literał
- jednostka leksykalna, która wyraża ustaloną wartość w kodzie źródłowym; rozpoznawana w pierwszych etapach pracy czytnika
- Mapa
- asocjacyjna struktura danych, która pozwala przypisywać unikatowe w jej obrębie klucze do wartości
- Przestrzeń nazw
- nazwany obiekt utrzymujący odwzorowania symboli na Vary i zaimportowane klasy Javy
- Symbol
- czytelny identyfikator; używany do nazywania wartości, funkcji i zmiennych
- Wyrażenie symboliczne (S-wyrażenie)
- sposób zapisu pozwalający wyrażać pojedyncze lub złożone kolekcje elementów; używany do organizowania kodu źródłowego
- Zmienna globalna
- umieszczony w przestrzeni nazw obiekt referencyjny typu
Var, który identyfikowany jest symbolem i ma powiązanie główne; wykorzystywany do nazywania funkcji, ustawień programu i innych globalnie osiągalnych wartości - Wektor
- struktura danych składająca się z listy elementów o pozycjach identyfikowanych numerami indeksów
Funkcje pomocnicze
W środowisku interaktywnym konsoli REPL możemy korzystać z przydatnych, pomocniczych konstrukcji:
-
(doc nazwa)
wyświetla dokumentację dla elementu o wskazanej nazwie; -
(find-doc fragment)
działa jakdoc, ale można podać fragment nazwy lub wyrażenie regularne; -
(apropos fragment)
zwraca sekwencję znalezionych elementów we wszystkich przestrzeniach nazw na podstawie fragmentu nazwy lub wyrażenia regularnego; -
(javadoc klasa)
dla podanej klasy lub obiektu Javy otwiera plik z dokumentacją w przeglądarce; -
(source nazwa)
wyświetla dostępny kod źródłowy Vara o podanej nazwie; -
(pst)
wyświetla informacje dot. śledzenia stosu wywołań (ang. stack trace).
Przykłady wyrażeń
Na zakończenie spróbujmy wprowadzić w REPL trochę bardziej skomplikowane wyrażenia. Każde opatrzone będzie komentarzem, który skrótowo wyjaśni do czego służy umieszczona w listingu konstrukcja. W tym momencie nie musimy dokładnie wiedzieć, jak w pełni korzystać z podanych funkcji czy makr – potraktujmy podane przykłady jako wstęp do kolejnych odcinków, w których zostaną one szerzej opisane.
1;; wartość nieustalona
2
3nil
4; => nil
5
6;; zwiększanie o jeden
7
8(inc 3)
9; => 4
10
11;; zagnieżdżone operacje
12
13(+ 1 2 3 (inc 1))
14; => 8
15
16;; powiązania leksykalne
17;; symboli z wartościami
18
19(let [a 5
20 b 1]
21 (- a b))
22; => 4
23
24;; powiązanie globalne
25;; symbolu z funkcją
26
27(def hejka
28 (fn [] "hej!"))
29; => #'user/hejka
30
31;; wywołanie funkcji hejka
32
33(hejka)
34; => "hej!"
35
36;; definiowanie funkcji nazwanej
37;; z użyciem makra defn
38
39(defn siemka [] "siema!")
40; => #'user/siemka
41
42;; wywołanie funkcji siemka
43
44(siemka)
45; => "siema!"
46
47;; definiowanie funkcji
48;; z domknięciem symboli
49;; powiązanych leksykalnie w otaczającym środowisku
50
51(def dodaj-2
52 (let [a 2]
53 (fn [b] (+ a b))))
54; => #'user/dodaj-2
55
56;; wywołanie funkcji dodaj-2
57
58(dodaj-2 6)
59; => 8
60
61;; definiowanie funkcji
62;; z domknięciem symboli
63;; powiązanych z argumentami wywołania funkcji otaczającej
64
65(defn generuj-sumator [x]
66 (fn [y] (+ x y)))
67; => #'user/generuj-sumator
68
69;; wywołanie funkcji generuj-sumator
70;; w celu wytworzenia sumatora dodającego 10
71;; i przypisanie zwracanej funkcji globalnej nazwy
72
73(def dodaj-10 (generuj-sumator 10))
74; => #'user/dodaj-10
75
76;; wywołanie funkcji dodaj-10
77;; zwróconej przez generuj-sumator z argumentem 10
78
79(dodaj-10 2)
80; => 12
81
82;; zacytowany symbol
83
84'symbol-literalny
85; => symbol-literalny
86
87;; zacytowane listowe S-wyrażenie
88
89'(+ 2 2 trzy)
90; => (+ 2 2 trzy)
;; wartość nieustalona
nil
; => nil
;; zwiększanie o jeden
(inc 3)
; => 4
;; zagnieżdżone operacje
(+ 1 2 3 (inc 1))
; => 8
;; powiązania leksykalne
;; symboli z wartościami
(let [a 5
b 1]
(- a b))
; => 4
;; powiązanie globalne
;; symbolu z funkcją
(def hejka
(fn [] "hej!"))
; => #'user/hejka
;; wywołanie funkcji hejka
(hejka)
; => "hej!"
;; definiowanie funkcji nazwanej
;; z użyciem makra defn
(defn siemka [] "siema!")
; => #'user/siemka
;; wywołanie funkcji siemka
(siemka)
; => "siema!"
;; definiowanie funkcji
;; z domknięciem symboli
;; powiązanych leksykalnie w otaczającym środowisku
(def dodaj-2
(let [a 2]
(fn [b] (+ a b))))
; => #'user/dodaj-2
;; wywołanie funkcji dodaj-2
(dodaj-2 6)
; => 8
;; definiowanie funkcji
;; z domknięciem symboli
;; powiązanych z argumentami wywołania funkcji otaczającej
(defn generuj-sumator [x]
(fn [y] (+ x y)))
; => #'user/generuj-sumator
;; wywołanie funkcji generuj-sumator
;; w celu wytworzenia sumatora dodającego 10
;; i przypisanie zwracanej funkcji globalnej nazwy
(def dodaj-10 (generuj-sumator 10))
; => #'user/dodaj-10
;; wywołanie funkcji dodaj-10
;; zwróconej przez generuj-sumator z argumentem 10
(dodaj-10 2)
; => 12
;; zacytowany symbol
'symbol-literalny
; => symbol-literalny
;; zacytowane listowe S-wyrażenie
'(+ 2 2 trzy)
; => (+ 2 2 trzy)