stats

Poczytaj mi Clojure, cz. 2

Pierwsze kroki

Grafika

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 clojure do uruchamiania programów,
  • polecenia clj do interaktywnej pracy,
  • pliku deps.edn do 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:

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:

SH
lein repl
lein repl

Po uruchomieniu powinniśmy ujrzeć na wyjściu rezultat podobny do zaprezentowanego poniżej:

REPL
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:

  1. Wprowadziliśmy linię tekstu zawierającą kod źródłowy.

  2. 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ć.

  3. 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.

  4. Czytnik stworzył pamięciową reprezentację kodu źródłowego, w której wyrażenie 123 reprezentowane jest daną typu java.lang.Long przechowującą wartość 123.

  5. Kontrolę nad dalszym uruchamianiem przejął ewaluator (proces odpowiedzialny za nadanie formie znaczenia).

  6. Ewaluator zaczął wartościować obiekt umieszczony w pamięci przez czytnik.

  7. Reprezentowane typem Long wyrażenie 123 zostało rozpoznane jako forma samowartościująca: rezultatem wartościowania jest sama liczba.

  8. Ewaluator przekazał rezultat przeliczania do funkcji wypisującej rezultaty obliczeń.

  9. 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

Komentarz

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

Projekt 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:

SH
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

Lista

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

Symbol

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ń

Argumenty

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

Funkcje

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

Łańcuch znakowy

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

Klucz

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

Wektor

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

Mapa

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

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
1(defn -main
2  "I don't do a whole lot ... yet."
3  [& args]
4  (println "Hello, World!"))
(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:

  1. Stworzenie funkcyjnego obiektu.
  2. 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:

SH
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:

SH
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:

SH
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:

SH
1cd target/uberjar
2java -classpath zakupy-1.0.0-standalone.jar zakupy.core
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ą:

REPL
 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
1(defn -main
2  "Wyświetla powitanie"
3  [& args]
4  (println "Witaj, Lispie!"))
(defn -main "Wyświetla powitanie" [& args] (println "Witaj, Lispie!"))

Gdy teraz w REPL wywołamy funkcję, nie zobaczymy jeszcze zmian:

REPL
(-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:

REPL
(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ę:

REPL
(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 jak doc, 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.

REPL
 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)
Jesteś w sekcji Poczytaj mi Clojure
Tematyka:

Taksonomie: