stats

Poczytaj mi Clojure, cz. 2

Pierwsze kroki

Grafika

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:

SH
1
2
3
if [ -d "$HOME/bin" ] ; then
    PATH="$HOME/bin:$PATH"
fi
if [ -d "$HOME/bin" ] ; then PATH="$HOME/bin:$PATH" fi

Potem możemy już pobrać i zainstalować stabilne wydanie narzędzia:

SH
1
2
3
4
5
[ ! -d "$HOME/bin" ] && mkdir "$HOME/bin"

wget -O ~/bin/lein \
  https://raw.github.com/technomancy/leiningen/stable/bin/lein && \
  chmod 755 ~/bin/lein && ~/bin/lein upgrade
[ ! -d "$HOME/bin" ] && mkdir "$HOME/bin" wget -O ~/bin/lein \ https://raw.github.com/technomancy/leiningen/stable/bin/lein && \ chmod 755 ~/bin/lein && ~/bin/lein upgrade

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:

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

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.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 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 – 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:

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

  4. Czytnik rozpoznał następujące rodzaje leksemów (tzw. tokeny):

    • literał liczbowy (123),
    • komentarz (; i wszystkie znaki do końca linii).
  5. W procesie parsowania przez czytnik:

    • literał 123 został rozpoznany jako tzw. wyrażenie symboliczne,
    • komentarz został zignorowany.
  6. 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.

  7. Kontrolę nad dalszym uruchamianiem przejął ewaluator (komponent odpowiedzialny za analizę znaczeniową programu).

  8. Ewaluator zaczął wartościować obiekty umieszczone w pamięci przez czytnik.

  9. Reprezentowane typem Long wyrażenie 123 zostało rozpoznane jako tzw. forma stała, czyli obiekt, który przedstawia wartość własną.

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

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

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 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 typu Symbol (symbole) i liczby całkowite (typ Long).

  • 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

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
1
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
2
3
4
5
6
7
8
9
(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.8.0"]]
  :main ^:skip-aot zakupy.core
  :target-path "target/%s"
  :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.8.0"]] :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
2
3
4
5
6
7
8
9
(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.9.0"]]
  :main ^:skip-aot zakupy.core
  :target-path "target/%s"
  :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.9.0"]] :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:

1
(+ 4 6 8)
(+ 4 6 8)

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:

1
(+ 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 22. 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

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.

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ń

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ń: funkcjemakra.

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:

(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ł, napł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, napł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 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

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:

1
[2 4 6 8]
[2 4 6 8]

W naszym pliku opisującym projekt wektor ma tylko jeden element, którym jest inny wektor:

1
[[org.clojure/clojure "1.9.0"]]
[[org.clojure/clojure "1.9.0"]]

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

Mapa

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:

1
{: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:

1
2
{: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
2
3
4
5
6
7
(ns zakupy.core
  (:gen-class))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (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 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:

src/zakupy/core.clj
1
2
3
4
(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (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. 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: &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
2
3
4
(defn -main
  "Wyświetla powitanie"
  [& args]
  (println "Witaj, Lispie!"))
(defn -main "Wyświetla powitanie" [& args] (println "Witaj, Lispie!"))

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
1
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. 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:

SH
1
lein run raz dwa trzy
lein run raz dwa trzy

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:

SH
1
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
1
2
cd target/uberjar
java -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.

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

REPL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-main
; => #<Fn@76c99f2f zakupy.core/_main>

(-main)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> nil
; => nil

(-main 1 2 3)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> (1 2 3)
; => nil
-main ; =&gt; #&lt;Fn@76c99f2f zakupy.core/_main&gt; (-main) ; &gt;&gt; Witaj, Lispie! ; &gt;&gt; Moje argumenty: ; &gt;&gt; nil ; =&gt; nil (-main 1 2 3) ; &gt;&gt; Witaj, Lispie! ; &gt;&gt; Moje argumenty: ; &gt;&gt; (1 2 3) ; =&gt; nil

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:

src/zakupy/core.clj
1
2
3
4
(defn -main
  "Wyświetla powitanie"
  [& args]
  (println "Witaj, Lispie!"))
(defn -main &#34;Wyświetla powitanie&#34; [&amp; args] (println &#34;Witaj, Lispie!&#34;))

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

REPL
(-main)
; >> Witaj, Lispie!
; >> Moje argumenty:
; >> nil
; => nil
(-main) ; &gt;&gt; Witaj, Lispie! ; &gt;&gt; Moje argumenty: ; &gt;&gt; nil ; =&gt; nil

Spróbujmy jeszcze raz, lecz teraz poprzedźmy wywołanie naszej funkcji konstrukcją use:

REPL
(use 'zakupy.core :reload)
; => nil

(-main)
; >> Witaj, Lispie!
; => nil
(use &#39;zakupy.core :reload) ; =&gt; nil (-main) ; &gt;&gt; Witaj, Lispie! ; =&gt; nil

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

REPL
(doc -main)
; >> -------------------------
; >> zakupy.core/-main
; >> ([& args])
; >>   Wyświetla powitanie
; => nil
(doc -main) ; &gt;&gt; ------------------------- ; &gt;&gt; zakupy.core/-main ; &gt;&gt; ([&amp; args]) ; &gt;&gt; Wyświetla powitanie ; =&gt; 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 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 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)
    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.

REPL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
;; 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)
;; wartość nieustalona nil ; =&gt; nil ;; zwiększanie o jeden (inc 3) ; =&gt; 4 ;; zagnieżdżone operacje (+ 1 2 3 (inc 1)) ; =&gt; 8 ;; powiązania leksykalne ;; symboli z wartościami (let [a 5 b 1] (- a b)) ; =&gt; 4 ;; powiązanie globalne ;; symbolu z funkcją (def hejka (fn [] &#34;hej!&#34;)) ; =&gt; #&#39;user/hejka ;; wywołanie funkcji hejka (hejka) ; =&gt; &#34;hej!&#34; ;; definiowanie funkcji nazwanej ;; z użyciem makra defn (defn siemka [] &#34;siema!&#34;) ; =&gt; #&#39;user/siemka ;; wywołanie funkcji siemka (siemka) ; =&gt; &#34;siema!&#34; ;; definiowanie funkcji ;; z domknięciem symboli ;; powiązanych leksykalnie w otaczającym środowisku (def dodaj-2 (let [a 2] (fn [b] (+ a b)))) ; =&gt; #&#39;user/dodaj-2 ;; wywołanie funkcji dodaj-2 (dodaj-2 6) ; =&gt; 8 ;; definiowanie funkcji ;; z domknięciem symboli ;; powiązanych z argumentami wywołania funkcji otaczającej (defn generuj-sumator [x] (fn [y] (+ x y))) ; =&gt; #&#39;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)) ; =&gt; #&#39;user/dodaj-10 ;; wywołanie funkcji dodaj-10 ;; zwróconej przez generuj-sumator z argumentem 10 (dodaj-10 2) ; =&gt; 12 ;; zacytowany symbol &#39;symbol-literalny ; =&gt; symbol-literalny ;; zacytowane listowe S-wyrażenie &#39;(+ 2 2 trzy) ; =&gt; (+ 2 2 trzy)
Jesteś w sekcji .
Tematyka:

Taksonomie: