Clojure, jak każdy dialekt 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). Oznacza to, że fundamentalne elementy języka są zaimplementowane w języku Java, a rezultatem kompilacji programów w Clojure będą pliki z pseudokodem, który można uruchomić z użyciem JVM. Można powiedzieć, że Clojure jest napisany w Javie, lecz różni się od niej składnią i gramatyką.
W pewnych kontekstach Clojure zachowuje się jak język interpretowany, ponieważ spora część kodu źródłowego programów jest tłumaczona jest na pseudokod (a następnie na język maszynowy) w czasie uruchamiania, aby korzystać z dobrodziejstw dynamicznego typizowania i polimorfizmu. Odpowiada za to kompilator JIT (ang. just-in-time compiler).
Instalacja
Popularnym sposobem instalacji języka Clojure jest skorzystanie ze skryptu Leiningen. Do poprawnej pracy wymagane jest środowisko uruchomieniowe Javy.
Leiningen to program, który pozwala szybko inicjować projekty. Ich zależności od
dodatkowych komponentów (bibliotek programistycznych, a także wersji samego języka
Clojure) jesteśmy w stanie określać z użyciem pliku konfiguracyjnego project.clj
umieszczanego w ich katalogach.
Aby skorzystać z Leiningena w systemach typu Unix, najlepiej utworzyć w katalogu
domowym podkatalog bin
(jeżeli jeszcze nie istnieje), a w pliku startowym powłoki
(np. .profile
) dodać ustawianie zmiennej PATH
w taki sposób, aby przeszukiwana
była również ta ścieżka:
Potem możemy już pobrać i zainstalować stabilne wydanie narzędzia:
CLI i deps.edn
Od roku 2018 sensowną alternatywą w stosunku do zewnętrznych narzędzi, takich jak Leiningen, jest korzystanie z mechanizmów już istniejących w dystrybuowanych pakietach języka:
- polecenia
clojure
do uruchamiania programów, - polecenia
clj
do interaktywnej pracy, - pliku
deps.edn
do specyfikowania zależności konkretnego projektu.
Należy oczywiście zacząć od instalacji samego języka, która będzie wyglądała różnie w zależności od systemu operacyjnego. Instrukcje zawarto w sekcji Getting Started oficjalnej strony języka Clojure.
Warto zaznaczyć, że w przypadku dystrybucji systemów GNU/Linux instalacja z użyciem managera pakietów i paczek przygotowanych przez dystrybutora (np. DEB czy RPM) może być nieco przestarzała: będzie w niej brakowało niektórych poleceń bądź niektóre z opcji okażą się nieobsługiwane. Najlepiej więc posłużyć się oficjalnym skryptem instalacyjnym ze strony języka.
Wydanie dla Mac OS-a znajdziemy w repozytorium pakietów Brew (instalacja jest
banalna i polega na wykonaniu brew install clojure
). Użytkownicy Windows mogą
zaś skorzystać z podręcznika instalacji „clj on
Windows”.
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). Bazuje on na S-wyrażeniach i stworzony został przez osoby zajmujące się
rozwojem języka Clojure. W istocie EDN jest podzbiorem składni Clojure. Możemy myśleć
o tym formacie jak o bezpiecznym sposobie zapisywania danych, z których korzystają
programy. Bezpiecznym w tym sensie, że żadne z nich nie zostaną przypadkowo
potraktowane jak kod, który trzeba zrealizować.
Zobacz także:
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, mając bezpośredni dostęp do wszystkich globalnych identyfikatorów (nazw funkcji, wartości zmiennych globalnych czy klas Javy).
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. Istnieją projekty, jak np. CIDER, które pozwalają na integrowanie popularnych edytorów, takich jak ViM czy Emacs, 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 dochodzi do pobrania danych z dysku lub standardowego wejścia i sprawdzenia ich pod względem składniowym. Dokonuje tego komponent zwany czytnikiem (ang. reader). Jeżeli składnia jest poprawna, znalezione w programie wyrażenia są zmieniane w odpowiadające im obiekty w pamięci, które przedstawiają kod źródłowy w postaci zrozumiałej dla kompilatora.
Na tym jednak nie koniec, ponieważ z przygotowanych danych wyrażających program należy zrobić jakiś użytek i zacząć je interpretować pod względem znaczeniowym. W tym momencie do akcji wkracza ewaluator (ang. evaluator), który przetwarza umieszczone w pamięci struktury i próbuje je wartościować.
Praca z konsolą
Spróbujmy zaprzyjaźnić się z interaktywną konsolą języka korzystając z polecenia
lein repl
lub clj
:
Po uruchomieniu powinniśmy ujrzeć na wyjściu rezultat podobny do zaprezentowanego poniżej:
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 – specjalnej mapy odwzorowań, o której jeszcze
wspomnimy.
Prosty przykład
Spróbujmy zacząć najprościej, jak się da, wpisując w konsoli REPL tekst z poniższego listingu:
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 przeanalizował linię i wyodrębnił z niej tzw. leksemy, czyli konstrukcje mające znaczenie składniowe:
123
oraz;
. Nie znalazł żadnego znacznika, który sprawiałby, że z dalszą analizą należy poczekać do następnej linii.Czytnik rozpoznał następujące rodzaje leksemów (tzw. tokeny):
- literał liczbowy (
123
), - komentarz (
;
i wszystkie znaki do końca linii).
- literał liczbowy (
W procesie parsowania przez czytnik:
- literał
123
został rozpoznany jako tzw. wyrażenie symboliczne, - komentarz został zignorowany.
- literał
Czytnik stworzył pamięciową reprezentację kodu źródłowego, w której wyrażenie
123
reprezentowane jest daną typujava.lang.Long
przechowującą wartość 123.Kontrolę nad dalszym uruchamianiem przejął ewaluator (komponent odpowiedzialny za analizę znaczeniową programu).
Ewaluator zaczął wartościować obiekty umieszczone w pamięci przez czytnik.
Reprezentowane typem
Long
wyrażenie 123 zostało rozpoznane jako tzw. forma stała, czyli obiekt, który przedstawia wartość własną.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:
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 specjalnej mapie zwanej przestrzenią 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
. Ten ostatni będzie zawierał odwołanie
do kodu funkcji, czyli do obiektu typu clojure.lang.Fn
.
Dlaczego print
od razu nie odnosi się do obiektu funkcji? Związane jest to z budową
języka Clojure, jego podejściem do wykonywania współbieżnego i obsługi danych
niemutowalnych. Pośredni obiekt typu Var
zapewnia odpowiednią izolację i strategię
aktualizowania wartości, gdy program składa się z wielu równolegle działających
wątków.
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 zachłannego wartościowania każdego
przekazywanego argumentu. W tym przypadku wartość 123 (typu java.lang.Long
) nie
będzie dalej przeliczana, ponieważ zostanie rozpoznana jako tzw. forma stała,
czyli taka, której wartość nie zmienia się podczas wartościowania.
Formami stałymi będą nie tylko wartości (formy normalne), ale również wyrażenia, których przeliczanie zostało celowo wstrzymane z użyciem tzw. cytowania (formy cytowania).
W tym momencie znamy około 80% składni Clojure i 90% składni Lispów w ogóle. Brawo!
Uwaga: Ponieważ Clojure jest zaimplementowany w innym języku wysokiego poziomu, większość przetwarzanych konstrukcji składniowych jest wartościowana już na etapie ich wczytywania i rozpoznawania poprawnych wyrażeń. Nie oznacza to, że wiedza dotycząca kolejnych etapów przetwarzania jest nieadekwatna, lecz warto mieć na uwadze, iż w procesie tym dochodzi do daleko idących optymalizacji. W praktyce więc niektóre z faz tłumaczenia wczytanego programu są realizowane błyskawicznie i w oparciu o istniejące już mechanizmy języka-gospodarza (JVM).
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 możemy spotkać się z różnymi terminami
używanymi w odniesieniu do tych samych elementów programu. Najpierw fragment 123
nazywamy literałem, potem wyrażeniem symbolicznym, a na końcu formą. Wynika to
z różnych poziomów abstrakcji używanych w odniesieniu do programu, które zakorzenione
są w różnych etapach jego tłumaczenia z postaci tekstowej na wykonywalną (a nawet
wykonywaną).
Aby swobodniej było nam korzystać z terminologii opisującej programowanie w Clojure (i innych dialektach języka Lisp), warto zapoznać się z poniższą tabelą, która ilustruje pojęcia pojawiające się na różnych poziomach abstrakcji – od konstrukcji konkretnych po najbardziej abstrakcyjne.
Jednostka | Opis | Poziom |
---|---|---|
Sekwencja znaków | Kod źródłowy w postaci tekstowej | Tekstowy |
Leksem | Fragment tekstu programu rozpoznany jako znana konstrukcja leksykalna | Leksykalny |
Wyrażenie symboliczne | Wyodrębniony element składni, który wyraża wartość lub operację | Gramatyczny |
Forma | Wyrażenie, którego wartość można obliczyć | Semantyczny |
Wartość | Rezultat obliczeń wyrażenia | Danych |
Spróbujmy rozpisać w ten sposób nieco bardziej skomplikowany program:
Zapis | Jednostka | Poziom |
---|---|---|
(+ 2 2) |
Kod źródłowy | Tekstowy |
( + 2 2 ) |
Literał listy (początek) Symbol Literał liczbowy Literał liczbowy Literał listy (koniec) |
Leksykalny |
+ 2 2 |
Wyrażenia symboliczne (atomowe) | Gramatyczny |
( + 2 2 ) |
Wyrażenie symboliczne (listowe) złożone z atomowych wyrażeń |
Gramatyczny |
+ [typ Symbol ] 2 [typ Long ] 2 [typ Long ] |
Forma symbolowa (identyfikator) Forma stała (wartość własna) Forma stała (wartość własna) |
Semantyczny |
(clojure.core/+ 2 2) |
Forma wywołania funkcji Forma listy argumentów |
Semantyczny |
4 | Wartość zwracana przez funkcję | Danych |
Poszczególne poziomy odpowiadają kolejnym etapom przetwarzania kodu źródłowego:
Na poziomie tekstowym mamy do czynienia z sekwencją znaków reprezentującą kod źródłowy.
Na poziomie leksykalnym dochodzi do wczytania sekwencji znakowej i rozpoznania w niej jednostek leksykalnych, np. liczb, symboli, znaczników otwierających i zamykających listy itd.
Na poziomie gramatycznym mamy do czynienia parsowaniem, czyli tworzeniem w pamięci struktury reprezentującej wyrażenia z użyciem odpowiednich typów danych; np. złożone, listowe wyrażenie będzie reprezentowane listą (typ
PersistentList
), której poszczególne elementy to dane typuSymbol
(symbole) i liczby całkowite (typLong
).Poziom semantyczny związany jest z fazą obliczania wartości wyrażeń rezydujących w pamięci. Dochodzi tu do wykrywania form, np. rozpoznawania symboli wskazujących na inne obiekty, wywołań funkcji czy stałych wartości.
Na poziomie danych mamy do czynienia z rezultatami obliczeń, które powstały w trakcie wartościowania form.
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:
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:
Ł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ść:
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:
W dialektach języka Lisp tego rodzaju konstrukcje składniowe mają specjalne znaczenie. Ich pierwszy element jest identyfikatorem podprogramu, który będzie wywołany.
Wyrażenia symboliczne mogą być zagnieżdżane:
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
podprogramu, który – jak możemy się domyślać – zdefiniowano w narzędziu Leiningen
w postaci funkcji lub 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.
W fazie wczytywania kodu źródłowego do pamięci defproject
będzie wewnętrznie
reprezentowany typem danych Symbol
(a dokładniej clojure.lang.Symbol
). Na tym
jednak nie koniec, bo zaraz potem (podczas etapu wartościowania wyrażeń) zmieni się
w wartość, którą do niego wcześniej przypisano (np. w liczbę, napis, obiekt funkcji
bądź makra itp.). Użyty w ten sposób będzie formą symbolową, która pomaga nam
korzystać ze zrozumiałych etykiet w odniesieniu do danych.
To, że w programach możemy korzystać z symbolicznych nazw, zawdzięczamy specjalnemu traktowaniu symboli przez kompilator. Gdy tylko w kodzie wystąpi forma symbolowa, mechanizmy języka będą próbowały uzyskać powiązaną z nią wartość, aby następnie podstawić ją w miejsce symbolu i dalej wartościować wyrażenia.
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 służą do modyfikowania kodu źródłowego programu przez sam program.
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.
Funkcje przyjmują zestawy stałych wartości i po dokonaniu na nich obliczeń zwracają wartość lub wielowartościową strukturę. Jeżeli w miejscu argumentu funkcji pojawi się symbol, będzie potraktowany jak forma symbolowa i poddany procesowi rozpoznawania wartości, z którą został powiązany. Dopiero ta wartość trafi do wywołania 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. Oczywiście nic nie stoi na przeszkodzie,
aby wewnątrz makra samodzielnie rozpoznać wartość symbolu lub dokonać wartościowania
innego rodzaju wyrażenia.
Wartościami zwracanymi przez makra są S-wyrażenia, które pojawią się w kodzie w miejscach wywołania makr i będą dalej wartościowane. Tym sposobem program może modyfikować własny kod.
Rozważmy dwa wywołania – funkcji funkcja
i makra makro
:
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 jest on sam, ponadto dwa klucze o takiej samej nazwie
reprezentowane są tylko jednym obiektem w pamięci.
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:
W naszym pliku opisującym projekt wektor ma tylko jeden element, którym jest inny wektor:
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.9.0.
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 w Clojure obiektami typu PersistentMap
lub
PersistentArrayMap
(dla krótszych kolekcji). Pozwala ona przyporządkowywać
do siebie w parach obiekty 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:
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:
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ą:
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 przypadku języka Clojure przestrzenie nazw to wewnętrznie mapy, w których możemy
umieszczać odwołania do danych dowolnego typu (m.in. pojedynczych wartości, kolekcji
czy funkcji). Kluczami tych map są symbole, a wartościami zmienne globalne (typ
clojure.lang.Var
) lub klasy Javy.
W naszym programie makro ns
tworzy przestrzeń nazw zakupy.core
i ustawia
specjalną zmienną dynamiczną *ns*
tak, aby w obrębie kodu źródłowego umieszczonego
w pliku core.clj
domyślną przestrzenią była właśnie ta wskazana. Dzięki temu
podczas rozpoznawania wartości każdej formy symbolowej umieszczonej w pliku będzie
przeszukiwana ustawiona przestrzeń nazw. Gdybyśmy w pliku core.clj
umieścili na
przykład symbol x
, to podczas jego wartościowania będzie przeszukana przestrzeń
zakupy.core
, aby w niej odnaleźć skojarzony z symbolem obiekt.
Do przestrzeni nazw można importować pary pochodzące z innych przestrzeni. Tak dzieje
się w przypadku wbudowanych w Clojure standardowych funkcji, makr i konstrukcji
specjalnych. Każda nowo tworzona przestrzeń zawiera potrzebne wpisy, chyba
że programista wyraźnie wskaże, że tego nie chce. W efekcie możemy odwoływać się
w programach do popularnych funkcji bez określania przestrzeni, z której pochodzą,
a więc na przykład użyć zapisu (+ 2 2)
zamiast (clojure.core/+ 2 2)
. Ta druga
postać symbolu nazywana jest symbolem z dookreśloną przestrzenią nazw.
Zapis :gen-class
z linii nr 2 jest jedną z dyrektyw sterujących zachowaniem makra
ns
i oznacza, że podczas kompilacji programu do kodu bajtowego dla tej przestrzeni
nazw wygenerowana zostanie klasa Javy, a funkcje z tej przestrzeni, których nazwy
rozpoczynają się znakiem -
będą reprezentowane publicznymi metodami. Dzięki temu
będzie można uruchamiać funkcje Clojure z poziomu maszyny wirtualnej Javy.
Funkcja główna
W linii nr 4 widzimy kolejne S-wyrażenie:
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.
- Umieszczenie w bieżącej przestrzeni nazw symbolu powiązanego z obiektem funkcji.
Pierwszy argument defn
to nazwa funkcji, która tutaj określona jest symbolem
-main
. Rozpoczynający znak łącznik-minus pomaga w automatycznym udostępnieniu
funkcji w postaci metody Javy.
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:
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:
Na ekranie powinniśmy ujrzeć:
Witaj, Lispie!
Moje argumenty:
nil
Linia wyświetlająca przekazane wartości argumentów zawiera napis nil
. Jest to
specjalny obiekt, która oznacza wartość nieustaloną. Pojawił się, ponieważ nasza
funkcja nie otrzymała ani jednego argumentu. Spróbujmy raz jeszcze, ale tym razem
przekażmy do polecenia jakieś parametry:
Tym razem zobaczymy:
Witaj, Lispie!
Moje argumenty:
(raz dwa trzy)
Nawiasy oznaczają, że wewnętrznie lista argumentów reprezentowana jest listową
strukturą danych – w ten sposób funkcja println
prezentuje tego rodzaju
kolekcje. 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:
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:
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.
Warto pamiętać, że tak spakowany program zawiera wszystkie pakiety zależne, włączając w to interpreter języka Clojure w wersji, której użyliśmy, a także kod źródłowy programu. Clojure jest językiem wysoce dynamicznym i zaleca się dystrybuować programy w postaci źródłowej, aby maszyna wirtualna mogła korzystać z kompilacji JIT dokonywanej w czasie uruchamiania. Możliwe jest kompilowanie AOT, jednak wiąże się to z utratą pewnych dynamicznych właściwości.
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.9.0
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ą:
W pierwszej linii umieściliśmy tylko symbol -main
, który został rozpoznany jako
forma symbolowa, której wartością jest funkcyjny obiekt. To właśnie jego tekstową
reprezentację możemy zaobserwować w wyjściu REPL z linii nr 2.
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.
Najłatwiejszym i najszybszym sposobem jest posłużenie się wbudowaną funkcją use
,
która powoduje załadowanie kodu wskazanych bibliotek i skopiowanie odwzorowań
obecnych w ich przestrzeniach nazw w przestrzeni bieżącej. Pierwszym argumentem
wywołania powinien być literalnie wyrażony symbol z nazwą ładowanej biblioteki. Gdy
wartością kolejnego argumentu jest słowo kluczowe :reload
, to operacja zostanie
ponowiona, nawet gdy już wcześniej załadowano kod.
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:
Gdy teraz w REPL wywołamy funkcję, nie zobaczymy jeszcze zmian:
Spróbujmy jeszcze raz, lecz teraz poprzedźmy wywołanie naszej funkcji konstrukcją
use
:
Udało się! Funkcja use
spowodowała, że został ponownie wczytany plik
src/zakupy/core.clj
, a symbol -main
w przestrzeni nazw zakupy.core
powiązano
z kodem nowego wydania naszej funkcji.
Skąd podprogram funkcji use
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, czyli
forma stała. Jest to konieczne, ponieważ use
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ę:
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 odpowiadający za odczyt i parsowanie kodu źródłowego
- Ewaluator
- komponent odpowiedzialny za wartościowanie wyrażeń zapisanych w pamięci przez czytnik
- Forma
- wyrażenie, którego wartość może obliczyć ewaluator
- 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
- Globalna mapa o ustalonej nazwie zawierająca odwzorowania symbolów na obiekty
- 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, a sam wskazuje na dowolną wartość w pamięci; wykorzystywany do nazywania funkcji, ustawień programu i innych globalnych tożsamości, których stany nieczęsto ulegają zmianom - 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)
zwraca kod źródłowy podprogramu (np. funkcji) o podanym identyfikatorze;(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.