Programowanie w języku Clojure

Przewodnik po Clojure dla każdego! Clojure to jeden z najciekawszych współczesnych języków programowania funkcyjnego. Jest obecny na rynku od pięciu lat. Język jest oparty o wirtualną maszynę języka Java i zachęca użytkowników do programowania współbieżnego. W ostatnim czasie Clojure gwałtownie zdobywa popularność i uznanie wśród programistów. Dzieje się tak, ponieważ jest dobrze przemyślany i wspaniale zaprojektowany, kryje w sobie potencjał języka Lisp, a do tego jest szybki i działa na dobrze znanej wszystkim wirtualnej maszynie. Ta książka to kompletny przewodnik po Clojure. Lekturę zaczniesz od poznania jego zalet, składni i zasad programowania. W momencie, kiedy zbudujesz solidne fundamenty, przejdziesz do nauki programowania funkcyjnego, które wymaga pewnej zmiany w sposobie myślenia. Ale nie martw się, z tą książką przyjdzie Ci to z łatwością! W kolejnych rozdziałach skupisz się na programowaniu współbieżnym, protokołach, typach danych i makrach. Zobaczysz również, jak za pomocą Clojure stworzyć klasę języka Java, oraz zaznajomisz się z procesem tworzenia kompletnej aplikacji korzystającej z Clojure. Jeżeli chcesz poszerzyć swoje horyzonty programistyczne, trafiłeś na idealną książkę. Ta inwestycja się opłaci! Poznaj zalety Clojure: potencjał języka Lisp programowanie funkcyjne i współbieżne uporządkowaną i przemyślaną architekturę zasięg wirtualnej maszyny Java morze nowych możliwości! Zmień myślenie na funkcyjne!

112 downloads 5K Views 2MB Size

Recommend Stories

Empty story

Idea Transcript


Tytuł oryginału: Programming Clojure Tłumaczenie: Tomasz Walczak ISBN: 978-83-246-5376-8 © Helion 2013. All rights reserved. Copyright © 2012 The Pragmatic Programmers, LLC. All rights reserved. No part of this publication may be reproduced, stored in retrieval system, or transmitted, in any form, or by any means, electronic, mechanical, photocopying, recording, or otherwise, without the prior consent of the poublisher. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Wydawnictwo HELION dołożyło wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie bierze jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Wydawnictwo HELION nie ponosi również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Pliki z przykładami omawianymi w książce można znaleźć pod adresem: ftp://ftp.helion.pl/przyklady/proclo.zip Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/proclo_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Printed in Poland. • Poleć książkę na Facebook.com

• Księgarnia internetowa

• Kup w wersji papierowej

• Lubię to! » Nasza społeczność

• Oceń książkę

Opinie czytelników na temat książki Programowanie w języku Clojure Clojure to jeden z najciekawszych współczesnych języków programowania, a najlepsza książka do jego nauki stała się jeszcze bardziej wartościowa. W drugim wydaniu podręcznika Programowanie w języku Clojure dodano aktualne informacje, wiele praktycznych przykładów i mnóstwo przydatnych porad dotyczących tego, jak uczyć się języka Clojure i jak z powodzeniem go stosować.  Ola Bini Twórca języka Ioke, programista, pracownik firmy ThoughtWorks Czujesz się onieśmielony językiem Clojure? Przestaniesz po przeczytaniu tej książki. Jest ona napisana prostym i ciekawym stylem. Dzięki niej nauczysz się języka Clojure krok po kroku z bardzo przystępnych rozdziałów.  Tim Berglund Założyciel i dyrektor firmy August Technology Group Autorzy opracowali najwygodniejszą do tej pory pomoc w opanowaniu języka Clojure. Jest nią ta dobrze zaplanowana i łatwa w lekturze książka. Autorzy mają talent do wymyślania prostych i trafnych przykładów pokazujących, jak połączyć wyjątkowe cechy języka Clojure.  Chris Houser Jeden z głównych autorów kodu i bibliotek języka Clojure Clojure to piękny, elegancki i dający bardzo duże możliwości język, który działa w maszynie JVM. Przypomina katedrę — możesz poznawać go samemu, ale lepiej robić to wraz z przewodnikiem, który pomoże Ci dostrzec i docenić architekturę oraz kunszt twórców. Ta książka pozwala Ci cieszyć się towarzystwem nie jednego, ale dwóch doświadczonych programistów, mających bogatą wiedzę i perspektywę pomocną do nauczania innych.  Dr Venkat Subramaniam Nagradzany autor książek i założyciel firmy Agile Developer Inc.

Ku pamięci mojego kochanego ojca i mentora, Craiga Bedry, który nauczył mnie, jak cenne jest uczenie się przez eksplorację, oraz tego, że magia nie istnieje. — Aaron

Spis treści

Podziękowania ............................................................................................10 Przedmowa do wydania drugiego .............................................................11 Przedmowa do wydania pierwszego ........................................................13 Wstęp ...........................................................................................................15 Rozdział 1. Wprowadzenie .........................................................................23 1.1. Dlaczego Clojure? .............................................................................................24 1.2. Szybkie wprowadzenie do programowania w Clojure ...........................................34 1.3. Biblioteki języka Clojure .....................................................................................40 1.4. Podsumowanie ..................................................................................................44 Rozdział 2. Przegląd języka Clojure ..........................................................45 2.1. Konstrukcje składniowe ......................................................................................46 2.2. Makra odczytu ...................................................................................................55 2.3. Funkcje .............................................................................................................56 2.4. Zmienne, wiązania i przestrzenie nazw ...............................................................61 2.5. Wywoływanie kodu Javy .....................................................................................68 2.6. Przepływ sterowania ..........................................................................................70 2.7. Gdzie się podziała pętla for? ..............................................................................74 2.8. Metadane ..........................................................................................................77 2.9. Podsumowanie ..................................................................................................79

8



Programowanie w języku Clojure

Rozdział 3. Ujednolicanie danych za pomocą sekwencji .......................81 3.1. Wszystko jest sekwencją .....................................................................................83 3.2. Stosowanie biblioteki sekwencji ...........................................................................87 3.3. Sekwencje nieskończone i „leniwe” .....................................................................96 3.4. W Clojure Java jest sekwencyjna .........................................................................98 3.5. Funkcje przeznaczone dla konkretnych struktur ..................................................104 3.6. Podsumowanie ................................................................................................113 Rozdział 4. Programowanie funkcyjne ...................................................115 4.1. Zagadnienia z obszaru programowania funkcyjnego ...........................................116 4.2. Jak stosować „leniwe” podejście? ......................................................................121 4.3. Leniwsze niż leniwe .........................................................................................130 4.4. Jeszcze o rekurencji ..........................................................................................136 4.5. Podsumowanie ................................................................................................146 Rozdział 5. Stan .........................................................................................147 5.1. Współbieżność, równoległość i blokady .............................................................148 5.2. Referencje i pamięć STM ................................................................................150 5.3. Nieskoordynowane i synchroniczne aktualizacje za pomocą atomów ....................157 5.4. Stosowanie agentów do asynchronicznego aktualizowania danych .......................158 5.5. Zarządzanie stanem specyficznym dla wątku za pomocą zmiennych ....................163 5.6. Gra Snake w języku Clojure .............................................................................168 5.7. Podsumowanie ................................................................................................178 Rozdział 6. Protokoły i typy danych .......................................................179 6.1. Programowanie z wykorzystaniem abstrakcji ......................................................180 6.2. Interfejsy .........................................................................................................183 6.3. Protokoły ........................................................................................................184 6.4. Typy danych ...................................................................................................188 6.5. Rekordy ..........................................................................................................193 6.6. Makro reify .....................................................................................................198 6.7. Podsumowanie ................................................................................................199

Spis treści



9

Rozdział 7. Makra .....................................................................................201 7.1. Kiedy należy stosować makra? ..........................................................................202 7.2. Makro do sterowania przebiegiem programu ......................................................202 7.3. Upraszczanie makr ..........................................................................................209 7.4. Taksonomia makr ............................................................................................214 7.5. Podsumowanie ................................................................................................224 Rozdział 8. Wielometody ..........................................................................225 8.1. Życie bez wielometod ......................................................................................226 8.2. Definiowanie wielometod .................................................................................228 8.3. Więcej niż proste wybieranie metod ...................................................................231 8.4. Tworzenie doraźnych taksonomii ......................................................................233 8.5. Kiedy należy korzystać z wielometod? ...............................................................237 8.6. Podsumowanie ................................................................................................241 Rozdział 9. Sztuczki z Javą ......................................................................243 9.1. Obsługa wyjątków ............................................................................................244 9.2. Zmagania z liczbami całkowitymi .....................................................................248 9.3. Optymalizowanie wydajności ............................................................................250 9.4. Tworzenie klas Javy w języku Clojure ................................................................255 9.5. Praktyczny przykład .........................................................................................261 9.6. Podsumowanie ................................................................................................268 Rozdział 10. Tworzenie aplikacji ............................................................269 10.1. Wynik w grze Clojurebreaker ..........................................................................270 10.2. Testowanie kodu zwracającego wynik ..............................................................274 10.3. Biblioteka test.generative ................................................................................278 10.4. Tworzenie interfejsu .......................................................................................287 10.5. Instalowanie kodu ..........................................................................................292 10.6. Pożegnanie ....................................................................................................295 Dodatek A. Edytory kodu .........................................................................297 Dodatek B. Bibliografia ............................................................................299 Skorowidz ..................................................................................................301

10

Programowanie w języku Clojure



Podziękowania

W

iele osób przyczyniło się do tego, że ta książka jest tak dobra. Za problemy i błędy odpowiadamy tylko my sami.

Dziękujemy świetnemu zespołowi z jednostki Clojure/core firmy Relevance za stworzenie atmosfery, w której mogą powstawać i rozkwitać dobre pomysły. Przemiłym ludziom z listy mailingowej1 dotyczącej języka Clojure jesteśmy wdzięczni za pomoc i zachęty. Podziękowania należą się też wszystkim osobom z wydawnictwa Pragmatic Bookshelf. Dziękujemy zwłaszcza redaktorowi Michaelowi Swaine’owi za błyskawicznie dostarczane cenne wskazówki. Jesteśmy wdzięczni Dave’owi Thomasowi i Andy’emu Huntowi za zbudowanie ciekawej platformy do pisania książek technicznych i za wiarę w pasję ich autorów. Dziękujemy wszystkim osobom, które zamieszczały sugestie na stronie z erratą do książki2. Jesteśmy wdzięczni recenzentom technicznym za ich komentarze i pomocne sugestie. Oto oni: Kevin Beam, Ola Bini, Sean Corfield, Fred Daoud, Steven Huwig, Tibor Simic, David Sletten, Venkat Subramaniam i Stefan Turalski. Specjalne podziękowania należą się Davidowi Liebke, który napisał rozdział 6., „Protokoły i typy danych”. David przygotował fantastyczny przewodnik po nowych pomysłach. Bez jego wkładu książka ta nie byłaby tym, czym jest. Dziękujemy Richowi Hickeyowi za opracowanie znakomitego języka Clojure i zbudowanie wokół niego społeczności. Podziękowania należą się także mojej żonie, Joey, i córkom, Hattie, Harper i Mable Faire. Dla Was warto żyć. — Stuart Dziękuję mojej żonie, Erin, za nieustającą miłość i wsparcie. — Aaron

1

http://groups.google.com/group/clojure

2

http://www.pragprog.com/titles/shcloj2/errata

Przedmowa do wydania drugiego

W

iele się zmieniło od czasu pojawienia się pierwszego wydania tej książki. Do języka dodano pewne nowości, na przykład obsługę protokołów i rekordów. Najważniejsze jest jednak to, że język Clojure zaczęto stosować w różnych dziedzinach. Użytkownicy tworzą za jego pomocą nowe serwisy, analizują duże zbiory danych, a także obsługują komunikację oraz operacje finansowe, sieciowe i bazodanowe. Wokół języka Clojure powstała duża i pomocna społeczność, a wraz z nią wiele bibliotek. Są one interesujące nie tylko ze względu na udostępniane w nich mechanizmy. Najlepsze jest to, że w bibliotekach uwzględniono podejście i mechanizmy charakterystyczne dla języka Clojure, co pozwoliło osiągnąć nowy poziom prostoty i współdziałania kodu. W drugim wydaniu Stuart i Aaron omawiają nowe elementy języka i pokazują, jak korzystać z wybranych bibliotek rozwijanych przez społeczność. Przedstawiają przy tym mechanizmy pozwalające łączyć różne elementy. Książka ta jest fantastycznym wprowadzeniem do języka Clojure. Mam nadzieję, że zainspiruje Cię do dołączenia do społeczności skupionej wokół Clojure i że w przyszłości sam utworzysz jakąś bibliotekę. — Rich Hickey Twórca języka Clojure

12



Programowanie w języku Clojure

Przedmowa do wydania pierwszego

O

taczający nas świat jest coraz bardziej skomplikowany. W dużym stopniu wynika to z przypadku i jest efektem sposobu rozwiązywania problemów, a nie ich samych. Programowanie obiektowe wydaje się proste, jednak aplikacje powstające w tym paradygmacie często obejmują złożone sieci wzajemnie powiązanych modyfikowalnych obiektów. Wywołanie jednej metody może prowadzić do serii zmian w powiązanych ze sobą obiektach. Bardzo trudno jest zrozumieć, co się stało, dlaczego aplikacja znalazła się w danym stanie i jak przywrócić wcześniejszy stan w celu naprawienia błędu. Jeśli dodamy do tego współbieżność, sytuacja szybko może stać się beznadziejna. Programiści stosują makiety obiektów i pakiety testów, jednak zbyt rzadko zastanawiają się nad narzędziami oraz modelem programowania. Alternatywą dla modelu obiektowego jest programowanie funkcyjne. Z powodu nacisku na pisanie czystych funkcji, które przyjmują i zwracają niezmienne wartości, efekty uboczne są raczej wyjątkiem niż normą. Jest to ważne zwłaszcza w obliczu rosnącej współbieżności w architekturach wielordzeniowych. Język Clojure ma zapewnić twórcom oprogramowania komercyjnego wygodę stosowania programowania funkcyjnego w praktyce. Działa w ramach sprawdzonej architektury, maszyny JVM, i pozwala wykorzystać inwestycje poczynione we frameworki i biblioteki Javy. Jest to bardzo praktyczne podejście.

14



Programowanie w języku Clojure

Ekscytującym aspektem książki Stuarta jest to, w jakim stopniu autor rozumie język Clojure. W końcu język ten jest przeznaczony dla zawodowych programistów, takich jak Stuart. Autor wystarczająco często spotykał się z problemami, których rozwiązaniem ma być Clojure, by doceniać pragmatyczny charakter tego języka. Niniejsza książka jest entuzjastycznym przeglądem najważniejszych cech języka Clojure. Znajdziesz tu omówienie jego praktycznych zastosowań, a także przystępne wprowadzenie w nowe zagadnienia. Mam nadzieję, że zainspiruje Cię to do pisania w języku Clojure oprogramowania, o którym można powiedzieć, że nie tylko wykonuje stawiane przed nim zadania, ale też robi to w niezawodny i prosty sposób (a ponadto pisanie kodu jest przyjemnością). — Rich Hickey Twórca języka Clojure

Wstęp

C 

 



 

lojure to dynamiczny język programowania, który działa w maszynie JVM (ang. Java Virtual Machine) i posiada bardzo atrakcyjny zestaw cech. Oto one:

Clojure jest elegancki. Przejrzysty, staranny projekt języka umożliwia skupienie się na istocie problemu w trakcie pisania programów. Można tworzyć je w sposób uporządkowany i bezproblemowy. Clojure to usprawniony Lisp. Clojure ma zalety Lispa, ale nie występują tu ograniczenia charakterystyczne dla starszego z tych języków. Clojure jest językiem funkcyjnym. Struktury danych są niezmienne, a większość funkcji nie ma efektów ubocznych. Dlatego pisanie poprawnych programów jest łatwiejsze, podobnie jak tworzenie większych aplikacji na podstawie mniejszych. Clojure ułatwia pisanie programów współbieżnych. W wielu językach współbieżność jest oparta na blokadach, które trudno jest stosować prawidłowo. Clojure udostępnia kilka innych możliwości, takich jak programowa pamięć transakcyjna, agenty, atomy i zmienne dynamiczne. Clojure współdziała z Javą. W Javie kod języka Clojure można wywoływać bezpośrednio i szybko. Nie występuje tu warstwa pośrednia. Clojure, w odróżnieniu od wielu popularnych języków dynamicznych, działa szybko. Język Clojure napisano tak, aby można było wykorzystać optymalizacje dostępne we współczesnych maszynach JVM.

Wiele innych języków ma niektóre z cech wymienionych na powyższej liście, jednak Clojure wyróżnia się spośród nich wszystkich. Już same poszczególne cechy są ciekawe i dają dużo możliwości. Ich połączenie ma nieodparty urok. Te i inne cechy omawiamy w rozdziale 1., „Wprowadzenie”.

16



Programowanie w języku Clojure

Dla kogo przeznaczona jest ta książka? Clojure to język programowania ogólnego przeznaczenia, który daje duże możliwości. Dlatego książka ta jest przeznaczona dla doświadczonych programistów szukających nowych możliwości i eleganckich rozwiązań. Ponadto jest przydatna dla każdego użytkownika współczesnych języków programowania (takich jak C#, Java, Python lub Ruby). Clojure działa w maszynie JVM i jest szybki. Książka ta jest interesująca zwłaszcza dla programistów Javy, którzy chcą korzystać z możliwości języka dynamicznego, ale nie kosztem wydajności. Język Clojure pomaga określić, jakie cechy powinien mieć język ogólnego przeznaczenia. Jeśli programujesz w Lispie, używasz języka funkcyjnego, na przykład Haskella, albo piszesz programy współbieżne, Clojure Ci się spodoba. W języku tym połączono cechy Lispa z mechanizmami programowania funkcyjnego i programowania współbieżnego. Wszystkie te elementy są łatwe w użyciu dla programistów, którzy stykają się z nimi po raz pierwszy. Clojure jest przejawem większego zjawiska. Języki w rodzaju Erlanga, F#, Haskella i Scali zyskują ostatnio na popularności z uwagi na obsługę programowania funkcyjnego lub współbieżności. Zwolennicy tych języków odnajdą w Clojure wiele charakterystycznych dla nich mechanizmów.

Zawartość książki W rozdziale 1., „Wprowadzenie”, pokazujemy, jak eleganckim językiem ogólnego przeznaczenia jest Clojure. Omawiamy też podejście funkcyjne i model współbieżności, które sprawiają, że Clojure jest wyjątkowy. Przedstawiamy proces instalowania języka Clojure i interaktywne rozwijanie kodu w środowisku REPL (ang. read-eval-print loop, czyli pętla wczytaj-wykonaj-wyświetl wynik). Rozdział 2., „Przegląd języka Clojure”, to ogólne omówienie wszystkich podstawowych mechanizmów języka Clojure. Po zapoznaniu się z tym rozdziałem będziesz rozumiał większość standardowego kodu napisanego w Clojure. Dwa następne rozdziały dotyczą programowania funkcyjnego. W rozdziale 3., „Ujednolicanie danych za pomocą sekwencji”, pokazujemy, jak ujednolicić wszystkie dane przy użyciu szerokich możliwości modelu sekwencji. Rozdział 4., „Programowanie funkcyjne”, poświęcony jest pisaniu kodu funkcyjnego w sposób charakterystyczny dla biblioteki sekwencji.

Wstęp



17

Rozdział 5., „Stan”, dotyczy modelu współbieżności w języku Clojure. Język ten udostępnia cztery rozbudowane modele do obsługi współbieżności, a także pozwala korzystać z wszystkich dobrodziejstw bibliotek do obsługi współbieżności z Javy. W rozdziale 6., „Protokoły i typy danych”, omawiamy rekordy, typy i protokoły języka Clojure. Mechanizmy te wprowadzono w wersji 1.2.0 języka i poprawiono w wersji 1.3.0. Rozdział 7., „Makra”, poświęcony jest mechanizmowi charakterystycznemu dla Lispa. W obsłudze makr wykorzystano to, że kod w języku Clojure to dane. Pozwala to udostępnić w zakresie metaprogramowania możliwości, których odtworzenie jest bardzo trudne lub wprost niemożliwe w językach innych niż Lisp. W rozdziale 8., „Wielometody”, przedstawiamy jedną z odpowiedzi twórców języka Clojure na polimorfizm. Polimorfizm zwykle funkcjonuje na zasadzie „ustal klasę pierwszego argumentu i na tej podstawie wywołaj metody”. Wielometody umożliwiają wybranie dowolnej funkcji dla wszystkich argumentów i wywoływanie metod na tej podstawie. W rozdziale 9., „Sztuczki z Javą”, pokazujemy, jak wywoływać kod Javy w języku Clojure i na odwrót. Zobaczysz, jak pisać niskopoziomowy kod i uzyskać wydajność na poziomie Javy. W ostatnim, 10. rozdziale, „Tworzenie aplikacji”, omawiamy kompletny proces korzystania z języka Clojure. Wyjaśniamy, jak utworzyć aplikację od podstaw, rozwiązując różne aspekty problemu i uwzględniając przy tym prostotę i jakość. Wykorzystujemy tu zestaw pomocnych bibliotek języka Clojure do napisania i zainstalowania aplikacji sieciowej. W dodatku A, „Edytory kodu”, omawiamy mechanizmy wspomagające pisanie kodu w języku Clojure. Dostępne są tu odnośniki do instrukcji dotyczących instalacji takich mechanizmów.

Jak czytać tę książkę? Wszyscy Czytelnicy powinni zacząć od przeczytania dwóch pierwszych rozdziałów. Warto uważnie przeczytać zwłaszcza podrozdział 1.1, „Dlaczego Clojure?”, gdzie opisujemy zalety języka Clojure. Ciągle eksperymentuj. Clojure udostępnia interaktywne środowisko, w którym można natychmiast uzyskać informacje zwrotne. Więcej informacji znajdziesz w punkcie „Stosowanie środowiska REPL”.

18



Programowanie w języku Clojure

Po zapoznaniu się z dwoma pierwszymi rozdziałami możesz przejść do dowolnego fragmentu książki. Jednak przed lekturą rozdziału 5., „Stan”, zapoznaj się z rozdziałem 3., „Ujednolicanie danych za pomocą sekwencji”. Rozdziały te pozwalają przejść od niezmiennych struktur danych języka Clojure do rozbudowanego modelu pisania poprawnych programów współbieżnych. W dalszych rozdziałach pojawiają się dłuższe fragmenty przykładowego kodu. Przy jego wpisywaniu warto korzystać z edytora dodającego wcięcia charakterystyczne dla języka Clojure. Popularne edytory omawiamy w dodatku A, „Edytory kodu”. Jeśli to możliwe, wypróbuj edytor z obsługą parowania nawiasów, na przykład Emacsa w trybie ParEdit lub wtyczkę CounterClockWise dla środowiska Eclipse. Mechanizm ten jest bardzo pomocny w trakcie nauki programowania w języku Clojure.

Uwagi dla użytkowników języków funkcyjnych 



Programowanie funkcyjne w języku Clojure łączy w sobie akademicką ścisłość i praktyczne aspekty wykonywania kodu we współczesnych maszynach JVM. Zapoznaj się uważnie z rozdziałem 4., „Programowanie funkcyjne”, aby zrozumieć różnice między technikami z języka Clojure a rozwiązaniami z innych języków (na przykład z Haskella). Model współbieżności w języku Clojure (rozdział 5., „Stan”) zapewnia kilka sposobów na radzenie sobie z efektami ubocznymi i stanem. Dzięki temu programowanie funkcyjne jest atrakcyjne dla większej grupy użytkowników.

Uwagi dla użytkowników języków Java i C# 



Uważnie przeczytaj rozdział 2., „Przegląd języka Clojure”. Składnia języka Clojure jest bardzo uboga (w porównaniu do składni języków Java lub C#), dlatego jej omówienie jest krótkie. Zwróć baczną uwagę na makra (rozdział 7., „Makra”). Dla użytkowników języków Java i C# makra są jednym z najbardziej tajemniczych mechanizmów.

Wstęp



19

Uwagi dla użytkowników języka Lisp 

 

Część rozdziału 2., „Przegląd języka Clojure”, będzie dla użytkowników Lispa tylko powtórzeniem, jednak i tak warto ją przeczytać. W języku Clojure zachowano najważniejsze cechy Lispa, jednak w kilku aspektach zerwano z „tradycją”, co omawiamy we wspomnianym rozdziale. Zwróć uwagę zwłaszcza na „leniwe” sekwencje, opisane w rozdziale 4., „Programowanie funkcyjne”. Przed pisaniem przykładowego kodu z dalszych rozdziałów przełącz Emacsa w tryb, w którym wygodnie pracuje Ci się z kodem w języku Clojure.

Uwagi dla użytkowników języków Perl, Python i Ruby  

Dokładnie przeczytaj rozdział 5., „Stan”. Współbieżność wewnątrzprocesowa jest w języku Clojure bardzo ważna. Stosuj makra (rozdział 7., „Makra”), nie oczekuj jednak, że techniki metaprogramowania z danego języka uda się łatwo przekształcić na makra. Pamiętaj, że makra są uruchamiane w momencie ich wczytywania, a nie w czasie wykonywania programu.

Konwencje związane z zapisem W książce stosujemy wymienione dalej konwencje związane z zapisem. Fragmenty kodu zapisujemy w następujący sposób: (+ 2 2)

Efekt wykonania przykładowego kodu jest poprzedzany sekwencją ->. (+ 2 2) -> 4

Jeśli dane wyjściowe z konsoli trudno jest odróżnić od kodu i wyników, poprzedzamy je symbolem potoku (|). (println "witaj") | witaj -> nil

20

Programowanie w języku Clojure



Przy przedstawianiu po raz pierwszy struktury języka Clojure posługujemy się następującą gramatyką: (przyklad-fn (przyklad-fn (przyklad-fn (przyklad-fn (przyklad-fn

wymagany-arg) opcjonalny-arg?) zero-lub-wiecej-arg*) jeden-lub-więcej-arg+) & kolekcja-zmiennych-arg)

Gramatyka ta jest nieformalna. Symbole ?, *, + i & informują o różnych sposobach przekazywania argumentów. Kod w języku Clojure jest umieszczany w bibliotekach. Jeśli przykładowy kod wymaga biblioteki, która nie należy do rdzenia języka Clojure, informujemy o zależności za pomocą słów use lub require: (use '[nazwa-bib :only (nazwy+)]) (require '[nazwa-bib :as alias])

Użycie słowa use powoduje dodanie tylko elementów z listy nazwy, natomiast słowo require pozwala utworzyć alias, dzięki któremu wiadomo, skąd pochodzi każda funkcja. Jedną z często stosowanych funkcji jest file z biblioteki clojure.java.io: (use '[clojure.java.io :only (file)]) (file "hello.txt") -> #

Można ją dodać także za pomocą słowa require: (require '[clojure.java.io :as io]) (io/file "hello.txt") -> #

Po udanym wywołaniu use Clojure zwraca wartość pomijamy ją w listingach z przykładowym kodem.

nil.

Z uwagi na zwięzłość

W trakcie lektury książki będziesz wprowadzał kod w interaktywnym środowisku REPL. Wiersz zachęty w takim środowisku wygląda tak: użytkownik=>

Słowo użytkownik określa bieżącą przestrzeń nazw. W większości przykładów z tej książki bieżąca przestrzeń nazw nie ma znaczenia. W takich sytuacjach interakcję ze środowiskiem REPL przedstawiamy w następujący sposób: (+ 2 2) -> 4

; Wprowadzany wiersz (bez przestrzeni nazw). ; Zwracana wartość.

W nielicznych miejscach, w których bieżąca przestrzeń nazw jest istotna, używamy takiego zapisu: użytkownik=> (+ 2 2) ; Wprowadzany wiersz (z przestrzenią nazw). -> 4 ; Zwracana wartość.

Wstęp



21

Materiały w internecie i informacje zwrotne Oficjalną stroną książki w internecie jest strona Programming Clojure w witrynie wydawnictwa Pragmatic Bookshelf1. Możesz tam zamówić elektroniczne lub papierowe egzemplarze oryginału książki i pobrać kod źródłowy. Ponadto możesz przekazać nam informacje zamieszczając wpis na poświęconym książce forum2.

Przykładowy kod Przykładowy kod z książki dostępny jest w dwóch miejscach: 3  Na stronie Programming Clojure znajdują się odnośniki do oficjalnej wersji kodu źródłowego, aktualizowanego pod kątem każdego wydania książki. 4  Powiązane z książką repozytorium Git jest aktualizowane na bieżąco. O ile nie piszemy inaczej, poszczególne przykłady znajdują się w katalogu examples. Polską wersję kodu znajdziesz w witrynie wydawnictwa Helion5. Listingi w tekście zaczynają się od nazwy pliku. Aby odróżnić ją od kodu, umieszczamy ją na szarym tle. Na przykład poniższy kod pochodzi z pliku src/examples/preface.clj: src/examples/preface.clj

(println "witaj")

Po pobraniu przykładowego kodu możesz przystąpić do pracy. Zaczynamy od omówienia zestawu cech, które sprawiają, że język Clojure jest wyjątkowy.

http://www.pragprog.com/titles/shcloj2/programming-clojure http://forums.pragprog.com/forums/207 3 http://www.pragprog.com/titles/shcloj2 4 http://github.com/stuarthalloway/programming-clojure 5 ftp://ftp.helion.pl/przyklady/proclo.zip 1 2

22



Programowanie w języku Clojure

Rozdział 1.

Wprowadzenie

S

zybki wzrost popularności języka Clojure wynika z wielu przyczyn. Po krótkich poszukiwaniach w sieci WWW dowiesz się, że Clojure:  jest językiem funkcyjnym;  jest Lispem na maszyny JVM;  ma specjalne mechanizmy do obsługi współbieżności.

Wszystkie te cechy są ważne, jednak żadna z nich nie odgrywa najważniejszej roli. Naszym zdaniem najistotniejszymi aspektami języka Clojure są jego prostota i możliwości. Prostota w kontekście oprogramowania jest ważna z kilku względów, tu jednak mamy na myśli pierwotne i najważniejsze znaczenie tego słowa — proste jest to, co nie jest złożone. Proste komponenty umożliwiają systemowi przeprowadzanie operacji zaplanowanych przez projektantów i nie wykonują czynności niepowiązanych z danym zadaniem. Z naszych doświadczeń wynika, że niewielka złożoność zwykle szybko przekształca się w niebezpiecznie poważną. Także słowo możliwości ma wiele znaczeń. Tu mamy na myśli zdolność do wykonywania stawianych aplikacji zadań. Aby programista miał odpowiednie możliwości, musi wykorzystać platformę, która sama je posiada i jest powszechnie dostępna. Taką platformą jest na przykład maszyna JVM. Ponadto używane narzędzia muszą zapewniać pełny, nieograniczony dostęp do oferowanych możliwości. Dostęp do możliwości jest często podstawowym wymogiem w projektach, w których trzeba w pełni wykorzystać platformę.

24



Programowanie w języku Clojure

Przez lata tolerowaliśmy bardzo skomplikowane narzędzia, które były jedynym sposobem na uzyskanie potrzebnych możliwości. Czasem akceptowaliśmy też ograniczone możliwości w celu uproszczenia modelu programowania. Niekiedy nie da się uniknąć pewnych kompromisów, jednak w obszarze możliwości i prostoty nie trzeba się z nimi godzić. Język Clojure to dowód na to, że cechy te można połączyć.

1.1. Dlaczego Clojure? Wszystkie charakterystyczne cechy języka Clojure mają zapewniać prostotę, możliwości lub i jedno, i drugie. Oto kilka przykładów:  Programowanie funkcyjne jest proste, ponieważ pozwala oddzielić obliczenia od stanu i tożsamości. Zaletą jest to, że programy funkcyjne są łatwiejsze do zrozumienia, pisania, testowania, optymalizowania i równoległego wykonywania.  Konstrukcje umożliwiające współdziałanie języków Clojure i Java dają duże możliwości, ponieważ zapewniają bezpośredni dostęp do składni Javy. Zaletą jest to, że można uzyskać wydajność na poziomie Javy i stosować składnię charakterystyczną dla tego języka. Co ważniejsze, nie trzeba uciekać się do języka niższego poziomu, aby zapewnić sobie dodatkowe możliwości.  Lisp jest prosty w dwóch bardzo ważnych aspektach — oddziela wczytywanie od wykonania, a jego składnia obejmuje niewielką liczbę niezależnych elementów. Zaletą jest to, że wzorce projektowe są ujęte w abstrakcyjne konstrukcje składniowe, a S-wyrażenia obejmują kod w językach XML, JSON i SQL.  Lisp daje też duże możliwości, ponieważ udostępnia kompilator i system makr działający w czasie wykonywania programu. Zaletą jest to, że w Lispie występuje późne wiązanie i można łatwo tworzyć języki DSL.  Model czasu w języku Clojure jest prosty. Oddzielono w nim wartości, tożsamość, stan i czas. Zaletą jest to, że w programach można sprawdzać i zapamiętywać informacje bez obaw o to, że starsze dane zostaną nadpisane.  Protokoły są proste. W Clojure polimorfizm jest niezależny od dziedziczenia. Zaletą jest to, że można bezpiecznie i w konkretnym celu rozszerzać typy oraz abstrakcje. Nie wymaga to stosowania zawiłych wzorców projektowych lub wprowadzania zmian w cudzym kodzie (co często prowadzi do błędów).

Rozdział 1. • Wprowadzenie



25

Ta lista cech stanowi „mapę” pomocną w dalszych rozdziałach książki. Nie martw się, jeśli na razie nie rozumiesz wszystkich szczegółów. Każdej z tych cech poświęcamy cały rozdział. Zobaczmy, jak niektóre z tych cech sprawdzają się w praktyce. Zbudujmy w tym celu prostą aplikację. Przy okazji dowiesz się, jak wczytywać i wykonywać większe przykładowe programy, które prezentujemy dalej w książce.

Język Clojure jest elegancki W języku Clojure stosunek sygnału do szumu jest wysoki. Dlatego programy pisane w tym języku są krótkie. Takie programy są tańsze w tworzeniu, instalowaniu i konserwacji1. Jest to prawdą zwłaszcza wtedy, gdy programy są zwięzłe, a nie tylko treściwe. Przyjrzyj się na przykład poniższemu kodowi w Javie (pochodzi on z projektu Apache Commons): data/snippets/isBlank.java

public class StringUtils { public static boolean isBlank(String str) { int strLen; if (str == null || (strLen = str.length()) == 0) { return true; } for (int i = 0; i < strLen; i++) { if ((Character.isWhitespace(str.charAt(i)) == false)) { return false; } } return true; } }

Metoda isBlank() sprawdza, czy łańcuch znaków jest pusty (nie zawiera żadnych znaków lub obejmuje same odstępy). Oto kod podobnej metody w języku Clojure: src/examples/introduction.clj

(defn blank? [str] (every? #(Character/isWhitespace %) str))

Wersja w języku Clojure jest krótsza, a co ważniejsze — prostsza. Nie występują tu zmienne, modyfikowalny stan ani rozgałęzienia. Efekt ten jest możliwy dzięki funkcjom wyższego rzędu. Funkcje tego rodzaju przyjmują inne funkcje 1

Software Estimation: Demystifying the Black Art [McC06] to znakomita książka. Jej autorzy dowodzą, że krótsze jest tańsze.

26



Programowanie w języku Clojure

jako argumenty i (lub) zwracają funkcje. Funkcja every? przyjmuje funkcję i kolekcję, a zwraca wartość true, jeśli otrzymana funkcja zwraca true dla każdego elementu z kolekcji. Ponieważ w wersji w języku Clojure nie ma rozgałęzień, kod jest bardziej czytelny i łatwiejszy do przetestowania. Zalety te są jeszcze wyraźniejsze w większych programach. Ponadto, choć kod jest zwięzły, można go łatwo zrozumieć. Program w języku Clojure można potraktować jak definicję pustego łańcucha znaków — jest to łańcuch, w którym każdy znak jest odstępem. Kod ten jest znacznie lepszy od metody z projektu Commons, w którym definicja pustego łańcucha znaków jest ukryta za szczegółowym kodem pętli i instrukcji if. Oto inny przykład. Przyjrzyj się banalnej klasie Person napisanej w języku Java: data/snippets/Person.java

public class Person { private String firstName; private String lastName; public Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } }

W języku Clojure rekord Person można zdefiniować w jednym wierszu: (defrecord Person [first-name last-name])

Korzystać z tego rekordu można tak: (def foo (->Person "Aaron" "Bedra")) -> #'user/foo foo -> #:user.Person{:first-name "Aaron", :last-name "Bedra"}

Rozdział 1. • Wprowadzenie



27

Instrukcję defrecord i powiązane funkcje omawiamy w podrozdziale 6.3, „Protokoły”. Kod w języku Clojure nie tylko jest znacznie krótszy; rekord Person jest tu niezmienny. Niezmienne struktury danych są z natury bezpieczne ze względu na wątki, a mechanizmy modyfikowania danych można utworzyć w odrębnej warstwie za pomocą referencji, agentów i atomów języka Clojure. Techniki te omawiamy w rozdziale 5., „Stan”. Ponieważ rekordy są niezmienne, język Clojure automatycznie udostępnia poprawne implementacje funkcji hashCode() i equals(). Język Clojure ma wbudowanych wiele eleganckich rozwiązań, jeśli jednak stwierdzisz, że czegoś Ci w nim brakuje, możesz samodzielnie dodać potrzebne mechanizmy. Umożliwiają to cechy Lispa.

Clojure to odświeżony Lisp Clojure jest Lispem. Od dziesięcioleci zwolennicy Lispa mówią o przewagach, jakie język ten ma w porównaniu z właściwie wszystkimi innymi językami. Plan opanowania świata przez Lispa jest jednak realizowany dość powoli. Twórcy języka Clojure, podobnie jak autorzy każdej odmiany Lispa, musieli zmierzyć się z dwoma problemami. Oto one:  Clojure ma odnieść sukces jako odmiana Lispa. Wymaga to przekonania użytkowników Lispa, że Clojure obejmuje najważniejsze mechanizmy swojego poprzednika.  Jednocześnie Clojure ma odnieść sukces tam, gdzie wcześniejsze wersje Lispa się nie przyjęły. Wymaga to zdobycia poparcia większej społeczności programistów. Autorzy języka Clojure radzą sobie z tymi problemami przez udostępnienie mechanizmów metaprogramowania (charakterystycznych dla Lispa) i wprowadzenie zestawu usprawnień składniowych ułatwiających stosowanie języka Clojure programistom, którzy nie znają Lispa.

Dlaczego Lisp? Wersje Lispa mają mały rdzeń, są prawie pozbawione składni i mają rozbudowane mechanizmy do obsługi makr. Z uwagi na te cechy można zmodyfikować Lispa pod kątem projektu, zamiast dostosowywać projekt do Lispa. Przyjrzyj się poniższemu fragmentowi kodu w Javie:

28



Programowanie w języku Clojure

public class Person { private String firstName; public String getFirstName() { // Ciąg dalszy.

W kodzie występuje metoda getFirstName(). Metody są polimorficzne i można je dostosować do potrzeb. Jednak znaczenie każdego innego słowa w przykładowym kodzie jest określane przez język. Czasem wygodna jest możliwość zmiany znaczenia poszczególnych słów. Pozwala to na przykład:  zdefiniować słowo private jako „prywatne w kodzie produkcyjnym, ale publiczne na potrzeby serializacji i testów jednostkowych”;  zdefiniować słowo class w taki sposób, aby platforma automatycznie generowała metody pobierające i ustawiające dla pól prywatnych (o ile programista nie zarządzi inaczej);  utworzyć podklasę dla class i umieścić w niej wywoływane zwrotnie uchwyty dla zdarzeń cyklu życia; klasa z obsługą cyklu życia może na przykład zgłaszać zdarzenie utworzenia egzemplarza tej klasy. Natykaliśmy się na programy, w których potrzebne były wszystkie te cechy. Bez potrzebnych mechanizmów programiści musieli uciekać się do powtarzalnych i podatnych na błędy sztuczek. Aby poradzić sobie z brakiem niezbędnych rozwiązań, napisano dosłownie miliony wierszy kodu. W większości języków trzeba poprosić osoby odpowiedzialne za implementację, aby dodały wspomniane wcześniej mechanizmy. W Clojure możesz samodzielnie dodać potrzebne cechy, pisząc makra (rozdział 7., „Makra”). Nawet sam język Clojure jest zbudowany za pomocą makr, takich jak defrecord: (defrecord name [arg1 arg2 arg3])

Jeśli chcesz zmienić znaczenie słowa, możesz napisać własne makro. Jeżeli potrzebujesz rekordów o ścisłej kontroli typów i z opcjonalnym sprawdzaniem, czy pola nie mają wartości null, wystarczy utworzyć własne makro defrecord. Powinno ono wyglądać tak: (defrecord name [Type :arg1 Type :arg2 Type :arg3] :allow-nulls false)

Możliwość zmiany działania języka w nim samym jest wyjątkową zaletą Lispa. Można znaleźć wiele opisów różnych aspektów tego podejścia: 2  Lisp jest homoikoniczny . Oznacza to, że kod w Lispie to dane. Dlatego łatwo jest tworzyć programy za pomocą innych programów. 2

http://pl.wikipedia.org/wiki/homoikoniczność

Rozdział 1. • Wprowadzenie 



29

Cały język jest dostępny w każdym momencie. Paul Graham w artykule Revenge of the Nerds3 wyjaśnia, dlaczego jest to ważne.

Składnia Lispa eliminuje też problemy z priorytetami operatorów i łącznością operacji. W książce tej nie znajdziesz tabel dotyczących tych zagadnień. Z uwagi na wymóg stosowania nawiasów wyrażenia są jednoznaczne. Wadą prostej, jednolitej składni Lispa (przynajmniej dla początkujących) jest nacisk na nawiasy i listy (listy to główny typ danych w Lispie). Clojure udostępnia ciekawe połączenie cech, które ułatwiają używanie Lispa programistom znającym inne języki.

Lisp z mniejszą liczbą nawiasów Clojure zapewnia istotne zalety programistom używającym innych odmian Lispa. Oto te korzyści:  W języku Clojure fizyczne listy z Lispa są uogólnione do abstrakcyjnej postaci — sekwencji. Pozwala to zachować możliwości, jakie dają listy, a przy tym wykorzystać ich zalety w innych strukturach danych.  Wykorzystanie maszyn JVM jako podstawy dla kodu w języku Clojure daje dostęp do standardowej biblioteki i bardzo popularnej platformy.  Sposób analizowania symboli i cudzysłowów sprawia, że w języku Clojure pisanie standardowych makr jest stosunkowo proste. Wielu użytkowników języka Clojure nie zna Lispa, za to prawdopodobnie słyszało niepochlebne opinie na temat stosowania w nim nawiasów. W języku Clojure zachowano nawiasy (i możliwości Lispa!), jednak pod kilkoma względami usprawniono tradycyjną składnię Lispa.  Clojure zapewnia wygodną, opartą na literałach składnię dla różnych struktur danych (nie tylko dla list), takich jak wyrażenia regularne, odwzorowania, zbiory, wektory i metadane. Dlatego kod w języku Clojure jest mniej zależny od list niż w większości innych odmian Lispa. Między innymi parametry funkcji są podawane w wektorach, [], a nie w listach, (). src/examples/introduction.clj

(defn hello-world [username] (println (format "Witaj, %s" username)))

Zastosowanie wektora sprawia, że lista argumentów jest lepiej widoczna, co ułatwia czytanie definicji funkcji w języku Clojure. 3

http://www.paulgraham.com/icad.html

30

 

Programowanie w języku Clojure

W języku Clojure, inaczej niż w większości odmian Lispa, przecinki są traktowane jak odstępy. ; Wektory przypominają tablice z innych języków. [1, 2, 3, 4] -> [1 2 3 4]



Język Clojure jest idiomatyczny i nie wymaga niepotrzebnego zagnieżdżania nawiasów. Przyjrzyj się makru cond, które występuje zarówno w Common Lispie, jak i w Clojure. Makro cond sprawdza zestaw par test-wynik i zwraca pierwszy wynik, dla którego wartość wyrażenia testowego to true. Każda para test-wynik znajduje się w nawiasach: ; Makro cond w Common Lispie. (cond ((= x 10) "equal") ((> x 10) "more"))

W języku Clojure dodatkowe nawiasy są niepotrzebne. ; Makro cond w Clojure. (cond (= x 10) "equal" (> x 10) "more")

Jest to kwestia czysto estetyczna i oba podejścia mają swoich zwolenników. Ważne jest to, że język Clojure pozbawiono uciążliwych aspektów Lispa, jeśli było to możliwe bez utraty zalet tego ostatniego. Clojure jest świetną odmianą Lispa zarówno dla ekspertów, jak i dla początkujących programistów Lispa.

Clojure to język funkcyjny Clojure jest językiem funkcyjnym, natomiast nie jest (w odróżnieniu od Haskella) czysto funkcyjny. Oto cechy języków funkcyjnych:  Funkcje to pełnoprawne obiekty. Oznacza to, że funkcje można tworzyć w czasie wykonywania programu, przekazywać, zwracać i ogólnie używać ich jak wszelkich innych typów danych.  Dane są niezmienne.  Funkcje są czyste, czyli nie mają efektów ubocznych. W wielu obszarach programy funkcyjne są łatwiejsze do zrozumienia, mniej podatne na błędy i znacznie łatwiejsze do wielokrotnego użytku. Na przykład poniższy krótki program wyszukuje w bazie danych utwory każdego kompozytora, który napisał dzieło pod tytułem „Requiem”: (for [c compositions :when (= "Requiem" (:name c))] (:composer c)) -> ("W. A. Mozart" "Giuseppe Verdi")

Rozdział 1. • Wprowadzenie



31

Słowo for nie jest początkiem pętli, ale wyrażenia listowego (ang. list comprehension). Kod ten oznacza „dla każdego c z kolekcji compositions, gdzie nazwą c jest "Requiem", podaj kompozytora c”. Wyrażenia listowe omawiamy w punkcie „Przekształcanie sekwencji”. Przykładowy kod ma cztery pożądane cechy:  Jest prosty. Nie obejmuje pętli, zmiennych ani zmiennego stanu.  Jest bezpieczny ze względu na wątki. Nie wymaga stosowania blokad.  Jest możliwy do równoległego wykonywania. Każdy krok można przydzielić do odrębnego wątku bez konieczności modyfikowania kodu poszczególnych kroków.  Jest uniwersalny. Kolekcją compositions może być zwykły zbiór, kod w XML-u lub zbiór wyników z bazy danych. Warto porównać programy funkcyjne z programami imperatywnymi, w których instrukcje zmieniają stan programu. Większość programów obiektowych pisze się w stylu imperatywnym, dlatego nie mają one żadnych z wymienionych wcześniej zalet. Są niepotrzebnie skomplikowane, niebezpieczne ze względu na wątki, nie umożliwiają równoległego działania, a kod jest trudny do uogólnienia. Bezpośrednie porównanie podejścia funkcyjnego i imperatywnego znajdziesz w podrozdziale 2.7, „Gdzie się podziała pętla for?”. Programiści znają zalety języków funkcyjnych już od długiego czasu. Jednak języki funkcyjne, na przykład Haskell, nie zdobyły dominującej pozycji. Wynika to z tego, że nie wszystkie operacje można wygodnie wykonać w podejściu czysto funkcyjnym. Są cztery powody, dla których język Clojure może zyskać większe zainteresowanie niż dawne języki funkcyjne:  Podejście funkcyjne jest dziś bardziej przydatne niż kiedykolwiek wcześniej. Pojawiają się maszyny o coraz większej liczbie rdzeni, a języki funkcyjne pozwalają łatwo wykorzystać możliwości takiego sprzętu. Programowanie funkcyjne omawiamy w rozdziale 4., „Programowanie funkcyjne”.  W językach funkcyjnych niewygodnie zarządza się stanem, jeśli musi się on zmieniać. Clojure udostępnia mechanizmy do obsługi zmiennego stanu za pomocą programowej pamięci transakcyjnej i referencji, agentów, atomów i wiązania dynamicznego.

32

 



Programowanie w języku Clojure

W wielu językach funkcyjnych typy są określane statycznie. W języku Clojure stosuje się dynamiczne określanie typów, co ułatwia zadanie programistom poznającym dopiero programowanie funkcyjne. Wywoływanie w języku Clojure kodu Javy nie odbywa się w modelu funkcyjnym. Przy korzystaniu z Javy wkraczamy w znany świat zmiennych obiektów. Zapewnia to wygodę początkującym, którzy uczą się programowania funkcyjnego, a także pozwala w razie potrzeby zrezygnować z podejścia funkcyjnego. Wywoływanie kodu Javy opisujemy w rozdziale 9., „Sztuczki z Javą”.

Mechanizmy zarządzania zmianą stanu w języku Clojure umożliwiają pisanie programów współbieżnych bez bezpośredniego korzystania z blokad i uzupełniają podstawowy funkcjonalny rdzeń języka.

Clojure upraszcza programowanie współbieżne Mechanizmy programowania funkcyjnego dostępne w Clojure ułatwiają pisanie kodu bezpiecznego ze względu na wątki. Ponieważ niezmienne struktury danych nigdy się nie zmieniają, nie występuje zagrożenie uszkodzeniem danych w wyniku działania innych wątków. Jednak obsługa współbieżności w Clojure wykracza poza mechanizmy programowania funkcyjnego. Jeśli potrzebne są referencje do zmiennych danych, Clojure zabezpiecza je za pomocą programowej pamięci transakcyjnej (ang. software transactional memory — STM). STM służy do tworzenia kodu bezpiecznego ze względu na wątki i jest rozwiązaniem wyższego poziomu niż blokady z Javy. Zamiast stosować podatne na błędy strategie blokowania danych, można zabezpieczyć współużytkowany stan za pomocą transakcji. Jest to dużo lepsze podejście, ponieważ wielu programistów dobrze zna transakcje z uwagi na doświadczenie w korzystaniu z baz danych. Poniższy kod tworzy działającą, bezpieczną ze względu na wątki bazę danych z kontami przechowywaną w pamięci: (def accounts (ref #{})) (defrecord Account [id balance])

Funkcja ref tworzy zabezpieczoną za pomocą transakcji referencję do bieżącego stanu bazy danych. Aktualizowanie stanu jest niezwykle proste. Poniżej pokazujemy, jak dodać do bazy nowe konto: (dosync (alter accounts conj (->Account "CLJ" 1000.00)))

Rozdział 1. • Wprowadzenie



33

Instrukcja dosync powoduje aktualizowanie bazy accounts w transakcji. Rozwiązanie to zapewnia bezpieczeństwo ze względu na wątki i jest łatwiejsze w stosowaniu od blokad. Dzięki transakcjom nigdy nie trzeba martwić się o to, które obiekty i w jakiej kolejności należy zablokować. Podejście transakcyjne sprawdza się lepiej także w niektórych standardowych zastosowaniach. Przykładowo: w modelu tym wątki wczytujące dane nigdy nie muszą blokować danych. Choć przedstawiony przykład jest bardzo prosty, technika jest ogólna i sprawdza się także w praktyce. Więcej informacji o współbieżności i pamięci STM w języku Clojure znajdziesz w rozdziale 5., „Stan”.

Wykorzystanie maszyny JVM w Clojure Clojure zapewnia przejrzysty, prosty i bezpośredni dostęp do Javy. W kodzie można bezpośrednio wywołać metody z dowolnego interfejsu API Javy: (System/getProperties) -> {java.runtime.name=Java(TM) SE Runtime Environment ... i wiele innych ...

Clojure obejmuje wiele składniowych mechanizmów do wywoływania kodu w Javie. Nie omawiamy tu ich szczegółowo (zobacz podrozdział 2.5, „Wywoływanie kodu w Javie”), warto jednak wspomnieć, że w Clojure występuje mniej kropek i mniej nawiasów niż w analogicznym kodzie Javy: // Java "hello".getClass().getProtectionDomain() ; Clojure (.. "hello" getClass getProtectionDomain)

Clojure udostępnia proste funkcje do implementowania interfejsów Javy i tworzenia klas pochodnych od klas Javy. Ponadto wszystkie funkcje języka Clojure obejmują implementację interfejsów Callable i Runnable. Dlatego można łatwo przekazać poniższą funkcję anonimową do konstruktora klasy Thread Javy: (.start (new Thread (fn [] (println "Witaj" (Thread/currentThread))))) -> Witaj #

Dziwne dane wyjściowe wynikają ze sposobu wyświetlania informacji o obiektach Javy w języku Clojure. Thread to nazwa klasy danego obiektu, a człon Thread[Thread-0,5,main] to efekt wywołania metody toString obiektu. (Zauważ, że w przedstawionym kodzie nowy wątek działa aż do zakończenia pracy, natomiast jego dane wyjściowe mogą w dziwny sposób przeplatać się z wierszami zachęty środowiska REPL. Nie jest to problem z językiem Clojure, a jedynie wynik zapisywania danych do strumienia wyjścia przez więcej niż jeden wątek).

34



Programowanie w języku Clojure

Ponieważ składnia do wywoływania kodu Javy w Clojure jest przejrzysta i prosta, zwykle używa się Javy bezpośrednio, zamiast ukrywać kod w tym języku za charakterystycznymi dla Lispa nakładkami. Poznałeś już kilka powodów do stosowania języka Clojure. Pora przystąpić do pisania kodu.

1.2. Szybkie wprowadzenie do programowania w Clojure Aby uruchomić środowisko języka Clojure i kod z tej książki, potrzebujesz dwóch rzeczy. Oto one: 4  Środowisko uruchomieniowe Javy. Pobierz i zainstaluj Javę w wersji 5. lub nowszej. W wersji 6. znacznie poprawiono wydajność i system informowania o wyjątkach, dlatego warto stosować właśnie ją. 5  Leiningen . Leiningen to narzędzie do zarządzania zależnościami i uruchamiania operacji na kodzie. Jest to także najpopularniejsze w świecie języka Clojure narzędzie do wykonywania tych zadań. Leiningen posłuży do zainstalowania języka Clojure i wszystkich elementów wymaganych w przykładowym kodzie. Jeśli masz już zainstalowane narzędzie Leiningen, wiesz zapewne, jak z niego korzystać. Jeżeli jeszcze nie masz potrzebnej wiedzy, zapoznaj się z krótkim wprowadzeniem ze strony narzędzia w serwisie GitHub6. Znajdziesz tam instrukcje dotyczące instalacji, a także podstawowe informacje na temat użytkowania Leiningena. Nie musisz jednak uczyć się wszystkiego, ponieważ w książce opisujemy polecenia potrzebne do uruchomienia przykładów. W trakcie pracy z książką korzystaj z języka Clojure w wersji właściwej dla przykładowego kodu. Po przeczytaniu książki możesz zastosować się do instrukcji z ramki „Samodzielne budowanie języka Clojure” i zbudować aktualną wersję języka.

4 5 6

http://www.oracle.com/technetwork/java/javase/downloads/index.html http://github.com/technomancy/leiningen http://github.com/technomancy/leiningen

Rozdział 1. • Wprowadzenie



35

W punkcie „Pobieranie przykładowego kodu”, znajdziesz instrukcje dotyczące pobierania przykładowego kodu. Po ściągnięciu przykładowego kodu trzeba użyć Leiningena do pobrania zależności. W katalogu głównym z kodem wywołaj następującą instrukcję: lein deps

Samodzielne budowanie języka Clojure Możliwe, że chcesz zbudować język Clojure na podstawie kodu źródłowego, aby uzyskać nowe funkcje i wprowadzić poprawki błędów. Można zrobić to w następujący sposób: git clone git://github.com/clojure/clojure.git cd clojure mvn package

Przykładowy kod jest regularnie aktualizowany pod kątem nowych rozwiązań wprowadzanych w języku Clojure. Zapoznaj się z plikiem README w przykładowym kodzie, aby sprawdzić numer najnowszej wersji, dla której sprawdzono kod.

Zależności są pobierane i umieszczane w odpowiednim miejscu. Możesz przetestować zainstalowane narzędzia przez przejście do katalogu z przykładowym kodem i uruchomienie środowiska REPL języka Clojure. Leiningen obejmuje skrypt uruchomieniowy środowiska REPL, który wczytuje język Clojure wraz z zależnościami potrzebnymi w dalszych rozdziałach. lein repl

Po udanym uruchomieniu środowiska REPL powinien pojawić się wiersz zachęty z tekstem user=>: Clojure user=>

Teraz jesteś gotowy do wyświetlenia tekstu „Witaj, świecie”.

Korzystanie ze środowiska REPL Aby pokazać, jak korzystać ze środowiska REPL, tworzymy kilka wersji kodu wyświetlającego tekst „Witaj, świecie”. Najpierw wpisz kod (println "Witaj, świecie") w wierszu zachęty środowiska REPL. user=> (println "Witaj, świecie") -> Witaj, świecie

36

Programowanie w języku Clojure



Drugi wiersz, Witaj,

świecie,

to żądane dane wyjściowe z konsoli.

Teraz umieśćmy kod w funkcji, która potrafi „zwracać się” do użytkownika po imieniu. (defn hello [name] (str "Witaj, " name)) -> #'user/hello

Rozłóżmy ten kod na fragmenty. Oto one:  defn służy do definiowania funkcji;  hello to nazwa funkcji;  funkcja hello przyjmuje jeden argument, name;  str to wywołanie funkcji łączącej dowolną listę argumentów w łańcuch znaków;  defn, hello, name i str to symbole, czyli nazwy prowadzące do różnych elementów; dozwolone symbole opisujemy w punkcie „Symbole”. Przyjrzyj się zwracanej wartości, #'user/hello. Przedrostek #' oznacza, że funkcję zapisano w zmiennej języka Clojure, a user to przestrzeń nazw, w której znajduje się ta funkcja. Jest to domyślna przestrzeń nazw w środowisku REPL, odpowiadająca domyślnemu pakietowi w Javie. Na razie zmienne i przestrzenie nazw nie mają znaczenia. Omawiamy je w podrozdziale 2.4, „Zmienne, wiązanie i przestrzenie nazw”. Teraz można wywołać funkcję hello i przekazać do niej imię. user=> (hello "Janku") -> "Witaj, Janku"

Jeśli środowisko REPL znajduje się w dziwnym stanie, najłatwiej zamknąć je za pomocą kombinacji klawiszy Ctrl+C w systemie Windows lub Ctrl+D w systemach uniksowych, a następnie ponownie uruchomić.

Specjalne zmienne Środowisko REPL obejmuje szereg przydatnych zmiennych specjalnych. W czasie pracy w środowisku REPL wyniki obliczania trzech ostatnich wyrażeń znajdują się w specjalnych zmiennych *1, *2 i *3. Pozwala to na wygodną pracę w modelu iteracyjnym. Spróbujmy połączyć kilka powitań. user=> (hello "Janku") -> "Witaj, Janku" user=> (hello "Clojure") -> "Witaj, Clojure"

Rozdział 1. • Wprowadzenie



37

Teraz można zastosować specjalne zmienne do połączenia wyników ostatnich instrukcji. (str *1 " i " *2) -> "Witaj, Clojure i Witaj, Janku"

Popełnienie błędu w środowisku REPL prowadzi do zgłoszenia wyjątku Javy (z uwagi na zwięzłość szczegóły pomijamy). Niedozwolone jest na przykład dzielenie przez zero. user=> (/ 1 0) -> ArithmeticException Divide by zero clojure.lang.Numbers.divide

Tu problem jest oczywisty, jednak czasem jest bardziej skomplikowany i potrzebujemy szczegółowego stosu wywołań. W specjalnej zmiennej *e znajdują się informacje o ostatnim wyjątku. Ponieważ wyjątki w Clojure są wyjątkami Javy, można wyświetlić stos wywołań za pomocą instrukcji pst (od ang. print stacktrace, czyli wyświetl stos wywołań)7. user=> (pst) -> ArithmeticException Divide by zero | clojure.lang.Numbers.divide | sun.reflect.NativeMethodAccessorImpl.invoke0 | sun.reflect.NativeMethodAccessorImpl.invoke | sun.reflect.DelegatingMethodAccessorImpl.invoke | java.lang.reflect.Method.invoke | clojure.lang.Reflector.invokeMatchingMethod | clojure.lang.Reflector.invokeStaticMethod | user/eval1677 | clojure.lang.Compiler.eval | clojure.lang.Compiler.eval | clojure.core/eval

Współdziałanie z Javą omawiamy w rozdziale 9., „Sztuczki z Javą”. Jeśli blok kodu jest zbyt długi, aby można go wygodnie wpisać w środowisku REPL, umieść kod w pliku, a następnie wczytaj ten plik w środowisku. Możesz użyć ścieżki bezwzględnej lub podać ją względem miejsca uruchomienia środowiska REPL. ; Zapisz kod w pliku temp.clj, a następnie wywołaj instrukcję: user=> (load-file "temp.clj")

REPL to znakomite środowisko do wypróbowywania pomysłów i otrzymywania natychmiastowych informacji zwrotnych. Aby jak najlepiej wykorzystać książkę, w trakcie jej lektury nie zamykaj środowiska REPL.

7

Instrukcja pst jest dostępna tylko w Clojure 1.3.0 i nowszych wersjach.

38



Programowanie w języku Clojure

Dodawanie stanu współużytkowanego Funkcja hello z poprzedniego przykładu to czysta funkcja, czyli taka, której działanie nie ma efektów ubocznych. Czyste funkcje łatwo się pisze i testuje. Są także łatwe do zrozumienia, dlatego w wielu sytuacjach warto je stosować. Jednak w większości programów występuje współużytkowany stan, a do zarządzania nim służą funkcje typu impure (czyli takie, które nie są czyste). Dodajmy do funkcji hello mechanizm śledzenia liczby użytkowników. Najpierw trzeba utworzyć strukturę danych do przechowywania tej liczby. Użyjmy do tego zbioru. #{} -> #{}

#{}

to literał oznaczający pusty zbiór. Potrzebna jest też operacja conj.

(conj coll item)

Instrukcja conj (od ang. conjoin, czyli łączyć) tworzy nową kolekcję z dodawanym elementem. Dołączmy element do zbioru, aby upewnić się, że powstaje nowy zbiór. (conj #{} "Janku") -> #{"Janku"}

Tworzenie nowych zbiorów jest już możliwe. Pora opracować sposób na sprawdzanie aktualnego zbioru użytkowników. Clojure udostępnia kilka służących do tego typów referencyjnych. Najprostszym typem referencyjnym jest atom. (atom initial-state)

Aby określić nazwę atomu, należy użyć instrukcji def. (def symbol initial-value?)

Instrukcja def przypomina polecenie defn, ale jest ogólniejsza. Przy jej użyciu można definiować funkcje lub dane. Użyjmy słowa atom do utworzenia atomu i instrukcji def do powiązania atomu z nazwą visitors. (def visitors (atom #{})) -> #'user/visitors

Aby zaktualizować referencję, trzeba użyć funkcji, na przykład swap!. (swap! r update-fn & args)

Funkcja swap! przeprowadza operację update-fn na referencji r i w razie potrzeby używa przy tym opcjonalnych argumentów args. Spróbujmy wstawić użytkownika do kolekcji visitors, używając do aktualizowania funkcji conj. (swap! visitors conj "Janku") -> #{"Janku"}

Rozdział 1. • Wprowadzenie



39

atom to tylko jeden z kilku typów referencyjnych dostępnych w języku Clojure. Wybór odpowiedniego typu referencyjnego nie jest prosty (zagadnienie to omawiamy w rozdziale 5., „Stan”).

W dowolnym momencie można sprawdzić zawartość pamięci, do której prowadzi referencja. Służy do tego instrukcja deref lub jej krótszy odpowiednik, @. (deref visitors) -> #{"Janku"} @visitors -> #{"Janku"}

Teraz można zbudować nową, bardziej rozbudowaną wersję funkcji hello. src/examples/introduction.clj

(defn hello "Wyświetla powitanie na wyjściu, używając nazwy użytkownika. Potrafi stwierdzić, że korzystałeś już z programu." [username] (swap! visitors conj username) (str "Witaj, " username))

Teraz sprawdźmy, czy użytkownicy są poprawnie zapisywani w pamięci. (hello "Marku") -> "Witaj, Marku" @visitors -> #{"Jacku" "Janku" "Marku"}

Na Twoim komputerze lista użytkowników będzie prawdopodobnie inna. Na tym właśnie polega problem ze stanem. Efekty są różne w zależności od tego, kiedy zaszły dane zdarzenia. Zrozumieć funkcję można na podstawie jej analizy. Aby zrozumieć stan, trzeba poznać całą historię działania programu. Jeśli to możliwe, unikaj przechowywania stanu. Jeżeli jest to niemożliwe, dbaj o to, aby stanem można było zarządzać. Używaj do tego typów referencyjnych, na przykład atomów. Atomy (i wszystkie inne typy referencyjne w Clojure) są bezpieczne przy korzystaniu z wielu wątków i procesorów. Co lepsze, zapewnienie bezpieczeństwa nie wymaga stosowania blokad, które bywają skomplikowane w użyciu. Na tym etapie powinieneś umieć już wprowadzać krótkie fragmenty kodu w środowisku REPL. Wprowadzanie większych porcji kodu odbywa się podobnie. Także biblioteki języka Clojure można wczytywać i uruchamiać z poziomu środowiska REPL. Dalej pokazujemy, jak to zrobić.

40



Programowanie w języku Clojure

1.3. Biblioteki języka Clojure Kod języka Clojure jest umieszczony w bibliotekach. Każda biblioteka języka Clojure znajduje się w przestrzeni nazw, która jest odpowiednikiem pakietu Javy. Bibliotekę języka Clojure można wczytać za pomocą instrukcji require. (require quoted-namespace-symbol)

Jeśli programista żąda biblioteki o nazwie clojure.java.io, Clojure szuka pliku o nazwie clojure/java/io.clj w ścieżce ze zmiennej CLASSPATH. Zobaczmy, jaki jest tego efekt. user=> (require 'clojure.java.io) -> nil

Początkowy pojedynczy apostrof (') jest niezbędny i służy do dosłownego podawania (ang. quoting) nazwy biblioteki (podawanie nazw omawiamy w podrozdziale 2.2, „Makra odczytu”). Zwrócona wartość nil oznacza powodzenie. Przy okazji sprawdź, czy możesz wczytać przykładowy kod do tego rozdziału (bibliotekę examples.introduction). user=> (require 'examples.introduction) -> nil

Biblioteka examples.introduction obejmuje implementację generowania liczb Fibonacciego. W językach funkcyjnych jest to tradycyjny program typu „Witaj, świecie”. Liczby Fibonacciego omawiamy szczegółowo w podrozdziale 4.2, „»Leniwe« podejście”. Na razie upewnij się, że możesz uruchomić przykładową funkcję fibs. Wprowadź poniższy wiersz kodu w środowisku REPL, a otrzymasz 10 pierwszych liczb Fibonacciego. (take 10 examples.introduction/fibs) -> (0 1 1 2 3 5 8 13 21 34)

Jeśli otrzymałeś 10 pierwszych liczb Fibonacciego (wymienionych powyżej), poprawnie zainstalowałeś przykładowy kod z książki. Wszystkie przykłady sprawdziliśmy za pomocą testów jednostkowych (testy znajdują się w katalogu examples/test). Samych testów nie omawiamy w książce, jednak mogą okazać się przydatnym źródłem wiedzy. Aby uruchomić testy jednostkowe, użyj instrukcji lein test.

Rozdział 1. • Wprowadzenie



41

Instrukcje require i use Po zażądaniu biblioteki języka Clojure za pomocą instrukcji require elementy z biblioteki trzeba wskazywać za pomocą pełnej nazwy. Zamiast nazwy fibs trzeba użyć określenia examples.introduction/fibs. Uruchom drugi egzemplarz środowiska REPL8 i wprowadź poniższy kod. (require 'examples.introduction) -> nil (take 10 examples.introduction/fibs) -> (0 1 1 2 3 5 8 13 21 34)

Wprowadzanie pełnych nazw szybko staje się kłopotliwe. Możesz użyć instrukcji refer dla przestrzeni nazw i odwzorować wszystkie nazwy z tej przestrzeni na bieżącą przestrzeń nazw. (refer quoted-namespace-symbol)

Wywołaj instrukcję refer dla przestrzeni examples.introduction i sprawdź, czy możesz bezpośrednio wywołać funkcję fibs. (refer 'examples.introduction) -> nil (take 10 fibs) -> (0 1 1 2 3 5 8 13 21 34)

Wygodna funkcja use pozwala wykonać instrukcje require i refer w jednym kroku. (use quoted-namespace-symbol)

W nowym środowisku REPL wprowadź następujące instrukcje. (use 'examples.introduction) -> nil (take 10 fibs) -> (0 1 1 2 3 5 8 13 21 34)

W trakcie pracy z przykładowym kodem z książki możesz wywołać instrukcję require lub use z opcją :reload, aby wymusić ponowne wczytanie biblioteki. (use :reload 'examples.introduction) -> nil

Opcja :reload jest przydatna, jeśli wprowadzasz zmiany i chcesz sprawdzić ich efekt bez ponownego uruchamiania środowiska REPL. 8

Otwarcie nowego środowiska REPL zapobiega konfliktom nazw między utworzonym wcześniej kodem a funkcjami o tych samych nazwach z przykładowego kodu. W praktyce nie stanowi to problemu. Zagadnienie to omawiamy w punkcie „Przestrzenie nazw”.

42

Programowanie w języku Clojure



Znajdowanie dokumentacji Potrzebna dokumentacja często jest dostępna bezpośrednio w środowisku REPL. Najprostszą funkcją pomocniczą9 jest doc. (doc name)

Użyjmy funkcji doc do wyświetlenia dokumentacji funkcji str. user=> (doc str) ------------------------clojure.core/str ([] [x] [x & ys]) With no args, returns the empty string. With one arg x, returns x.toString(). (str nil) returns the empty string. With more than one arg, returns the concatenation of the str values of the args.

Pierwszy wiersz danych wyjściowych funkcji doc obejmuje pełną nazwę sprawdzanej funkcji. W drugim znajdują się argumenty generowane bezpośrednio w kodzie. Wybrane często stosowane nazwy argumentów i ich zastosowanie omawiamy w ramce „Zwyczajowe nazwy parametrów”. Dalsze wiersze obejmują łańcuch znaków dokumentacji, jeśli jest on podany w definicji funkcji. Zwyczajowe nazwy parametrów Łańcuchy znaków dokumentacji w funkcjach reduce i areduce obejmują szereg krótkich nazw parametrów. Oto niektórych z tych nazw i sposoby ich stosowania. Parametr a agt coll expr f idx r v val

Zastosowanie Tablica Javy Agent Kolekcja Wyrażenie Funkcja Indeks Referencja Wektor Wartość

Nazwy mogą wydawać się krótkie, jednak jest tak nie bez powodu — „dobre” nazwy często są już „zajęte” przez funkcje języka Clojure! Używanie dla parametrów nazw identycznych z nazwami funkcji jest dopuszczalne, ale uznaje się to za oznakę złego stylu. Parametr zasłania wtedy funkcję, dlatego jest ona niedostępna, kiedy parametr znajduje się w zasięgu programu. Dlatego nie należy nazywać referencji ref, agentów agent, a liczników — count, ponieważ są to nazwy funkcji. 9

Tak naprawdę doc to makro języka Clojure.

Rozdział 1. • Wprowadzenie



43

Łańcuch znaków dokumentacji można dodać do funkcji przez umieszczenie go bezpośrednio po jej nazwie. src/examples/introduction.clj

(defn hello "Wyświetla powitanie na wyjściu, używając nazwy użytkownika. " [username] (println (str "Witaj, " username))

Czasem nie znasz nazwy elementu, którego dokumentacji potrzebujesz. Funkcja find-doc wyszukuje informacje o wszystkich elementach, dla których dane wyjściowe funkcji doc pasują do przekazanego wyrażenia regularnego lub łańcucha znaków. (find-doc s)

Za pomocą funkcji kolekcje.

find-doc

można sprawdzić, w jaki sposób Clojure skraca

user=> (find-doc "reduce") ------------------------clojure/areduce ([a idx ret init expr]) Macro ... Szczegóły pominięto ... ------------------------clojure/reduce ([f coll] [f val coll]) ... Szczegóły pominięto ...

Funkcja reduce pozwala w skrócony sposób stosować operacje do kolekcji języka Clojure. Omawiamy ją w punkcie „Przekształcanie sekwencji”. Funkcja areduce współdziała z tablicami Javy, a opisujemy ją w punkcie „Korzystanie z kolekcji Javy”. Duża część języka Clojure jest napisana w nim samym, dlatego lektura jego kodu źródłowego to pouczające zadanie. Kod źródłowy funkcji języka Clojure można wyświetlić za pomocą instrukcji source z biblioteki repl. (clojure.repl/source symbol)

Wyświetlmy kod źródłowy prostej funkcji identity. (use 'clojure.repl) (source identity) -> (defn identity "Returns its argument." {:added "1.0" :static true} [x] x)

44



Programowanie w języku Clojure

Oczywiście, można też używać interfejsu API Reflection Javy. Metody class, ancestors , instance? i podobne pozwalają sprawdzić model obiektowy Javy i informują na przykład o tym, że kolekcje języka Clojure są jednocześnie kolekcjami Javy. (ancestors (class [1 2 3])) -> #{clojure.lang.ILookup clojure.lang.Sequential java.lang.Object clojure.lang.Indexed java.lang.Iterable clojure.lang.IObj clojure.lang.IPersistentCollection clojure.lang.IPersistentVector clojure.lang.AFn java.lang.Comparable java.util.RandomAccess clojure.lang.Associative clojure.lang.APersistentVector clojure.lang.Counted clojure.lang.Reversible clojure.lang.IPersistentStack java.util.List clojure.lang.IEditableCollection clojure.lang.IFn clojure.lang.Seqable java.util.Collection java.util.concurrent.Callable clojure.lang.IMeta java.io.Serializable java.lang.Runnable}

Internetową dokumentację interfejsu API języka Clojure znajdziesz na stronie http://clojure.github.com/clojure. W ramce widocznej w prawej części tej strony znajdują się odnośniki do wszystkich funkcji i makr. Po lewej stronie umieszczono odnośniki do artykułów na temat różnych cech języka Clojure.

1.4. Podsumowanie Właśnie zakończyłeś szybki przegląd języka Clojure. Poznałeś dającą duże możliwości składnię tego języka i jego związki z Lispem, a także zobaczyłeś, jak łatwe jest wywoływanie w Clojure kodu Javy. Uruchomiłeś język Clojure w swoim środowisku, a także napisałeś w środowisku REPL krótkie programy ilustrujące programowanie funkcyjne i służący do obsługi stanu model referencji. Pora przyjrzeć się całemu językowi.

Rozdział 2.

Przegląd języka Clojure

C

lojure daje programistom bardzo duże możliwości z uwagi na podejście funkcyjne, obsługę współbieżności i przejrzyste współdziałanie z Javą. Jednak zanim docenisz te cechy, musisz zacząć od podstaw. W tym rozdziale znajdziesz krótki przegląd języka Clojure. Opisujemy tu między innymi:  konstrukcje składniowe,  makra odczytu,  funkcje,  wiązania i przestrzenie nazw,  przepływ sterowania,  metadane. Jeśli wcześniej korzystałeś głównie z języków imperatywnych, w tym przeglądzie możesz zauważyć brak pewnych ważnych elementów języka, na przykład zmiennych i pętli for. Z podrozdziału 2.7, „Gdzie się podziała pętla for?”, dowiesz się, że bez pętli for i zmiennych życie jest łatwiejsze. Język Clojure daje bardzo duże możliwości wyrazu, a w tym rozdziale na niewielu stronach omawiamy liczne zagadnienia. Nie przejmuj się, jeśli nie wszystko zrozumiesz. Do tematów tych wracamy w dalszych rozdziałach. Jeśli to możliwe, otwórz środowisko REPL i wykonuj przykłady w trakcie lektury.

46

Programowanie w języku Clojure



2.1. Konstrukcje składniowe Język Clojure jest homoikoniczny1, co oznacza, że kod składa się tu z danych języka Clojure. W momencie uruchomienia programu w tym języku część języka, tak zwany czytnik (ang. reader), odczytuje tekst programu w porcjach zwanych konstrukcjami (ang. form) i przekształca je na struktury danych języka. Następnie Clojure kompiluje i wykonuje struktury danych. Listę omawianych w książce konstrukcji języka Clojure znajdziesz w tabeli 2.1, „Konstrukcje języka Clojure”. Aby pokazać działanie konstrukcji, zaczynamy od ich prostych odmian służących do obsługi typów liczbowych. Tabela 2.1.

Konstrukcje języka Clojure

Konstrukcja

Przykłady

Omówienie

Wartość logiczna

true, false

Wartości logiczne i nil

Znak

\a

„Łańcuchy znaków i znaki”

Słowo kluczowe

:tag, :doc

„Odwzorowania, słowa kluczowe i rekordy”

Lista

(1 2 3), (println "foo")

Rozdział 3., „Ujednolicanie danych za pomocą sekwencji”

Odwzorowanie

{:name "Adam", :age 42}

„Odwzorowania, słowa kluczowe i rekordy”

Wartość nil

nil

„Wartości logiczne i nil”

Liczba

1, 4.2

„Stosowanie typów liczbowych”

Zbiór

#{:snap :crackle :pop}

Rozdział 3., „Ujednolicanie danych za pomocą sekwencji”

Łańcuch znaków

"Witaj"

„Łańcuchy znaków i znaki”

Symbol

user/foo, java.lang.String

„Symbole”

Wektor

[1 2 3]

Rozdział 3., „Ujednolicanie danych za pomocą sekwencji”

Stosowanie typów liczbowych Literały liczbowe są konstrukcjami. Wartością liczby jest ona sama. Jeśli wprowadzisz liczbę, środowisko REPL ją zwróci. 42 -> 42

1

http://pl.wikipedia.org/wiki/Homoikoniczność

Rozdział 2. • Przegląd języka Clojure



47

Wektor liczb to konstrukcja innego rodzaju. Utwórzmy wektor z liczbami 1, 2 i 3. [1 2 3] -> [1 2 3]

Także lista to konstrukcja. Lista to tylko dane, jednak można jej też użyć do wywoływania funkcji. Utwórzmy listę, której pierwszy element to nazwa funkcji języka Clojure — symbol +. (+ 1 2) -> 3

Jak widać, przy wartościowaniu Clojure traktuje listę jak wywołanie funkcji. Umieszczanie funkcji na początku to tak zwana notacja przedrostkowa2, różniąca się od częściej stosowanej notacji wrostkowej 1 + 2 = 3. Notacja przedrostkowa jest oczywiście zupełnie naturalna dla wywołań funkcji, których nazwami są słowa. Większość programistów słusznie zakłada, że w poniższym wyrażeniu nazwa concat powinna znajdować się na początku. (concat [1 2] [3 4]) -> (1 2 3 4)

W języku Clojure operatory matematyczne są traktowane tak jak inne funkcje i umieszczane na początku. Praktyczną zaletą notacji przedrostkowej jest to, że można łatwo zastosować ją dla dowolnej liczby argumentów. (+ 1 2 3) -> 6

Nawet uproszczone wywołanie bez argumentów działa w oczekiwany sposób (zwraca zero). Pomaga to wyeliminować podatny na błędy kod związany z warunkami granicznymi. (+) -> 0

Wiele operatorów matematycznych oraz porównania ma nazwy i działanie standardowe dla innych języków programowania. Operatory dodawania, odejmowania, mnożenia, porównywania i równości funkcjonują w oczekiwany sposób. (- 10 5) -> 5 (* 3 10 10) -> 300 (> 5 2) 2

A dokładniej — notacja polska.

48



Programowanie w języku Clojure

-> true (>= 5 5) -> true (< 5 2) -> false (= 5 2) -> false

Pewnym zaskoczeniem może być wynik dzielenia. (/ 22 7) -> 22/7

Język Clojure ma wbudowany typ Ratio. (class (/ 22 7)) -> clojure.lang.Ratio

Jeśli programista chce przeprowadzić dzielenie liczb dziesiętnych, powinien zapisać dzielną z kropką dziesiętną. (/ 22.0 7) -> 3.142857142857143

Aby ograniczyć się do liczb całkowitych, można użyć funkcji brania części całkowitej oraz reszty z dzielenia.

quot

i

rem

do po-

(quot 22 7) -> 3 (rem 22 7) -> 1

Jeżeli potrzebne są operacje na liczbach zmiennoprzecinkowych o określonej precyzji, należy dodać literę M do liczby. Powstanie wtedy literał typu BigDecimal. (+ 1 (/ 0.00001 1000000000000000000)) -> 1.0 (+ 1 (/ 0.00001M 1000000000000000000)) -> 1.00000000000000000000001M

Liczby całkowite o dowolnie określonej precyzji można tworzyć przez dodanie litery N. Powstaje w ten sposób literał typu BigInt. (* 1000N 1000 1000 1000 1000 1000 1000) -> 1000000000000000000000N

Warto zauważyć, że wystarczy użyć jednego literału typu BigInt, aby całe obliczenia były przeprowadzane z wykorzystaniem tego typu.

Rozdział 2. • Przegląd języka Clojure



49

Symbole Konstrukcje w rodzaju +, concat i java.lang.String to tak zwane symbole. Służą one do nazywania elementów kodu. Na przykład + to nazwa funkcji dodającej różne rzeczy. W języku Clojure symbole są nazwami rozmaitych elementów. Oto przykładowe elementy:  funkcje, na przykład str i concat,  „operatory” w rodzaju + i – (są one zwykłymi funkcjami),  klasy Javy, takie jak java.lang.String i java.util.Random,  przestrzenie nazw (na przykład clojure.core) i pakiety Javy (takie jak java.lang),  struktury danych i referencje. Symbole nie mogą zaczynać się od cyfry, natomiast mogą obejmować dowolne znaki alfanumeryczne oraz znaki +, -, *, /, !, ?, . i _. Jest to najmniejsza lista znaków, które można stosować w symbolach w języku Clojure. Przedstawiona lista nie jest wyczerpująca, jednak we własnym kodzie nie należy stosować innych znaków. W języku Clojure czasem używane są inne, nieudokumentowane znaki w wewnętrznie stosowanych symbolach. Ponadto w przyszłości dozwolone mogą stać się inne znaki. Ich aktualną listę znajdziesz w dokumentacji internetowej3 języka Clojure. W Clojure znaki / i . mają specjalne znaczenie, związane z obsługą przestrzeni nazw. Więcej informacji na ten temat znajdziesz w punkcie „Przestrzenie nazw”.

Łańcuchy znaków i znaki Łańcuchy znaków to inny rodzaj konstrukcji odczytu. Łańcuchy znaków języka Clojure są łańcuchami znaków Javy. Umieszcza się je w cudzysłowach i mogą obejmować kilka wierszy. "To \nwielowierszowy łańcuch znaków" -> "To \nwielowierszowy łańcuch znaków" "To także wielowierszowy łańcuch znaków" -> "To także \nwielowierszowy łańcuch znaków"

Jak widać, środowisko REPL zawsze wyświetla literały łańcuchowe z sekwencjami nowego wiersza. Jednak wywołanie metody println dla wielowierszowego łańcucha znaków powoduje wyświetlenie go w wielu wierszach. 3

http://clojure.org/reader

50



Programowanie w języku Clojure

(println "następny\nwielowierszowy\nłańcuch") | następny | wielowierszowy | łańcuch -> nil

W języku Clojure generalnie nie ma nakładek na funkcje Javy służące do obsługi łańcuchów znaków (choć są wyjątki). Funkcje te można wywoływać bezpośrednio, używając konstrukcji obsługujących współdziałanie języka Clojure z Javą. (.toUpperCase "witaj") -> "WITAJ"

Kropka przed nazwą toUpperCase to informacja dla Clojure, że ma wywołać metodę Javy, a nie funkcję języka Clojure. W Clojure istnieje natomiast nakładka na funkcję łańcuchową toString. Funkcji tej nie trzeba wywoływać bezpośrednio. Zamiast toString należy używać funkcji str języka Clojure. (str & args)

Funkcja str różni się od metody toString pod dwoma względami: łączy wiele argumentów i pomija wartości nil bez zgłaszania błędów. (str 1 2 nil 3) -> "123"

Znaki w języku Clojure to odpowiednik znaków z Javy. Składnia przytaczania literałów znakowych to \{litera}, gdzie litera to albo litera, albo nazwa znaku (na przykład backspace, formfeed, newline, return, space lub tab). (str \h \e \j \space \t \y) -> "hej ty"

Podobnie jak dla funkcji łańcuchowych, tak i dla funkcji znakowych w Clojure nie ma nakładek na funkcje Javy. Można wykorzystać konstrukcje przeznaczone do współdziałania z Javą i na przykład wywołać metodę Character/toUpperCase. (Character/toUpperCase \s) -> \S

Konstrukcje współdziałania z Javą omawiamy w podrozdziale 2.5, „Wywoływanie kodu w Javie”. Więcej informacji o klasie Character Javy znajdziesz w dokumentacji API na stronie http://tinyurl.com/java-character. Łańcuchy znaków składają się z sekwencji znaków. Wywołanie dla łańcuchów znaków funkcji języka Clojure przeznaczonych dla sekwencji prowadzi do zwrócenia znaków w formie sekwencji. Załóżmy, że chcemy „zaszyfrować” tajną wiadomość przez przeplecenie jej z inną, nieistotną informacją. Można użyć funkcji interleave do połączenia obu wiadomości.

Rozdział 2. • Przegląd języka Clojure



51

(interleave "Atak o świcie" "Różowe słonie na balkonie") -> (\A \R \t \ó \a \ż \k \o \space \w \o \e \space \space \ś \s \w \ł \i \o \c \n \i \i \e \e)

To rozwiązanie działa, jednak wiadomość łatwiej byłoby przesyłać w formie łańcucha znaków. Można spróbować użyć funkcji str do umieszczenia znaków z powrotem w łańcuchu, ale to rozwiązanie nie działa. (str (interleave "Atak o świcie" "Różowe słonie na balkonie")) -> "clojure.lang.LazySeq@d4ea9f36"

Problem wynika z tego, że metoda str działa dla dowolnej liczby argumentów, natomiast tu przekazujemy pojedynczy argument w postaci listy z argumentami. Poprawnym rozwiązaniem jest użycie funkcji apply. (apply f args* argseq)

Funkcja apply przyjmuje funkcję f, opcjonalnie argument (args) i sekwencję argumentów (argseq). Następnie wywołuje funkcję f i przekształca przy tym argumenty args oraz argseq na listę argumentów. Za pomocą składni(apply str …) można utworzyć łańcuch znaków na podstawie ich sekwencji. (apply str (interleave "Atak o świcie" "Różowe słonie na balkonie")) -> "ARtóażko woe śswłiocniiee"

Za pomocą składni (apply

str …)

można też „odszyfrować” wiadomość.

(apply str (take-nth 2 "ARtóażko woe śswłiocniiee")) -> "Atak o świcie"

Instrukcja (take-nth 2 …) powoduje pobranie co drugiego elementu sekwencji i wyodrębnienie ukrytej wiadomości.

Wartości logiczne i nil Reguły działania wartości logicznych w języku Clojure łatwo jest zrozumieć. Oto one:  true ma wartość true, a false — false,  także nil podane w miejscu wartości logicznej ma wartość false,  wszystko inne (oprócz false i nil) podane w miejscu wartości logicznej ma wartość true. Ostrzeżenie dla programistów Lispa — w języku Clojure puste listy nie mają wartości false. ; (tekst dla if) (if () "Jesteśmy w Clojure!" "Jesteśmy w Common Lisp!") -> "Jesteśmy w Clojure!"

(tekst dla else)

52



Programowanie w języku Clojure

Ostrzeżenie dla programistów języka C — w języku Clojure 0 nie ma wartości false. ; (tekst dla if) (if 0 "Zero to true" "Zero to false") -> "Zero to true"

(tekst dla else)

Predykat to funkcja zwracająca wartość true lub false. W języku Clojure typowe jest dodawanie do nazw predykatów znaku zapytania, na przykład true?, false?, nil? lub zero?. (true? expr) (false? expr) (nil? expr) (zero? expr)

Predykat true? sprawdza, czy wartość to true, a nie to, czy wyrażenie ma wartość true w kontekście logicznym. Jedyne, co spełnia ten warunek, to sama wartość true. (true? true) -> true (true? "foo") -> false

Predykaty nil? i false? działają tak samo. Pierwszy jest spełniony tylko dla wartości nil, a drugi — tylko dla wartości false. Predykat zero? działa dla dowolnego typu liczbowego i zwraca otrzyma wartość zero.

true ,

jeśli

(zero? 0.0) -> true (zero? (/ 22 7)) -> false

W języku Clojure występuje także wiele innych predykatów. Aby się z nimi zapoznać, wprowadź instrukcję (find-doc #"\?$") w środowisku REPL.

Odwzorowania, słowa kluczowe i rekordy Odwzorowanie w języku Clojure to kolekcja par klucz-wartość. Literał z odwzorowaniem to lista par umieszczona w nawiasach klamrowych. Za pomocą takiego literału można na przykład utworzyć tablicę wyszukiwań z nazwiskami twórców języków programowania. (def inventors {"Lisp" "McCarthy" "Clojure" "Hickey"}) -> #'user/inventors

Rozdział 2. • Przegląd języka Clojure

Wartość "McCarthy" jest tu wiązana z kluczem z kluczem "Clojure".

"Lisp",

a wartość



53

"Hickey"



Możesz też dodać przecinki, jeśli bardziej czytelna jest dla Ciebie postać z tymi znakami rozdzielającymi pary klucz-wartość. Clojure pomija przecinki, ponieważ traktuje je jak odstępy. (def inventors {"Lisp" "McCarthy", "Clojure" "Hickey"}) -> #'user/inventors

Odwzorowania to funkcje. Jeśli przekażesz klucz do odwzorowania, otrzymasz powiązaną wartość. Jeżeli podany klucz nie występuje w tablicy, otrzymasz wartość nil. (inventors "Lisp") -> "McCarthy" (inventors "Foo") -> nil

Można też zastosować wymagającą więcej kodu funkcję get. (get the-map key not-found-val?)

Funkcja get umożliwia określenie wartości zwracanej dla nieistniejących kluczy. (get inventors "Lisp" "Nie wiem!") -> "McCarthy" (get inventors "Foo" "Nie wiem!") -> "Nie wiem!"

Ponieważ struktury danych w Clojure są niezmienne i mają poprawną implementację funkcji hashCode, kluczem w odwzorowaniu może być dowolna struktura danych tego języka. Bardzo często używa się do tego słów kluczowych języka Clojure. Słowo kluczowe przypomina symbol, jest jednak poprzedzone dwukropkiem (:). Wartością zwracaną dla słowa kluczowego jest ono samo. :foo -> :foo

Inaczej wygląda sytuacja dla symboli, które powinny wskazywać coś innego. foo -> CompilerException java.lang.RuntimeException: Unable to resolve symbol: foo in this context

Ponieważ dla słów kluczowych zwracane są one same, dobrze sprawdzają się one jako klucze. Zmodyfikujmy odwzorowanie inventors, używając kluczy w postaci słów kluczowych. (def inventors {:Lisp "McCarthy" :Clojure "Hickey"}) -> #'user/inventors

54



Programowanie w języku Clojure

Słowa kluczowe to także funkcje. Przyjmują argument w postaci odwzorowania i wyszukują w nim same siebie. W wersji odwzorowania inventors ze słowami kluczowymi można znaleźć twórcę języka, wywołując odwzorowanie lub klucz. (inventors :Clojure) -> "Hickey" (:Clojure inventors) -> "Hickey"

Swoboda w kolejności podawania elementów jest wygodna przy wywoływaniu funkcji wyższego rzędu, na przykład z interfejsów API do obsługi referencji i agentów (rozdział 5., „Stan”). Jeśli kilka odwzorowań ma te same klucze, można to udokumentować przez utworzenie rekordu. Służy do tego instrukcja defrecord. (defrecord name [arguments])

Nazwy argumentów są przekształcane na klucze o wartościach przekazanych w momencie tworzenia rekordu. Użyjmy instrukcji defrecord do utworzenia rekordu Book. (defrecord Book [title author]) -> user.Book

Następnie można utworzyć egzemplarz rekordu za pomocą instrukcji user.Book. (->Book "title" "author")

Utworzony egzemplarz rekordu Book działa jak standardowe odwzorowanie. (def b (->Book "Anathem" "Neal Stephenson")) -> #'user/b b -> #:user.Book{:title "Anathem", :author "Neal Stephenson"} (:title b) -> "Anathem"

Także rekordy można wywoływać na różne sposoby. Jeden z nich to podstawowa składnia, z którą być może się już zetknąłeś. (Book. "Anathem" "Neal Stephenson") -> #user.Book{:title "Anathem", :author "Neal Stephenson"}

Egzemplarz rekordu można też utworzyć z wykorzystaniem literałów. Wymaga to dokładnego wprowadzenia tekstu zwracanego przez środowisko REPL. Jedyna różnica polega na tym, że w literale rekordu należy podawać pełne nazwy. #user.Book{:title "Infinite Jest", :author "David Foster Wallace"} -> #user.Book{:title "Infinite Jest", :author "David Foster Wallace"}

Rozdział 2. • Przegląd języka Clojure



55

Poznałeś już literały liczbowe, listy, wektory, symbole, łańcuchy znaków, znaki, wartości logiczne, rekordy i wartość nil. Pozostałe konstrukcje omawiamy w odpowiednich miejscach w dalszych częściach książki. W tabeli 2.1, „Konstrukcje języka Clojure”, znajdziesz listę wszystkich konstrukcji używanych w książce, krótki przykład zastosowania każdej z nich i numery stron, na których możesz dowiedzieć się czegoś więcej o danych konstrukcjach.

2.2. Makra odczytu Konstrukcje języka Clojure są odczytywane przez czytnik, który przekształca tekst na struktury danych języka. Oprócz podstawowych konstrukcji czytnik języka Clojure rozpoznaje też zestaw makr odczytu4. Makra odczytu to specjalne operacje czytnika uruchamiane w reakcji na wykrycie znaków makr (ang. macro characters). Najbardziej znanym makrem czytnika jest komentarz. Powiązanym z nim znakiem makra jest średnik (;), a specjalna operacja to: „ignoruj wszystko do końca wiersza”. Makra odczytu to skrócone odpowiedniki dłuższych konstrukcji listowych, które pozwalają zwiększyć przejrzystość kodu. Możliwe, że zetknąłeś się już z takimi makrami. Apostrof (') blokuje wartościowanie. '(1 2) -> (1 2)

Kod '(1

2)

to odpowiednik dłuższego zapisu (quote

(1 2)).

(quote (1 2)) -> (1 2)

Inne makra odczytu opisujemy w dalszej części książki. W tabeli na następnej stronie znajdziesz krótki przegląd składni i numery stron, na których opisujemy poszczególne makra odczytu. Clojure nie pozwala na definiowanie w programach nowych makr odczytu. Powód tego wyjaśniono (i przedyskutowano) na liście mailingowej poświęconej temu językowi5. Jeśli wcześniej używałeś Lispa, rozwiązanie to może być irytujące. Podzielamy Twoją frustrację. Jednak dzięki temu ograniczeniu rdzeń języka Clojure jest stabilniejszy. Niestandardowe makra odczytu mogłyby utrudnić współdziałanie programów w języku Clojure i zmniejszyć ich czytelność. 4

5

Makra odczytu są czymś zupełnie innym od zwykłych makr. Te ostatnie omawiamy w rozdziale 7., „Makra”. http://tinyurl.com/clojure-reader-macros

56

Programowanie w języku Clojure



Makro odczytu

Przykłady

Pełne omówienie

Funkcja anonimowa

#(.tuUpperCase %)

Podrozdział 2.3, „Funkcje”

Komentarz

; Komentarz jednowierszowy

Podrozdział 2.2, „Makra odczytu”

Dereferencja

@form => (deref form)

Rozdział 5., „Stan”

Metadane

^metadata form

Podrozdział 2.8, „Metadane”

Przytaczanie

'form => (quote form)

Podrozdział 2.1, „Konstrukcje składniowe”

Wzorzec wyrażenia regularnego

#"foo" => a java.util.regex.Pattern

Punkt „Sekwencje i wyrażenia regularne”

Operacja syntax-quote

`x

Podrozdział 7.3, „Upraszczanie makr”

Operacja unquote

~

Podrozdział 7.3, „Upraszczanie makr”

Operacja unquote-slicing

~@

Podrozdział 7.3, „Upraszczanie makr”

Dosłowne podawanie zmiennych

#'x => (var x)

Rozdział 5., „Stan”

2.3. Funkcje W języku Clojure wywołanie funkcji to lista, której pierwszy element jest funkcją. Na przykład poniższe wywołanie funkcji str scala argumenty w łańcuch znaków. (str "Witaj," " " "świecie") -> "Witaj, świecie"

Wieloczłonowe nazwy funkcji zwykle są łączone dywizami, na przykład clear-agent-errors. Jeśli funkcja jest predykatem, zwyczajowo jej nazwa powinna kończyć się znakiem zapytania. Poniższe predykaty sprawdzają typ podanego argumentu, a ich nazwy kończą się znakiem zapytania. (string? "hello") -> true (keyword? :hello) -> true (symbol? 'hello) -> true

Rozdział 2. • Przegląd języka Clojure



57

Do definiowania własnych funkcji służy instrukcja defn. (defn name doc-string? attr-map? [params*] body)

Funkcja attr-map łączy metadane ze zmienną funkcji. Metadane omawiamy w podrozdziale 2.8, „Metadane”. Aby przedstawić inne elementy definicji funkcji, tworzymy funkcję greeting, która przyjmuje imię i zwraca powitanie ze słowem „Witaj”. src/examples/exploring.clj

(defn greeting "Zwraca powitanie w formie 'Witaj, username.'" [username] (str "Witaj, " username))

Funkcję greeting można wywołać tak: (greeting "świecie") -> "Witaj, świecie"

Można też zapoznać się z dokumentacją funkcji. user=> (doc greeting) ------------------------exploring/greeting ([username]) Zwraca powitanie w formie 'Witaj, username.'

Jak funkcja greeting reaguje na brak argumentu username? (greeting) -> ArityException Wrong number of args (0) passed to: user$greeting clojure.lang.AFn.throwArity (AFn.java:437)

Przy korzystaniu z funkcji języka Clojure trzeba dostosować się do liczby argumentów. Wywołanie funkcji z niewłaściwą liczbą argumentów prowadzi do zgłoszenia wyjątku ArityException. Jeśli chcesz, aby funkcja greeting wyświetlała uniwersalne powitanie, jeśli użytkownik pominął argument username, możesz użyć innej postaci instrukcji defn, przyjmującej kilka list argumentów i ciał metody. (defn name doc-string? attr-map? ([params*] body)+)

W poszczególnych wersjach funkcji można wywoływać inne jej wersje, dlatego można łatwo utworzyć funkcję greeting pozbawioną argumentów, która wywołuje wersję jednoargumentową i przekazuje do niej domyślny argument username. src/examples/exploring.clj

(defn greeting "Zwraca powitanie w formie 'Witaj, username.' Domyślna wartość argumentu username to 'świecie'." ([] (greeting "świecie")) ([username] (str "Witaj, " username)))

58



Programowanie w języku Clojure

Można się upewnić, że nowa wersja funkcji działa zgodnie z oczekiwaniami. (greeting) -> "Witaj, świecie"

Funkcję przyjmującą zmienną liczbę argumentów można utworzyć przez podanie ampersandu na liście parametrów. Clojure powiąże wtedy nazwę występującą po ampersandzie z sekwencją wszystkich pozostałych parametrów. Poniższa funkcja pozwala określić dowolną liczbę przyzwoitek dla dwóch osób wybierających się na randkę. src/examples/exploring.clj

(defn date [person-1 person-2 & chaperones] (println "Na randce: " person-1 " i " person-2 "." "Liczba opiekunów: " (count chaperones) ".")) (date "Romeo" "Julia" "Ojciec Laurenty" "Niania") | Na randce: Romeo i Julia. Liczba opiekunów: 2.

Zmienna liczba argumentów przydaje się w definicjach rekurencyjnych. Przykłady znajdziesz w rozdziale 4., „Programowanie funkcyjne”. Wersje funkcji przyjmujące różną liczbę argumentów są przydatne. Jeśli jednak używałeś wcześniej języków obiektowych, chcesz korzystać z polimorfizmu, czyli z wybierania wersji funkcji na podstawie typu. Clojure umożliwia uzyskanie tego i wielu innych efektów. Szczegółowe informacje znajdziesz w rozdziale 8., „Wielometody”, i w rozdziale 6., „Protokoły i typy danych”. Instrukcja defn służy do definiowania funkcji na najogólniejszym poziomie hierarchii. Jeśli chcesz tworzyć funkcje w innych funkcjach, powinieneś zastosować funkcje anonimowe.

Funkcje anonimowe Oprócz tworzenia nazwanych funkcji (służy do tego instrukcja defn), można pisać także funkcje anonimowe (za pomocą instrukcji fn). Istnieją przynajmniej trzy powody, dla których warto tworzyć funkcje anonimowe. Oto one:  Funkcja jest tak krótka i oczywista, że nadawanie jej nazwy zmniejsza czytelność kodu, zamiast ją powiększać.  Funkcja jest używana tylko w innej funkcji i wymaga nazwy lokalnej zamiast wiązania na najogólniejszym poziomie.  Funkcja służy do wyodrębnienia przetwarzania pewnych danych w innej funkcji.

Rozdział 2. • Przegląd języka Clojure



59

Funkcje filtrujące są często krótkie i oczywiste. Wyobraź sobie na przykład, że chcesz utworzyć indeks dla sekwencji słów, ale nie interesują Cię wyrazy mające mniej niż trzy znaki. Zadanie to można wykonać za pomocą funkcji indexable-word? o pokazanym poniżej kodzie. src/examples/exploring.clj

(defn indexable-word? [word] (> (count word) 2))

Następnie można wykorzystać funkcję indexable-word? do wyodrębnienia ze zdania słów, które powinny znaleźć się w indeksie. (require '[clojure.string :as str]) (filter indexable-word? (str/split "To piękny dzień" #"\W+")) -> ("piękny" "dzień")

Funkcja split dzieli zdanie na słowa, a późniejsze wywołanie filter powoduje uruchomienie funkcji indexable-word? dla każdego wyrazu. Zwracane są te słowa, dla których funkcja indexable-word? zwraca wartość true. Funkcje anonimowe umożliwiają osiągnięcie tego samego efektu w jednym wierszu. Oto najprostsza konstrukcja funkcji anonimowej tworzonej za pomocą instrukcji fn. (fn [params*] body)

Ta konstrukcja pozwala połączyć kod funkcji z wywołaniem filter.

indexable-word?

bezpośrednio

(filter (fn [w] (> (count w) 2)) (str/split "To piękny dzień" #"\W+")) -> ("piękny" "dzień")

Istnieje jeszcze krótsza składnia do tworzenia funkcji anonimowej — można zastosować domyślne nazwy parametrów, czyli %1, %2 itd. Dla pierwszego parametru można też użyć samego znaku %. Składnia ta wygląda tak: #(body)

Można zmodyfikować wywołanie funkcji filter, wykorzystując krótszą konstrukcję anonimową. (filter #(> (count %) 2) (str/split "To piękny dzień" #"\W+")) -> ("piękny" "dzień")

Drugim powodem pisania funkcji anonimowych jest potrzeba utworzenia funkcji nazwanej, ale dostępnej tylko w innej funkcji. Funkcję indexable-word? można zapisać także w takiej postaci.

60



Programowanie w języku Clojure

src/examples/exploring.clj

(defn indexable-words [text] (let [indexable-word? (fn [w] (> (count w) 2))] (filter indexable-word? (str/split text #"\W+"))))

Funkcja let wiąże nazwę indexable-word? z napisaną wcześniej funkcją anonimową. Tym razem funkcja ta jest dostępna w zasięgu leksykalnym funkcji indexable-words. Funkcję let omawiamy dokładniej w podrozdziale 2.4, „Zmienne, wiązania i przestrzenie nazw”. Teraz można sprawdzić, czy funkcja indexable-words działa w oczekiwany sposób. (indexable-words "To piękny dzień") -> ("piękny" "dzień")

Zastosowanie funkcji let i funkcji anonimowej to dla osób czytających kod informacja: „funkcja indexable-word? jest wystarczająco ważna, aby nadać jej nazwę, ale potrzebujemy jej tylko w funkcji indexable-words”. Trzeci powód stosowania funkcji anonimowych związany jest z dynamicznym tworzeniem funkcji w czasie wykonywania programu. Wcześniej zaimplementowaliśmy prostą funkcję wyświetlającą powitanie. Na jej podstawie można utworzyć funkcję make-greeter, która tworzy funkcje generujące powitania. Funkcja make-greeter przyjmuje argument greeting-prefix (jest to początek powitania) i zwraca nową funkcję, która tworzy powitania na podstawie argumentu greeting-prefix i imienia. src/examples/exploring.clj

(defn make-greeter [greeting-prefix] (fn [username] (str greeting-prefix ", " username)))

Nie ma sensu nadawać nazwy funkcji tworzonej za pomocą instrukcji fn, ponieważ funkcja ta tworzy inną funkcję przy każdym wywołaniu instrukcji makegreeter. Do nazywania funkcji tworzonych przez instrukcję make-greeter można użyć polecenia def. (def hello-greeting (make-greeter "Witaj")) -> #'user/hello-greeting (def aloha-greeting (make-greeter "Hej")) -> #'user/aloha-greeting

Teraz wygenerowane funkcje można wywołać w standardowy sposób. (hello-greeting "świecie") -> "Witaj, świecie" (aloha-greeting "świecie") -> "Hej, świecie"

Rozdział 2. • Przegląd języka Clojure



61

Nie trzeba nadawać nazwy każdej wygenerowanej funkcji. Można po prostu utworzyć taką funkcję i umieścić ją na pierwszej (przeznaczonej na funkcje) pozycji konstrukcji. ((make-greeter "Jak leci") "stary") -> "Jak leci, stary"

Jak widać, funkcje wyświetlające powitania zapamiętują wartość argumentu greeting-prefix określoną w momencie ich tworzenia. Ujmijmy to formalnie — funkcje wyświetlające powitanie są domknięciem dla wartości argumentu greeting-prefix. Kiedy warto stosować funkcje anonimowe?

Funkcje anonimowe mają zwięzłą składnię, która nie zawsze jest odpowiednia. Możliwe, że preferujesz bardziej jednoznaczne podejście i wolisz tworzyć funkcje nazwane w rodzaju indexable-word?. Jest to całkowicie poprawne. Z pewnością jest to dobre rozwiązanie, jeśli funkcję indexable-word? trzeba wywoływać w więcej niż jednym miejscu. Funkcje anonimowe to opcjonalny element języka. Stosuj je tylko wtedy, kiedy poprawia to czytelność kodu. Przyzwyczajenie się do takich funkcji wymaga czasu, dlatego nie zdziw się, jeśli w przyszłości będziesz stosował je coraz częściej.

2.4. Zmienne, wiązania i przestrzenie nazw Obiekt zdefiniowany za pomocą instrukcji def lub defn jest zapisywany w zmiennej języka Clojure. Poniższa instrukcja def powoduje utworzenie zmiennej o nazwie user/foo. (def foo 10) -> #'user/foo

Symbol user/foo oznacza zmienną powiązaną z wartością 10. Jeśli zażądasz od języka Clojure podania wartości symbolu foo, otrzymasz wartość powiązanej zmiennej. foo -> 10

Początkowa wartość zmiennej to wiązanie podstawowe (ang. root binding). Czasem przydatne jest tworzenie wiązań lokalnych, obowiązujących tylko w danych wątkach. Technikę tę omawiamy w podrozdziale 5.5, „Zarządzanie stanem wątków za pomocą zmiennych”.

62

Programowanie w języku Clojure



Zmienne można wskazywać bezpośrednio. Specjalna konstrukcja samą zmienną, a nie jej wartość.

var

zwraca

(var symbol)

Instrukcji user/foo.

var

można użyć do zwrócenia zmiennej powiązanej z symbolem

(var foo) -> #'user/foo

Konstrukcja var w przedstawionej tu postaci prawie nigdy nie występuje w kodzie Clojure. Zamiast tego używa się analogicznego makra odczytu #', które także zwraca zmienną powiązaną z symbolem. #'foo -> #'user/foo

Do czego może służyć bezpośrednie wskazywanie zmiennej? Zwykle do niczego. Dlatego często można pominąć rozróżnienie na symbole i zmienne. Warto jednak pamiętać, że zmienne służą nie tylko do przechowywania wartości. Oto inne cechy zmiennych:  Dla jednej zmiennej można utworzyć różne aliasy w wielu przestrzeniach nazw (zobacz punkt „Przestrzenie nazw”). Pozwala to na stosowanie wygodnych krótkich nazw.  Zmienne mogą obejmować metadane (zobacz podrozdział 2.8, „Metadane”). Metadane zmiennych to między innymi dokumentacja (zobacz punkt „Wyszukiwanie dokumentacji”), sugerowane typy przydatne do optymalizacji i testy jednostkowe.  Zmienne można dynamicznie ponownie powiązać w poszczególnych wątkach (zobacz podrozdział 5.5, „Zarządzanie stanem wątków za pomocą zmiennych”).

Wiązania Zmienne są wiązane z nazwami, jednak istnieją też wiązania innego rodzaju. Na przykład w wywołaniu funkcji wartości argumentów są wiązane z nazwami parametrów. W poniższym wywołaniu funkcji triple wartość 10 jest wiązana z nazwą number. (defn triple [number] (* 3 number)) -> #'user/triple (triple 10) -> 30

Rozdział 2. • Przegląd języka Clojure



63

Wiązania parametrów funkcji mają określony zasięg leksykalny — obowiązują tylko w ciele danej funkcji. Wiązania leksykalne można tworzyć nie tylko w funkcjach. Specjalna konstrukcja let służy właśnie do tworzenia zbioru wiązań leksykalnych. (let [bindings*] exprs*)

Wiązania bindings obowiązują dla wyrażeń exprs, a wartością funkcji wartość ostatniego wyrażenia ze zbioru exprs.

let

jest

Załóżmy, że interesują nas współrzędne czterech rogów kwadratu. Znamy współrzędne dolnego boku (bottom), lewego boku (left) i wielkość kwadratu (size). Na podstawie tych danych można za pomocą funkcji let określić współrzędne górnego (top) i prawego (right) boku. src/examples/exploring.clj

(defn square-corners [bottom left size] (let [top (+ bottom size) right (+ left size)] [[bottom left] [top left] [top right] [bottom right]]))

Funkcja let wiąże nazwy top i right. Dzięki temu nie trzeba ponownie obliczać współrzędnych boków top i right (obie są potrzebne dwukrotnie w celu wygenerowania zwracanej wartości). Następnie funkcja let zwraca ostatnią konstrukcję, którą tu jest wartość zwracana przez funkcję square-corners.

Rozkładanie struktury W wielu językach programowania zmienną wiąże się z całą kolekcją, nawet jeśli potrzebny jest dostęp tylko do części danej kolekcji. Załóżmy, że używamy bazy autorów książek. Obejmuje ona imiona i nazwiska, jednak niektóre funkcje potrzebują tylko imion. src/examples/exploring.clj

(defn greet-author-1 [author] (println "Witaj, " (:first-name author)))

Funkcja greet-author-1 działa prawidłowo. (greet-author-1 {:last-name "Vinge" :first-name "Vernor"}) | Witaj, Vernor

Konieczność wiązania całego elementu author jest niepożądana. Nie potrzebujemy całego elementu, a tylko imienia (first-name). W Clojure problem ten rozwiązano przez mechanizm rozkładania struktury (ang. destructuring). W dowolnym miejscu, w którym wiązane są imiona i nazwiska, można zagnieździć wektor

64



Programowanie w języku Clojure

lub odwzorowanie w celu uzyskania dostępu do kolekcji i powiązać tylko potrzebną część. Oto wersja funkcji greet-author, w której wiązane jest tylko imię. src/examples/exploring.clj

(defn greet-author-2 [{fname :first-name}] (println "Witaj, " fname))

Konstrukcja wiązania {fname :first-name} określa, że Clojure ma powiązać fname z elementem :first-name z argumentu funkcji. Funkcja greet-author-2 działa tak samo jak funkcja greet-author-1. (greet-author-2 {:last-name "Vinge" :first-name "Vernor"}) | Witaj, Vernor

Podobnie jak można użyć odwzorowania do rozłożenia dowolnej asocjacyjnej kolekcji, tak można zastosować wektor do rozłożenia kolekcji sekwencyjnej. Można na przykład powiązać tylko dwie pierwsze współrzędne w trójwymiarowym układzie współrzędnych. (let [[x y] [1 2 3]] [x y]) -> [1 2]

Wyrażenie [x y] rozkłada wektor [1 2 3] i powoduje powiązanie x z 1 i y z 2. Ponieważ nie podano żadnego symbolu dla ostatniego elementu (3), nie zostaje on powiązany. Czasem programista chce pominąć elementy z początku kolekcji. Poniżej pokazujemy, jak powiązać tylko współrzędną z. (let [[_ _ z] [1 2 3]] z) -> 3

Podkreślenie (_) to dozwolony symbol, który standardowo informuje, że dane wiązanie nie ma znaczenia. Wiązanie odbywa się od lewej do prawej, dlatego symbol _ jest wiązany dwukrotnie. ; *Nie* jest to standardowy kod! (let [[_ _ z] [1 2 3]] _) -> 2

Można też jednocześnie powiązać samą kolekcję i zapisane w niej elementy. W wyrażeniu rozkładającym strukturę klauzula :as służy do wiązania całej struktury. Można na przykład określić współrzędne x i y, a także całą kolekcję coords, aby określić łączną liczbę wymiarów. (let [[x y :as coords] [1 2 3 4 5 6]] (str "x: " x ", y: " y ", liczba wymiarów: " (count coords))) -> "x: 1, y: 2, liczba wymiarów: 6"

Rozdział 2. • Przegląd języka Clojure



65

Spróbujmy zastosować rozkładanie struktury w funkcji ellipsize. Ma ona przyjmować łańcuch znaków i zwracać pierwsze trzy słowa, po których następuje wielokropek (…). src/examples/exploring.clj

(require '[clojure.string :as str]) (defn ellipsize [words] (let [[w1 w2 w3] (str/split words #"\s+")] (str/join " " [w1 w2 w3 "..."]))) (ellipsize "Król Karol kupił królowej Karolinie korale koloru koralowego.") -> "Król Karol kupił ..."

Funkcja split dzieli łańcuch znaków w miejscach wystąpień odstępów, a następnie konstrukcja rozkładania struktury ([w1 w2 w3]) pobiera trzy pierwsze słowa. Pozostałe wyrazy są przy tym pomijane (tak właśnie ma działać program). W ostatnim kroku funkcja join ponownie łączy trzy słowa i dodaje po nich wielokropek. Rozkładanie struktur ma też kilka innych właściwości, których nie omawiamy w tym miejscu. Mechanizm ten sam w sobie jest prostym językiem. W grze Snake (podrozdział 5.6, „Gra Snake w języku Clojure”) wykorzystujemy w dużym stopniu rozkładanie struktur. Kompletną listę możliwości związanych z tym mechanizmem znajdziesz w internetowej dokumentacji funkcji let6.

Przestrzenie nazw Wiązania podstawowe znajdują się w przestrzeni nazw. Można się o tym przekonać przez uruchomienie środowiska REPL języka Clojure i utworzenie wiązania. user=> (def foo 10) -> #'user/foo

Wiersz zachęty user=> informuje, że użytkownik obecnie pracuje w przestrzeni nazw user7. Można ją traktować jako pomocniczą przestrzeń nazw do eksperymentowania.

6 7

http://clojure.org/special_forms W książce w większości listingów sesji środowiska REPL pomijamy wiersz zachęty (z uwagi na zwięzłość). W tym punkcie wiersz ten przytaczamy wszędzie tam, gdzie przestrzeń nazw ma znaczenie.

66



Programowanie w języku Clojure

Kiedy Clojure określa nazwę foo, uwzględnia jej kwalifikator w postaci bieżącej przestrzeni nazw user. Można się o tym przekonać przez wywołanie funkcji resolve. (resolve sym)

Funkcja resolve zwraca zmienną lub klasę, której odpowiada dany symbol w bieżącej przestrzeni nazw. Użyjmy funkcji resolve do bezpośredniego określenia symbolu foo. (resolve 'foo) -> #'user/foo

Za pomocą instrukcji in-ns można zmienić przestrzeń nazw lub w razie potrzeby utworzyć nową. (in-ns name)

Utwórzmy przestrzeń nazw myapp. user=> (in-ns 'myapp) -> # myapp=>

Teraz pracujemy w przestrzeni nazw myapp. Wszystkie elementy zdefiniowane za pomocą instrukcji def lub defn będą znajdowały się właśnie w tej przestrzeni. Po utworzeniu nowej przestrzeni nazw za pomocą instrukcji in-ns automatycznie dostępny staje się pakiet java.lang. myapp=> String -> java.lang.String

W czasie nauki języka Clojure należy wywoływać instrukcję use dla przestrzeni nazw clojure.core przy każdym przejściu do nowej przestrzeni. Dzięki temu podstawowe funkcje języka Clojure będą dostępne także w nowej przestrzeni nazw. myapp=> (clojure.core/use 'clojure.core) -> nil

Nazwy klas spoza pakietu java.lang trzeba podawać w pełnej postaci. Nie wystarczy na przykład wywołać instrukcji File. myapp=> File/separator -> java.lang.Exception: No such namespace: File

Zamiast tego trzeba podać pełną nazwę można ustawić także inny separator. myapp=> java.io.File/separator -> "/"

java.io.File.

Warto zauważyć, że

Rozdział 2. • Przegląd języka Clojure



67

Jeśli nie chcesz podawać pełnych nazw klas, możesz odwzorować klasy z pakietu Javy na bieżącą przestrzeń nazw. Służy do tego instrukcja import. (import '(package Class+))

Po zaimportowaniu klasy można stosować jej skróconą nazwę. (import '(java.io InputStream File)) -> java.io.File (.exists (File. "/tmp")) -> true

Instrukcja import działa tylko dla klas Javy. Jeśli chcesz stosować zmienną języka Clojure w innej przestrzeni nazw, musisz podać pełną nazwę lub odwzorować daną nazwę na bieżącą przestrzeń nazw. Dotyczy to na przykład funkcji split języka Clojure, znajdującej się w pakiecie clojure.string. (require 'clojure.string) (clojure.string/split "Something,separated,by,commas" #",") -> ["Something" "separated" "by" "commas"] (split "Something,separated,by,commas" #",") -> Unable to resolve symbol: split in this context

Aby w bieżącej przestrzeni nazw utworzyć alias funkcji split, należy wywołać instrukcję require dla przestrzeni nazw zawierającej tę funkcję i określić alias dla tej przestrzeni (tu jest nim str). (require '[clojure.string :as str]) (str/split "Something,separated,by,commas" #",") -> ["Something" "separated" "by" "commas"]

Prosta, przedstawiona wcześniej konstrukcja z instrukcją require powoduje, że bieżąca przestrzeń nazw obejmuje wszystkie publiczne zmienne z przestrzeni nazw clojure.string i zapewnia dostęp do nich poprzez alias str. Może to być mylące, ponieważ nazwy nie są bezpośrednio określone. Zwyczajowo instrukcję import dla klas Javy i instrukcję require dla przestrzeni nazw umieszcza się na samym początku pliku z kodem źródłowym. Służy do tego makro ns. (ns name & references)

Makro ns ustawia bieżącą przestrzeń nazw (dostępną jako *ns*) na nazwę name i w razie potrzeby tworzy podaną przestrzeń nazw. W miejsce argumentu references można podać instrukcje :import, :require i :use, które działają jak podobnie nazwane funkcje. Pozwala to ustawić odwzorowania dla przestrzeni nazw w jednej instrukcji na początku pliku źródłowego. Na początku przykładowego kodu z tego rozdziału znajduje się poniższe wywołanie makra ns.

68



Programowanie w języku Clojure

src/examples/exploring.clj

(ns examples.exploring (:require [clojure.string :as str]) (:import (java.io File)))

Funkcje języka Clojure przeznaczone do zarządzania przestrzeniami nazw potrafią wykonywać także inne zadania. Można na przykład za pomocą mechanizmu refleksji poruszać się po przestrzeniach nazw i dodawać lub usuwać odwzorowania. Aby dowiedzieć się więcej, wywołaj poniższą instrukcję w środowisku REPL. Ponieważ zmieniliśmy przestrzeń nazw, należy wrócić do przestrzeni nazw użytkownika, w której dostępne są narzędzia środowiska. (in-ns 'user) (find-doc "ns-")

Inna możliwość to zapoznanie się z dokumentacją ze strony http://clojure.org/ namespaces.

2.5. Wywoływanie kodu Javy Język Clojure zapewnia prostą składnię do wywoływania kodu Javy. Pozwala to tworzyć obiekty, wywoływać metody oraz uzyskiwać dostęp do metod i pól statycznych. Ponadto w Clojure można korzystać ze składniowych dodatków, które pozwalają wywoływać kod Javy w sposób bardziej zwięzły niż w samej Javie! Nie wszystkie typy w Javie są sobie równe. Typy proste działają inaczej niż tablice. Specjalne elementy Javy są bezpośrednio dostępne także w języku Clojure. Ponadto Clojure zapewnia zestaw funkcji pomocniczych służących do obsługi popularnych, a niewygodnych do wykonania w Javie zadań.

Dostęp do konstruktorów, metod i pól Pierwszy krok w wielu scenariuszach współdziałania z kodem Javy to utworzenie obiektu Javy. W Clojure służy do tego specjalna konstrukcja new. (new classname)

Spróbujmy utworzyć nowy obiekt klasy Random. (new java.util.Random) ->

Rozdział 2. • Przegląd języka Clojure



69

Środowisko REPL wyświetla nowy egzemplarz klasy Random przez wywołanie jej metody toString(). Aby użyć egzemplarza tej klasy, należy go zapisać. Tu używamy instrukcji def do zapisania egzemplarza klasy Random w zmiennej rnd języka Clojure. (def rnd (new java.util.Random)) -> #'user/rnd

Teraz można wywoływać metody zmiennej rnd, używając specjalnej konstrukcji języka Clojure — kropki (.). (. class-or-instance member-symbol & args) (. class-or-instance (member-symbol & args))

Kropka (.) pozwala wywoływać metody. W poniższym kodzie wywołujemy na przykład bezargumentową wersję metody nextInt(). (. rnd nextInt) -> -791474443

Klasa Random udostępnia też metodę nextInt() przyjmującą argument. Tę drugą wersję można wywołać przez dodanie argumentu do listy. (. rnd nextInt 10) -> 8

W poprzednim wywołaniu konstrukcja . posłużyła do uzyskania dostępu do metody egzemplarza. Kropka działa jednak dla wszystkich składowych klasy — pól i metod zarówno statycznych, jak i egzemplarza. Poniżej używamy kropki do pobrania wartości pi. (. Math PI) -> 3.141592653589793

Zauważ, że nazwa Math nie jest pełna. Jest to dopuszczalne, ponieważ Clojure automatycznie importuje pakiet java.lang. Aby uniknąć powielania wszędzie członu java.util.Random, można bezpośrednio zaimportować tę klasę (instrukcja import). (import [& import-lists]) ; lista-importu => (symbol-pakietu & symbole-nazw-klas)

Instrukcja import przyjmuje dowolną liczbę list. Pierwsza część każdej listy to nazwa pakietu, a pozostałe elementy to nazwy importowane z danego pakietu. Poniższa instrukcja import sprawia, że dostęp do klas Random, Locale i MessageFormat można uzyskać bez kwalifikatora. (import '(java.util Random Locale) '(java.text MessageFormat)) -> java.text.MessageFormat Random -> java.util.Random

70



Programowanie w języku Clojure

Locale -> java.util.Locale MessageFormat -> java.text.MessageFormat

Wiesz już prawie wszystko, co jest potrzebne do wywoływania kodu Javy w Clojure. Potrafisz:  importować nazwy klas,  tworzyć egzemplarze klas,  uzyskać dostęp do pól,  wywoływać metody. Składnia do wykonywania tych operacji nie jest niczym niezwykłym — to po prostu „Java z innymi nawiasami”.

Javadoc Choć dostęp do Javy w języku Clojure jest prosty, pamiętanie o działaniu różnych mechanizmów Javy może być kłopotliwe. Clojure udostępnia funkcję javadoc, która znacznie ułatwia pracę. Pozwala ona łatwo wyszukiwać informacje w środowisku REPL. (javadoc java.net.URL) ->

2.6. Przepływ sterowania W języku Clojure istnieje bardzo mało konstrukcji przeznaczonych do sterowania przepływem. W tym podrozdziale poznasz instrukcje if, do i loop/recur. Okazuje się, że pozwalają one wykonać prawie wszystkie potrzebne zadania.

Rozgałęzienia i instrukcja if Instrukcja if w Clojure sprawdza wartość pierwszego argumentu. Jeśli ma on wartość logiczną true, instrukcja zwraca wynik przetworzenia drugiego argumentu. src/examples/exploring.clj

(defn is-small? [number] (if (< number 100) "tak")) (is-small? 50) -> "tak"

Rozdział 2. • Przegląd języka Clojure



Jeżeli wartość logiczna pierwszego argumentu instrukcji jest wartość nil.

if

to

false,

71

zwracana

(is-small? 50000) -> nil

Jeżeli chcesz określić wartość dla części argument.

else

instrukcji

if,

podaj ją jako trzeci

src/examples/exploring.clj

(defn is-small? [number] (if (< number 100) "tak" "nie")) (is-small? 50000) -> "nie"

Makra sterowania przepływem when i when-not są oparte na instrukcji sujemy je w punkcie „When i when-not”).

if.

Opi-

Efekty uboczne i instrukcja do Instrukcja if języka Clojure umożliwia podanie w każdej gałęzi tylko jednej konstrukcji. Co zrobić, jeśli chcesz wykonać w gałęzi więcej niż jedną operację? Możesz na przykład chcieć zapisać, że wybrano określoną gałąź. Instrukcja do przyjmuje dowolną liczbę konstrukcji, przetwarza każdą z nich i zwraca wartość ostatniej. Instrukcję do można wykorzystać do zapisania komunikatu w ramach instrukcji if. src/examples/exploring.clj

(defn is-small? [number] (if (< number 100) "tak" (do (println "Wykryto dużą liczbę" number) "nie")))

Poniżej przedstawiamy działanie tego kodu. (is-small? 200) | Wykryto dużą liczbę 200 -> "nie"

Jest to przykład stosowania efektów ubocznych. Funkcja println nie jest w żaden sposób powiązana z obliczaniem wartości zwracanej przez funkcję is-small?. Wykracza natomiast poza funkcję i wykonuje pewne operacje.

72



Programowanie w języku Clojure

W wielu językach programowania czyste funkcje i funkcje z efektami ubocznymi mieszają się ze sobą zupełnie przypadkowo. W Clojure jest inaczej. Tu efekty uboczne są czymś niezwykłym i stosuje się je w określony sposób. Instrukcja do jest jednym ze sposobów na wprowadzenie efektów ubocznych. Ponieważ pomija ona wartości zwracane przez wszystkie konstrukcje oprócz ostatniej, konstrukcje te muszą powodować efekty uboczne, aby były do czegokolwiek przydatne.

Rekurencja i instrukcja loop/recur W obszarze przepływu sterowania instrukcja loop jest jak szwajcarski scyzoryk. (loop [bindings *] exprs*)

Specjalna konstrukcja loop działa jak instrukcja let. Tworzy wiązania (bindings), a następnie określa wartość wyrażeń (exprs). Różnica polega na tym, że w instrukcji loop można ustawić punkt rekurencji, a następnie przejść do niego za pomocą specjalnej konstrukcji recur. (recur exprs*)

Instrukcja recur wiąże nowe wartości wiązań z instrukcji loop i zwraca sterowanie na początek pętli. Poniższa instrukcja loop/recur zwraca wartości uzyskane w wyniku odliczania. src/examples/exploring.clj

(loop [result [] x 5] (if (zero? x) result (recur (conj result x) (dec x)))) -> [5 4 3 2 1]

Przy pierwszym przebiegu instrukcja loop wiąże nazwę result z pustym wektorem, a x z liczbą 5. Ponieważ x jest różne od zera, instrukcja recur ponownie wiąże nazwy x i result.  Nazwa result jest wiązana z wcześniejszą wartością result połączoną za pomocą instrukcji conj z poprzednią wartością x.  Nazwa x jest wiązana ze zmniejszoną (instrukcja dec ) poprzednią wartością x. Następnie sterowanie wraca na początek pętli. Ponieważ x wciąż nie jest zerem, pętla działa dalej. Kod zapisuje wartość result i zmniejsza x. Ostatecznie x przyjmuje wartość zero, a instrukcja if kończy rekurencję i zwraca result.

Rozdział 2. • Przegląd języka Clojure



73

Zamiast używać instrukcji loop, można za pomocą instrukcji recur wracać na początek funkcji. Pozwala to w łatwo napisać funkcję, której całe ciało działa jak instrukcja loop. src/examples/exploring.clj

(defn countdown [result x] (if (zero? x) result (recur (conj result x) (dec x)))) (countdown [] 5) -> [5 4 3 2 1]

Instrukcja recur daje bardzo duże możliwości. Możliwe jednak, że nie będziesz zbyt często z niej korzystał, ponieważ wiele standardowych operacji rekurencyjnych jest dostępnych w bibliotece sekwencji języka Clojure. Na przykład odliczanie można zapisać także na kilka innych sposobów. (into [] (take 5 (iterate dec 5))) -> [5 4 3 2 1] (into [] (drop-last (reverse (range 6)))) -> [5 4 3 2 1] (vec (reverse (rest (range 6)))) -> [5 4 3 2 1]

Na razie nie musisz rozumieć tych konstrukcji. Zapamiętaj tylko, że często można stosować je zamiast instrukcji recur. Zastosowane tu funkcje z biblioteki sekwencji omawiamy w podrozdziale 3.2, „Korzystanie z biblioteki sekwencji”. Clojure nie przeprowadza automatycznie optymalizacji TCO (ang. tail-call optimization), natomiast optymalizuje wywołania instrukcji recur. W rozdziale 4., „Programowanie funkcyjne”, szczegółowo opisujemy optymalizację TCO i rekurencję. Poznałeś już sporo mechanizmów języka, ale nadal nie omówiliśmy zmiennych. Niektóre wartości się zmieniają, a w rozdziale 5., „Stan”, dowiesz się, jak w języku Clojure obsługiwane są zmienne referencje. Jednak zmienne spotykane w tradycyjnych językach są często zbędne, a nawet niebezpieczne. Zobaczmy, w jaki sposób udało się ich uniknąć w Clojure.

74



Programowanie w języku Clojure

2.7. Gdzie się podziała pętla for? W języku Clojure nie ma pętli for ani bezpośrednio modyfikowalnych zmiennych8. Jak więc należy pisać standardowy kod pętli for, do którego programiści są przyzwyczajeni? Zamiast tworzyć fikcyjny przykład, wybraliśmy losowo otwarty kod źródłowy Javy, znaleźliśmy metodę z pętlami for i zmiennymi, a następnie przekształciliśmy ją na wersję w języku Clojure. Kod pochodzi z powszechnie stosowanego projektu Apache Commons. Wybraliśmy klasę StringUtils z pakietu Commons Lang, ponieważ przyjęliśmy, że każdy zrozumie jej kod. Następnie poszukaliśmy metod z dużą liczbę pętli for i zmiennych lokalnych. W ten sposób znaleźliśmy metodę indexOfAny. data/snippets/StringUtils.java

// Z pakietu Apache Commons Lang, http://commons.apache.org/lang/ public static int indexOfAny(String str, char[] searchChars) { if (isEmpty(str) || ArrayUtils.isEmpty(searchChars)) { return -1; } for (int i = 0; i < str.length(); i++) { char ch = str.charAt(i); for (int j = 0; j < searchChars.length; j++) { if (searchChars[j] == ch) { return i; } } } return -1; }

Metoda indexOfAny przechodzi po łańcuchu str i podaje indeks pierwszego znaku, który pasuje do dowolnego znaku z tablicy searchChars. Jeśli nie istnieją pasujące znaki, metoda zwraca -1. Oto przykładowe wyniki opisane w dokumentacji metody indexOfAny. StringUtils.indexOfAny(null, *) = -1 StringUtils.indexOfAny("", *) = -1 StringUtils.indexOfAny(*, null) = -1 StringUtils.indexOfAny(*, []) = -1 StringUtils.indexOfAny("zzabyycdxx",['z','a']) = 0 StringUtils.indexOfAny("zzabyycdxx",['b','y']) = 3 StringUtils.indexOfAny("aba", ['z']) = -1 8

Clojure udostępnia pośrednio modyfikowalne referencje, przy czym ich zmiany trzeba jednoznacznie określić w kodzie. Szczegółowy opis tego zagadnienia znajdziesz w rozdziale 5., „Stan”.

Rozdział 2. • Przegląd języka Clojure



75

W metodzie indexOfAny występują dwie instrukcje if, dwie pętle for, trzy miejsca zwracania wartości i trzy modyfikowalne zmienne lokalne. Metoda ma czternaście wierszy kodu. Policzyło to wszystko narzędzie SLOCCount9 Davida A. Wheelera. Utwórzmy teraz krok po kroku funkcję index-of-any w języku Clojure. Aby stwierdzić, czy łańcuch znaków obejmuje pasujący znak, wystarczyłoby użyć funkcji filter. Interesuje nas jednak indeks pasującego znaku. Tworzymy więc funkcję indexed, która przyjmuje kolekcję, a zwraca kolekcję indeksowaną. src/examples/exploring.clj

(defn indexed [coll] (map-indexed vector coll))

Funkcja indexed zwraca sekwencję par w postaci określić indeksy dla łańcucha znaków.

[indeks element].

Spróbujmy

(indexed "abcde") -> ([0 \a] [1 \b] [2 \c] [3 \d] [4 \e])

Następnie chcemy ustalić indeksy wszystkich znaków łańcucha pasujących do szukanych znaków. W tym celu tworzymy funkcję index-filter. Przypomina ona funkcję języka Clojure, ale zwraca indeksy zamiast informacji o dopasowaniu.

filter

src/examples/exploring.clj

(defn index-filter [pred coll] (when pred (for [[idx elt] (indexed coll) :when (pred elt)] idx)))

Instrukcja for w języku Clojure nie jest pętlą, a wyrażeniem sekwencyjnym (zobacz punkt „Przekształcanie sekwencji”). Pary indeks-element kolekcji (indexed coll) są wiązane z nazwami idx i elt, ale tylko wtedy, jeśli wyrażenie (pred elt) ma wartość true. W ostatnim kroku wyrażenie sekwencyjne zwraca wartość idx dla każdego dopasowanego znaku. Zbiór w Clojure to funkcja sprawdzająca, czy element do niego należy. Można więc przekazać do funkcji index-filter zbiór znaków i łańcuch, a następnie uzyskać indeksy wszystkich znaków łańcucha należących do zbioru. Wypróbujmy funkcję na innych łańcuchach i zbiorach znaków. (index-filter #{\a \b} "abcdbbb") -> (0 1 4 5 6) (index-filter #{\a \b} "xyz") -> ()

9

http://www.dwheeler.com/sloccount/

76



Programowanie w języku Clojure

Na tym etapie osiągnęliśmy więcej, niż planowaliśmy. Funkcja index-filter zwraca indeksy wszystkich pasujących elementów, a potrzebujemy tylko indeksu pierwszego znaku. Dlatego w funkcji index-of-any należy pobrać pierwszy wynik (instrukcja first) z funkcji index-filter. src/examples/exploring.clj

(defn index-of-any [pred coll] (first (index-filter pred coll)))

Sprawdźmy na różnych danych, czy funkcja index-of-any działa prawidłowo. (index-of-any #{\z \a} "zzabyycdxx") -> 0 (index-of-any #{\b \y} "zzabyycdxx") -> 3

Wersja w języku Clojure jest pod każdym względem prostsza od wersji imperatywnej (zobacz tabelę 2.2, „Względna złożoność imperatywnej i funkcyjnej wersji metody indexOfAny”). Z czego wynika ta różnica?  W imperatywnej wersji metody indexOfAny trzeba obsługiwać specjalne przypadki — łańcuchy oraz zbiory szukanych znaków o wartości null i puste, a także brak dopasowania. Wymaga to dodawania odgałęzień i punktów wyjścia z metody. W podejściu funkcyjnym zwykle nie trzeba pisać dodatkowego kodu do obsługi specjalnych przypadków.  W wersji imperatywnej znajdują się zmienne lokalne potrzebne do poruszania się po kolekcjach (zarówno po łańcuchu znaków, jak i po zbiorze znaków). W wersji funkcyjnej (w funkcji index-of-any) zastosowano funkcje wyższego rzędu, na przykład map, i wyrażenie sekwencyjne, na przykład for, dlatego zmienne nie są potrzebne. Zbędna złożoność zwykle szybko narasta. W imperatywnej metodzie indexOfAny w gałęziach do obsługi specjalnych przypadków używana jest „magiczna” wartość -1, która oznacza brak dopasowania. Czy należy przekształcić tę wartość w zmienną symboliczną? Niezależnie od tego, jaką odpowiedź uważasz za prawidłową, zauważ, że w wersji funkcyjnej w ogóle nie trzeba zadawać tego pytania. Funkcja index-of-any jest nie tylko prostsza, ale też dużo bardziej uniwersalna.  Metoda indexOfAny przeszukuje łańcuch znaków, natomiast funkcja index-of-any — dowolną sekwencję.  Metoda indexOfAny dopasowuje znaki ze zbioru, a w funkcji index-of-any można zastosować dowolny predykat.  Metoda indexOfAny zwraca indeks pierwszego pasującego znaku, natomiast funkcja index-filter — indeksy wszystkich pasujących znaków (po czym do tych indeksów można zastosować inne filtry).

Rozdział 2. • Przegląd języka Clojure Tabela 2.2.



77

Względna złożoność imperatywnej i funkcyjnej wersji metody indexOfAny

Miara

Wiersze kodu

Gałęzie

Wyjścia z metody

Zmienne

Wersja imperatywna

14

4

3

3

Wersja funkcyjna

6

1

1

0

Oto przykład uniwersalności funkcji index-of-any — można ją zastosować do znalezienia trzeciego wystąpienia orła w serii rzutów monetą. (nth (index-filter #{:o} [:r :r :o :r :o :r :r :r :o :o]) 2) -> 8

Okazuje się więc, że napisana w stylu funkcyjnym (bez pętli i zmiennych) funkcja index-of-any jest prostsza, mniej podatna na błędy i bardziej ogólna od imperatywnej metody indexOfAny10. W większych jednostkach kodu zalety podejścia funkcyjnego są jeszcze bardziej odczuwalne.

2.8. Metadane W Wikipedii hasło poświęcone metadanym11 rozpoczyna się od stwierdzenia, że metadane to „dane o danych”. Jest ono prawdziwe, ale niewystarczająco dokładne. W Clojure metadane to dane niezależne od wartości logicznej obiektu. Na przykład imię i nazwisko osoby to zwykłe dane. Informacja, że obiekt reprezentujący osobę można zserializować do XML-a, nie ma nic wspólnego z danymi tej osoby. To właśnie są metadane. Metadanymi jest też informacja, że obiekt został zmodyfikowany i trzeba go zapisać w bazie.

Metadane czytnika W samym języku Clojure metadane są używane w różnych miejscach. Na przykład dla zmiennych istnieje odwzorowanie z metadanymi, obejmujące dokumentację, informacje o typie i o kodzie źródłowym. Oto metadane dla zmiennej str. 10

11

Warto wspomnieć, że funkcyjną wersję metody indexOfAny można napisać także w Javie, przy czym nie byłoby to standardowe rozwiązanie (choć mogłoby takie być, gdyby do Javy dodano domknięcia). Więcej informacji znajdziesz na stronie http:// functionaljava.org/. http://pl.wikipedia.org/wiki/Metadane

78

Programowanie w języku Clojure



(meta #'str) -> {:ns #, :name str, :file "core.clj", :line 313, :arglists ([] [x] [x & ys]), :tag java.lang.String, :doc "With no args, ... etc."}

Wybrane klucze często stosowanych metadanych i zastosowania takich informacji przedstawiamy w tabeli 2.3, „Standardowe klucze metadanych”. Tabela 2.3.

Standardowe klucze metadanych

Klucz metadanych

Używany do

:arglists

Informacje o parametrach używane przez funkcję doc

:doc

Dokumentacja używana przez funkcję doc

:file

Plik z kodem źródłowym

:line

Wiersz w kodzie źródłowym

:macro

Zwraca wartość true dla makr

:name

Nazwa lokalna

:ns

Przestrzeń nazw

:tag

Oczekiwany typ argumentu lub zwracanej wartości

Dużą część metadanych zmiennych automatycznie dodaje kompilator języka Clojure. Aby dodać własne pary klucz-wartość, należy użyć makra czytnika metadanych. ^metadata form

Możemy na przykład utworzyć prostą funkcję shout, która zmienia wielkość liter w łańcuchu znaków na wielkie, a następnie za pomocą klucza :tag udokumentować, że funkcja ta przyjmuje i zwraca łańcuch znaków. ; Zobacz też krótszy zapis (pokazany dalej). (defn ^{:tag String} shout [^{:tag String} s] (.toUpperCase s)) -> #'user/shout

Sprawdźmy metadane funkcji klucz :tag. (meta #'shout) -> {:arglists ([s]), :ns #, :name shout, :line 32, :file "NO_SOURCE_FILE", :tag java.lang.String}

shout,

aby zobaczyć, czy język Clojure dodał

Rozdział 2. • Przegląd języka Clojure



79

Określiliśmy klucz :tag, a pozostałe wygenerował język. Wartość NO_SOURCE_FILE klucza :file oznacza, że kod wprowadzono w środowisku REPL. Ponieważ klucz :tag jest tak często stosowany, można też użyć skróconego zapisu ^Classname, który oznacza ^{:tag Classname}. Stosując skróconą konstrukcję, można zapisać funkcję shout tak: (defn ^String shout [^String s] (.toUpperCase s)) -> #'user/shout

Jeśli metadane utrudniają Ci czytanie definicji funkcji, możesz umieścić je na końcu. Zastosujmy wersję instrukcji defn, w której ciała konstrukcji znajdują się w nawiasach, a na końcu umieszczane jest odwzorowanie z metadanymi. (defn shout ([s] (.toUpperCase s)) {:tag String})

2.9. Podsumowanie To długi rozdział. Pomyśl jednak, ile się nauczyłeś. Potrafisz tworzyć egzemplarz podstawowych typów literałowych, definiować i wywoływać funkcje, zarządzać przestrzeniami nazw, a także odczytywać i zapisywać metadane. Umiesz pisać czysto funkcyjny kod, a w razie potrzeby łatwo dodawać efekty uboczne. Poznałeś też techniki z Lispa, w tym makra odczytu, specjalne konstrukcje i rozkładanie struktur. Dla większości języków przedstawienie informacji z tego rozdziału wymaga kilkuset stron. Czy Clojure jest naprawdę aż o tyle prostszy? Po części tak. Połowę zasług można przypisać samemu językowi. Elegancki projekt i zastosowane abstrakcje sprawiają, że Clojure jest znacznie prostszy do przyswojenia niż większość innych języków. Jednak na tym etapie Clojure może nie wydawać się aż tak łatwy do przyswojenia. Wynika to z tego, że wykorzystujemy możliwości języka i omawiamy materiał znacznie szybciej niż w typowych książkach poświęconych językom programowania. Dlatego drugą połowę zasług możesz przypisać sobie. Clojure z nawiązką odda Ci to, co zainwestujesz w jego poznawanie. Jeśli tego potrzebujesz, poświęć trochę czasu na zapoznanie się z przykładami z tego rozdziału i na przyzwyczajenie się do środowiska REPL (będziesz miał do tego okazję także w dalszej części książki).

80



Programowanie w języku Clojure

Rozdział 3.

Ujednolicanie danych za pomocą sekwencji

P

rogramy manipulują danymi. Na najniższym poziomie programy manipulują strukturami w rodzaju łańcuchów znaków, list, wektorów, odwzorowań, zbiorów i drzew. Na wyższym poziomie te same struktury pojawiają się w abstrakcyjnej postaci. Oto przykłady:  Dane w XML-u to drzewo.  Zbiory wyników z baz danych można traktować jak listy lub wektory.  Hierarchie katalogów to drzewa.  Pliki często traktuje się jak jeden duży łańcuch znaków lub wektor wierszy. W języku Clojure dostęp do wszystkich struktur danych można uzyskać za pomocą jednej abstrakcji — sekwencji. Sekwencja to lista logiczna — logiczna, ponieważ Clojure nie wiąże sekwencji ze szczegółową implementacją listy, jak ma to miejsce w przypadku komórek cons w Lispie (historię funkcji cons opisujemy w ramce „Pochodzenie funkcji cons”). Sekwencja to abstrakcja, którą można stosować w dowolnym miejscu.

82



Programowanie w języku Clojure

Pochodzenie funkcji cons Sekwencje języka Clojure to abstrakcja oparta na listach konkretnych Lispa. W pierwszej implementacji Lispa trzy podstawowe operacje na listach nosiły nazwy car, cdr i cons. Nazwy car i cdr to akronimy związane ze szczegółami implementacji Lispa na pierwotną platformę IBM 704. W wielu wersjach Lispa, w tym w Clojure, te niezrozumiałe określenia zastąpiono znaczącymi nazwami first i rest. Nazwa trzeciej funkcji, cons, pochodzi od słowa construct (czyli tworzyć). Programiści Lispa używają nazwy cons jako rzeczownika, czasownika i przymiotnika. Funkcji tej można użyć do utworzenia struktury danych opartej na komórkach cons. W większości wersji Lispa, w tym w Clojure, zachowano pierwotną nazwę cons, ponieważ rozwinięcie „construct” dobrze opisuje działanie funkcji. Określenie to pomaga też zapamiętać, że sekwencje są niezmienne. Dla wygody można stwierdzić, że funkcja cons dodaje element do sekwencji, ale dokładniejsze jest stwierdzenie, że tworzy nową sekwencję, która przypomina pierwotną, ale ma dodatkowy element.

Kolekcje, które można przedstawiać w postaci sekwencji, to kolekcje sekwencyjne (ang. seq-able). W tym rozdziale opisujemy liczne kolekcje tego rodzaju. Należą do nich:  wszystkie kolekcje języka Clojure,  wszystkie kolekcje Javy,  tablice i łańcuchy znaków Javy,  elementy dopasowane do wyrażeń regularnych,  struktury katalogów,  strumienie wejścia-wyjścia,  drzewa danych XML-a. Omawiamy też bibliotekę sekwencji, obejmującą zestaw funkcji, które można stosować do dowolnych kolekcji sekwencyjnych. Ponieważ tak wiele elementów to sekwencje, biblioteka sekwencji daje znacznie więcej możliwości i jest bardziej ogólna niż interfejsy API do obsługi kolekcji z większości języków. Biblioteka sekwencji obejmuje funkcje do tworzenia, filtrowania i przekształcania danych. Funkcje te działają jak API do obsługi kolekcji w języku Clojure. Ponadto zastępują wiele pętli, które trzeba byłoby napisać w językach imperatywnych.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



83

W tym rozdziale staniesz się ekspertem od sekwencji języka Clojure. Zobaczysz, jak korzystać ze standardowego zestawu dających duże możliwości funkcji, działających dla bardzo różnorodnych typów danych. W następnym rozdziale (rozdział 4., „Programowanie funkcyjne”) poznasz styl funkcyjny, w którym napisana jest biblioteka sekwencji.

3.1. Wszystko jest sekwencją W języku Clojure każdą wieloelementową strukturę danych można traktować jak sekwencję. Sekwencje mają trzy podstawowe cechy:  Umożliwiają pobranie pierwszego elementu sekwencji (za pomocą instrukcji first). (first aseq)



Instrukcja first zwraca nil, jeśli argument jest pusty lub ma wartość nil. Można pobrać wszystkie elementy po pierwszym (czyli resztę sekwencji) za pomocą instrukcji rest. (rest aseq)



Instrukcja rest zwraca pustą sekwencję (nie nil), jeśli nie ma dalszych elementów. Można utworzyć nową sekwencję przez dodanie elementu na początek istniejącej sekwencji. Służy do tego instrukcja cons. (cons elem aseq)

Na zapleczu te trzy możliwości są zadeklarowane w interfejsie Javy clojure.lang.lSeq. Pamiętaj o tym w trakcie czytania tekstów angielskojęzycznych na temat języka Clojure, ponieważ nazwa lSeq jest często używana zamiast określenia seq. Funkcja seq zwraca sekwencję utworzoną na podstawie dowolnej kolekcji sekwencyjnej. (seq coll)

Funkcja seq zwraca nil, jeśli kolekcja coll jest pusta lub ma wartość nil. Funkcja next zwraca sekwencję elementów znajdujących się po pierwszym. (next aseq)

Wywołanie (next aseq) to odpowiednik instrukcji (seq (rest aseq)). W tabeli 3.1, „Działanie funkcji rest i next”, wyjaśniamy funkcjonowanie instrukcji rest i next.

84

Programowanie w języku Clojure

 Tabela 4.1.

Działanie funkcji rest i next

Konstrukcja

Wynik

(rest ())

()

(next ())

nil

(seq (rest ()))

nil

Jeśli stosowałeś wcześniej Lispa, nie będzie dla Ciebie zaskoczeniem, że funkcje do obsługi sekwencji działają też dla list. (first '(1 2 3)) -> 1 (rest '(1 2 3)) -> (2 3) (cons 0 '(1 2 3)) -> (0 1 2 3)

W języku Clojure te same funkcje działają także dla innych struktur danych. Jak sekwencje można traktować na przykład wektory. (first [1 2 3]) -> 1 (rest [1 2 3]) -> (2 3) (cons 0 [1 2 3]) -> (0 1 2 3)

Po zastosowaniu instrukcji rest lub cons do wektora wynikiem jest sekwencja, a nie wektor. W środowisku REPL sekwencje są wyświetlane w taki sam sposób jak listy, co widać we wcześniejszych danych wyjściowych. Typ zwróconych danych można ustalić przez sprawdzenie ich klasy (instrukcja class). (class (rest [1 2 3])) -> clojure.lang.PersistentVector$ChunkedSeq

Człon $ChunkedSeq w końcowej części nazwy klasy wynika ze sposobu manglowania w Javie nazw klas zagnieżdżonych. Sekwencje tworzone na podstawie kolekcji konkretnych typów są często implementowane za pomocą klasy ChunkedSeq zagnieżdżonej w klasie pierwotnej kolekcji (tu jest nią PersistentVector). Ogólny charakter sekwencji daje duże możliwości, jednak czasem potrzebny jest konkretny typ. Zagadnienie to omawiamy w podrozdziale 3.5, „Wywoływanie funkcji z konkretnych struktur”.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



85

Odwzorowania można traktować jak sekwencje, jeśli uznać pary klucz-wartość za jej elementy. (first {:fname "Aaron" :lname "Bedra"}) -> [:lname "Bedra"] (rest {:fname "Aaron" :lname "Bedra"}) -> ([:fname "Aaron"]) (cons [:mname "James"] {:fname "Aaron" :lname "Bedra"}) -> ([:mname "James"] [:lname "Bedra"] [:fname "Aaron"])

Także zbiory można traktować jak sekwencje. (first #{:the :quick :brown :fox}) -> :brown (rest #{:the :quick :brown :fox}) -> (:quick :fox :the) (cons :jumped #{:the :quick :brown :fox}) -> (:jumped :brown :quick :fox :the)

W odwzorowaniach i zbiorach kolejność przechodzenia po elementach jest stała, jednak zależy od szczegółów implementacji, dlatego nie należy na tym polegać. Elementy zbioru nie zawsze są zwracane w kolejności ich dodawania do kolekcji. #{:the :quick :brown :fox} -> #{:brown :quick :fox :the}

Aby zapewnić stabilną kolejność elementów, należy zastosować inne rozwiązanie. (sorted-set & elements)

Funkcja sorted-set sortuje wartości w naturalnym porządku. (sorted-set :the :quick :brown :fox) -> #{:brown :fox :quick :the}

Także pary klucz-wartość z odwzorowań nie zawsze są zwracane w kolejności ich dodawania. {:a 1 :b 2 :c 3} -> {:a 1, :c 3, :b 2}

Za pomocą instrukcji sorted-map można utworzyć posortowane odwzorowanie. (sorted-map & elements)

Instrukcja sorted-map nie zwraca danych w kolejności ich dodania, ale zwraca je posortowane według klucza. (sorted-map :c 3 :b 2 :a 1) -> {:a 1, :b 2, :c 3}

86



Programowanie w języku Clojure

Oprócz podstawowych cech sekwencji warto wspomnieć także o dwóch innych, związanych z instrukcjami conj i into. (conj coll element & elements) (into to-coll from-coll)

Instrukcja conj dodaje do kolekcji przynajmniej jeden element, a instrukcja into dodaje wszystkie elementy z jednej kolekcji do drugiej. Obie instrukcje dodają elementy w miejscu, w którym w danej kolekcji operacja ta jest najwydajniejsza. W przypadku list jest to początek kolekcji. (conj '(1 2 3) :a) -> (:a 1 2 3) (into '(1 2 3) '(:a :b :c)) -> (:c :b :a 1 2 3)

W wektorach instrukcje conj i into dodają elementy na koniec. (conj [1 2 3] :a) -> [1 2 3 :a] (into [1 2 3] [:a :b :c]) -> [1 2 3 :a :b :c]

Ponieważ instrukcja conj (i powiązane funkcje) wykonuje operacje wydajnie, można pisać kod, który zarówno szybko działa, jak i jest zupełnie niezależny od używanej kolekcji. Biblioteka sekwencji języka Clojure wyjątkowo dobrze nadaje się do obsługi dużych (a nawet nieskończonych) sekwencji. Większość sekwencji języka Clojure działa w trybie leniwym, co polega na generowaniu elementów dopiero wtedy, kiedy są potrzebne. Tak więc w języku Clojure można przetwarzać sekwencje zbyt długie, aby można umieścić je w pamięci.

Jaś pyta... Dlaczego funkcje wywoływane dla wektorów zwracają listy? Po uruchomieniu przykładowego kodu w środowisku REPL można odnieść wrażenie, że wyniki wywołań funkcji rest i cons to listy — także jeśli dane wejściowe to wektory, odwzorowania lub zbiory. Czy oznacza to, że Clojure wewnętrznie wszystko przekształca na listy? Nie! Funkcje do obsługi sekwencji zawsze zwracają sekwencje. Dane wejściowe nie mają tu znaczenia. Można się o tym przekonać, sprawdzając typ, jaki w Javie mają zwrócone obiekty. (class '(1 2 3)) -> clojure.lang.PersistentList

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



87

(class (rest [1 2 3])) -> clojure.lang.PersistentVector$ChunkedSeq

Jak widać, wynik wyrażenia (rest [1 2 3]) to odmiana sekwencji, a nie lista. Dlaczego więc wynik wygląda jak lista? Odpowiedź związana jest ze środowiskiem REPL. Jeśli ma ono wyświetlić sekwencję, zna tylko ją. Nie wie, jakiego rodzaju kolekcja posłużyła do utworzenia sekwencji. Dlatego środowisko REPL wszystkie sekwencje wyświetla w ten sam sposób — przechodzi po całej sekwencji i wyświetla ją jako listę.

Sekwencje w języku Clojure są niezmienne (ang. immutable). Dzięki temu łatwiej jest zrozumieć działanie programu. Ponadto oznacza to, że sekwencje języka Clojure są bezpieczne ze względu na dostęp równoległy. Występuje jednak pewien problem z przedstawianiem kodu w języku naturalnym. W języku polskim dużo łatwiej jest opisywać zmienne elementy. Przyjrzyj się dwóm opisom fikcyjnej funkcji triple przeznaczonej dla sekwencji.  Funkcja triple potraja wartość każdego elementu sekwencji.  Funkcja triple pobiera sekwencję i zwraca nową sekwencję, w której każdy element pierwotnej sekwencji ma trzykrotnie większą wartość. Druga z tych wersji jest konkretna i precyzyjna. Pierwsza jest znacznie czytelniejsza, jednak może sprawiać wrażenie, że sama sekwencja się zmienia. Nie Cię to nie myli — sekwencje nigdy się nie zmieniają. Jeśli natrafisz na stwierdzenie „funkcja foo modyfikuje x”, interpretuj to jako „funkcja foo zwraca zmodyfikowaną kopię x”.

3.2. Stosowanie biblioteki sekwencji Biblioteka sekwencji języka Clojure udostępnia bogaty zbiór mechanizmów, które działają dla dowolnej sekwencji. Jeśli masz doświadczenie w programowaniu obiektowym, gdzie dominują rzeczowniki, bibliotekę sekwencji możesz potraktować jak „zemstę czasowników”1. Funkcje zapewniają bogactwo mechanizmów, które można wykorzystać dla dowolnych struktur danych zgodnych z podstawowymi instrukcjami first, rest i cons. 1

Steve Yegge w artykule Execution in the Kingdom of Nouns (http://tinyurl.com/ the-kingdom-of-nouns) twierdzi, że programowanie obiektowe doprowadziło do niezasłużonej dominacji rzeczowników. Według Steve’a nadeszła pora na zmiany.

88



Programowanie w języku Clojure

Funkcje biblioteki dzielimy tu na cztery ogólne kategorie. Oto one:  funkcje do tworzenia sekwencji,  funkcje filtrujące sekwencje,  predykaty sekwencji,  funkcje przekształcające sekwencje. Podział ten jest nieco arbitralny. Ponieważ sekwencje są niezmienne, większość funkcji do ich obsługi tworzy nowe sekwencje. Niektóre funkcje zarówno filtrują, jak i przekształcają sekwencje. Jednak przedstawiony podział stanowi ogólną mapę omawianej tu dużej biblioteki.

Tworzenie sekwencji W języku Clojure sekwencje można tworzyć nie tylko za pomocą literałów, ale też przy użyciu różnych funkcji. Funkcja range tworzy sekwencję (przedział) od wartości start do end z elementów różniących się o wartość step. (range start? end step?)

Przedziały obejmują wartość start, ale już nie end. Domyślnie start to 0, a step to 1. Utwórzmy kilka przedziałów w środowisku REPL. (range 10) -> (0 1 2 3 4 5 6 7 8 9) (range 10 20) -> (10 11 12 13 14 15 16 17 18 19) (range 1 25 2) -> (1 3 5 7 9 11 13 15 17 19 21 23)

Funkcja repeat powiela element x n razy. (repeat n x)

Wywołajmy tę funkcję dla kilku elementów w środowisku REPL. (repeat 5 1) -> (1 1 1 1 1) (repeat 10 "x") -> ("x" "x" "x" "x" "x" "x" "x" "x" "x" "x")

Funkcje range i repeat reprezentują operacje, które można powtarzać w nieskończoność. Instrukcję iterate można traktować jak nieskończoną wersję funkcji range. (iterate f x)

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



89

Funkcja iterate rozpoczyna od wartości x i działa w nieskończoność, wywołując instrukcję f dla każdej wartości w celu obliczenia następnej. Jeśli zaczniemy od wartości 1 i wywołamy funkcję możemy wygenerować liczby całkowite.

iterate

z argumentem

inc,

(take 10 (iterate inc 1)) -> (1 2 3 4 5 6 7 8 9 10)

Ponieważ sekwencja jest nieskończona, potrzebna jest inna nowa funkcja, aby móc wyświetlić sekwencję w środowisku REPL. (take n sequence)

Funkcja take zwraca „leniwą” sekwencję pierwszych n elementów z kolekcji i umożliwia wyświetlenie skończonego fragmentu nieskończonej kolekcji. Liczby całkowite są przydatną sekwencją, dlatego użyjmy funkcji defn do zdefiniowania takiej sekwencji w celu jej przyszłego wykorzystania. (defn whole-numbers [] (iterate inc 1)) -> #'user/whole-numbers

Funkcja repeat wywołana z pojedynczym argumentem zwraca „leniwą” nieskończoną sekwencję. (repeat x)

Wywołajmy funkcję repeat dla wybranej wartości w środowisku REPL. Należy pamiętać o umieszczeniu wyniku w funkcji take. (take 20 (repeat 1)) -> (1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1)

Funkcja cycle przyjmuje kolekcję i powiela ją w nieskończoność. (cycle coll)

Spróbujmy powielić kolekcję w środowisku REPL. (take 10 (cycle (range 3))) -> (0 1 2 0 1 2 0 1 2 0)

Funkcja interleave przyjmuje kilka kolekcji i tworzy nową kolekcję, w której wartości z pierwotnych się przeplatają (do momentu skończenia się elementów jednej z kolekcji). (interleave & colls)

Po wyczerpaniu się elementów z jednej kolekcji funkcja interleave kończy pracę. Można więc wymieszać kolekcje skończone z nieskończonymi. (interleave (whole-numbers) ["A" "B" "C" "D" "E"]) -> (1 "A" 2 "B" 3 "C" 4 "D" 5 "E")

90

Programowanie w języku Clojure



Funkcja interpose jest podobna do interleave — zwraca sekwencję z wszystkimi elementami z wejściowej kolekcji rozdzielonymi podanym separatorem. (interpose separator coll)

Funkcję interpose można wykorzystać do tworzenia łańcuchów znaków rozdzielonych separatorami. (interpose "," ["apples" "bananas" "grapes"]) -> ("apples" "," "bananas" "," "grapes")

Funkcja interpose razem z wyrażeniem (apply wyjściowe łańcuchy znaków.

str …)

pozwala wygodnie tworzyć

(apply str (interpose \, ["apples" "bananas" "grapes"])) -> "apples,bananas,grapes"

Idiom (apply str …) jest na tyle często używany, że w języku Clojure ujęto go w instrukcji clojure.string/join. (join separator sequence)

Za pomocą instrukcji cinkami listę słów.

clojure.string/join

można na przykład rozdzielić prze-

(use '[clojure.string :only (join)]) (join \, ["apples" "bananas" "grapes"]) -> "apples,bananas,grapes"

W języku Clojure dla kolekcji każdego typu istnieje funkcja, która przyjmuje dowolną liczbę argumentów i tworzy kolekcję określonego rodzaju. (list & elements) (vector & elements) (hash-set & elements) (hash-map key-1 val-1 ...)

Istnieje też podobna do funkcji hash-set instrukcja set, która działa jednak w nieco inny sposób — jako pierwszy argument przyjmuje kolekcję. (set [1 2 3]) -> #{1 2 3}

Funkcja hash-set przyjmuje listę z dowolną liczbą argumentów. (hash-set 1 2 3) -> #{1 2 3}

Także funkcja vector ma odpowiednik, vec, przyjmujący jeden argument w postaci kolekcji zamiast listy o zmiennej liczbie argumentów. (vec (range 3)) -> [0 1 2]

Znasz już podstawy tworzenia sekwencji, możesz więc zacząć używać innych funkcji języka Clojure do ich filtrowania i przekształcania.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



91

Filtrowanie sekwencji Clojure udostępnia szereg funkcji do filtrowania sekwencji. Funkcje te zwracają sekwencje utworzone na podstawie pierwotnych. Najprostszą funkcją tego rodzaju jest filter. (filter pred coll)

Funkcja filter przyjmuje predykat i kolekcję, a następnie zwraca sekwencję obiektów, dla których predykat zwraca wartość true (interpretowaną w kontekście logicznym). Można przefiltrować na przykład kolekcję whole-numbers z poprzedniego punktu, aby otrzymać liczby nieparzyste lub parzyste. (take 10 (filter even? (whole-numbers))) -> (2 4 6 8 10 12 14 16 18 20) (take 10 (filter odd? (whole-numbers))) -> (1 3 5 7 9 11 13 15 17 19)

Za pomocą instrukcji take-while można pobierać elementy z sekwencji dopóty, dopóki predykat zwraca wartość true. (take-while pred coll)

Aby pobrać z łańcucha wszystkie znaki do miejsca wystąpienia pierwszej samogłoski, można użyć następującego kodu. (take-while (complement #{\a\e\i\o\u}) "the-quick-brown-fox") -> (\t \h)

Dzieje się tu kilka ciekawych rzeczy. Oto one:  Zbiory działają jak funkcje, dlatego kod #{\a\e\i\o\u} można odczytać jako „zbiór samogłosek” lub „funkcja sprawdzająca, czy jej argument jest samogłoską”.  Funkcja complement odwraca działanie innej funkcji. W przykładzie funkcja ta sprawdza, czy argument nie jest samogłoską. Przeciwieństwem funkcji take-while jest drop-while. (drop-while pred coll)

Funkcja drop-while pomija elementy z początku sekwencji, dla których predykat zwraca wartość true, i zwraca pozostałe elementy. (drop-while (complement #{\a\e\i\o\u}) "the-quick-brown-fox") -> (\e \- \q \u \i \c \k \- \b \r \o \w \n \- \f \o \x)

Funkcje split-at i split-with dzielą kolekcję na dwie nowe. (split-at index coll) (split-with pred coll)

92



Programowanie w języku Clojure

Funkcja split-at przyjmuje indeks, a funkcja split-with przyjmuje predykat. (split-at 5 (range 10)) ->[(0 1 2 3 4) (5 6 7 8 9)] (split-with #([(0 2 4 6 8 10) (12 14 16 18)]

Wszystkie funkcje take-, split- i drop- zwracają „leniwe” sekwencje.

Predykaty sekwencji Funkcje filtrujące przyjmują predykat i zwracają sekwencję. Ściśle powiązane są predykaty sekwencji. Taki predykat sprawdza, jaka jest wartość zastosowania innego predykatu dla każdego elementu sekwencji. Na przykład predykat every? sprawdza, czy inny predykat dla każdego elementu sekwencji zwraca wartość true. (every? pred coll) (every? odd? [1 3 5]) -> true (every? odd? [1 3 5 8]) -> false

Funkcja some ma „mniejsze wymagania”. (some pred coll)

Funkcja ta zwraca pierwszy element, dla którego predykat zwrócił true. Jeśli nie ma takich elementów, some zwraca nil. (some even? [1 2 3]) -> true (some even? [1 3 5]) -> nil

Warto zauważyć, że funkcja some nie ma na końcu znaku zapytania. Nie jest ona predykatem, choć często używa się jej w ten sposób. Funkcja some zwraca wartość pierwszego pasującego elementu, a nie true. Różnica ta jest niewidoczna, jeśli funkcji some używa się razem z predykatem even?. Aby zobaczyć znalezioną wartość inną niż true, użyjmy funkcji some razem z instrukcją identity do wyszukania w sekwencji pierwszej wartości różnej od nil. (some identity [nil false 1 nil 2]) -> 1

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



93

Działanie innych predykatów dokładnie opisują ich nazwy (not-every? oznacza nie każdy, a not-any? — żaden). (not-every? pred coll) (not-any? pred coll)

Nie każda liczba całkowita jest parzysta. (not-every? even? (whole-numbers)) -> true

Jednak stwierdzenie, że żadna liczba całkowita nie jest parzysta, jest nieprawdziwe. (not-any? even? (whole-numbers)) -> false

Warto zauważyć, że zastosowaliśmy pytania, na które znamy odpowiedź. Ogólnie należy zachować ostrożność przy stosowaniu predykatów do nieskończonych kolekcji. Takie predykaty mogą działać w nieskończoność.

Przekształcanie sekwencji Funkcje z tej grupy przekształcają wartości z sekwencji. Najprostszą funkcją tego rodzaju jest map. (map f coll)

Funkcja map przyjmuje kolekcję źródłową coll i funkcję f, a następnie zwraca nową sekwencję otrzymaną przez wywołanie funkcji f dla każdego elementu kolekcji coll. Funkcję map można wykorzystać do umieszczenia każdego elementu kolekcji w znacznikach języka HTML. (map #(format "

%s

" %) ["the" "quick" "brown" "fox"]) -> ("

the

" "

quick

" "

brown

" "

fox

")

Funkcja map może też przyjmować więcej niż jedną kolekcję. Wtedy funkcja f musi działać dla więcej niż jednego argumentu. W tej wersji funkcja map wywołuje funkcję f, przekazując do niej po jednym argumencie z każdej kolekcji. Proces ten kończy się w momencie wyczerpania się wszystkich elementów z najkrótszych kolekcji. (map #(format "%s" %1 %2 %1) ["h1" "h2" "h3" "h1"] ["the" "quick" "brown" "fox"]) -> ("

the

" "

quick

" "

brown

" "

fox

")

Inną często stosowaną funkcją przekształcającą jest reduce. (reduce f coll)

94



Programowanie w języku Clojure

Funkcja f przyjmuje dwa argumenty. Instrukcja reduce wywołuje funkcję f dla pierwszych dwóch elementów kolekcji coll, następnie wywołuje funkcję f dla wyniku i trzeciego elementu, itd. Funkcja reduce jest przydatna do obliczania różnego rodzaju łącznych wartości sekwencji, na przykład do dodawania wartości elementów. (reduce + (range 1 11)) -> 55

Można jej użyć także do mnożenia. (reduce * (range 1 11)) -> 3628800

Do sortowania kolekcji służą funkcje sort i sort-by. (sort comp? coll) (sort-by a-fn comp? coll)

Funkcja sort sortuje elementy według porządku naturalnego, natomiast funkcja sort-by — według wyników wywołania funkcji a-fn dla każdego elementu. (sort [42 1 7 11]) -> (1 7 11 42) (sort-by #(.toString %) [42 1 7 11]) -> (1 11 42 7)

Jeśli nie chcesz sortować danych według porządku naturalnego, możesz określić w instrukcjach sort i sort-by opcjonalną funkcję porównującą comp. (sort > [42 1 7 11]) -> (42 11 7 1) (sort-by :grade > [{:grade 83} {:grade 90} {:grade 77}]) -> ({:grade 90} {:grade 83} {:grade 77})

Praprzodkiem wszystkich funkcji filtrujących i przekształceń jest wyrażenie listowe (ang. list comprehension). Wyrażenie listowe tworzy listę na podstawie istniejącej listy. Służy do tego zapis charakterystyczny dla zbiorów. Oznacza to, że wyrażenie listowe określa cechy, które musi mieć wynikowa lista. Ogólnie wyrażenie listowe składa się z następujących elementów:  listy wejściowej (lub list wejściowych),  symboli na elementy list wejściowych,  predykatów dotyczących elementów,  konstrukcji wyjściowej, generującej dane wyjściowe na podstawie elementów listy wejściowej, które spełniają predykat.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



95

W języku Clojure wyrażenia listowe oczywiście uogólniono do wyrażeń sekwencyjnych. Wykorzystywane jest w nich makro for2. (for [binding-form coll-expr filter-expr? ...] expr)

Makro for przyjmuje wektor z binding-form i coll-expr oraz dodatkowo filter-expr, a następnie zwraca sekwencję wyrażeń expr. Wyrażenia listowe są bardziej ogólne od funkcji (takich jak map i filter) oraz pozwalają zasymulować działanie większości opisanych wcześniej funkcji filtrujących i przekształcających. Wcześniejszy przykład z funkcją listowego tak:

map

można zapisać za pomocą wyrażenia

(for [word ["the" "quick" "brown" "fox"]] (format "

%s

" word)) -> ("

the

" "

quick

" "

brown

" "

fox

")

Można odczytać to tak: dla każdego słowa z sekwencji słów sformatuj według instrukcji formatowania. W wyrażeniu listowym można zasymulować działanie funkcji filter za pomocą klauzuli :when. Do klauzuli :when można przekazać predykat even?, aby filtr zwrócił tylko liczby parzyste. (take 10 (for [n (whole-numbers) :when (even? n)] n)) -> (2 4 6 8 10 12 14 16 18 20)

Klauzula :while powoduje kontynuację przetwarzania tylko wtedy, jeśli wyrażenie ma wartość true. (for [n (whole-numbers) :while (even? n)] n) -> ()

Prawdziwa wartość makra for staje się widoczna, kiedy używane jest więcej niż jedno wyrażenie z wiązaniem. Można na przykład przedstawić wszystkie pola szachownicy w notacji algebraicznej, wiążąc nazwy rank i file. (for [file "ABCDEFGH" rank (range 1 9)] (format "%c%d" file rank)) -> ("A1" "A2" ... pozostałe pomijamy ... "H7" "H8")

Clojure najpierw iteracyjnie przechodzi po prawym wyrażeniu wiążącym w wyrażeniu sekwencyjnym, a następnie kontynuuje przetwarzanie w lewo. Ponieważ w wyrażeniu wiążącym słowo rank znajduje się na prawo od słowa file, przetwarzanie zaczyna się od niego. Aby najpierw przejść po nazwach kolumn, należy odwrócić kolejność wiązania i umieścić na początku słowo rank. 2

Wyrażenie listowe for nie ma nic wspólnego z pętlą for z języków imperatywnych.

96



Programowanie w języku Clojure

(for [rank (range 1 9) file "ABCDEFGH"] (format "%c%d" file rank)) -> ("A1" "B1" ... pozostałe pomijamy ... "G8" "H8")

W wielu językach funkcje przekształcające i filtrujące oraz wyrażenia listowe są wykonywane natychmiast. W języku Clojure nie można przyjąć, że tak się stanie. Większość funkcji manipulujących sekwencjami nie przechodzi po elementach do momentu, w którym dane elementy są używane.

3.3. Sekwencje nieskończone i „leniwe” Większość sekwencji w Clojure działa w trybie leniwym. Oznacza to, że wartości elementów są obliczane dopiero wtedy, kiedy są potrzebne. „Leniwe” sekwencje mają kilka zalet:  pozwalają odłożyć kosztowne obliczenia, które mogą okazać się zbędne,  pozwalają pracować z dużymi zbiorami danych, które nie mieszczą się w pamięci,  pozwalają odłożyć operacje wejścia-wyjścia do czasu, kiedy są niezbędne. Zastanów się nad poniższym kodem i wyrażeniem. src/examples/primes.clj

(ns examples.primes) ;; Kod pochodzi z biblioteki clojure.contrib.lazy-seqs ; Liczb pierwszych nie można wyświetlić wydajnie w funkcji, ponieważ ; niezbędne jest przejrzenie całej sekwencji. Porównaj ten kod z kodem do ; obliczania liczb Fibonacciego i potęg dwójki, gdzie potrzebny jest tylko ; stały bufor z jedną lub dwiema poprzednimi wartościami. (def primes (concat [2 3 5 7] (lazy-seq (let [primes-from (fn primes-from [n [f & r]] (if (some #(zero? (rem n %)) (take-while #( ([1001 7927] [1002 7933] [1003 7937] [1004 7949] [1005 7951])

Kiedy warto stosować „leniwe” sekwencje? Prawie zawsze. Większość funkcji przetwarzających sekwencje działa w trybie „leniwym”, dlatego ponoszone są tylko koszty obliczania używanych elementów. Co ważniejsze, tworzenie „leniwych” sekwencji nie wymaga od programisty wykonywania dodatkowych operacji. W poprzednim przykładzie funkcje iterate, primes i map zwracają „leniwe” sekwencje, dlatego struktura ordinals-and-primes automatycznie działa w tym trybie. „Leniwe” sekwencje są bardzo ważne w programowaniu sekwencyjnym w Clojure. W podrozdziale 4.2, „Jak stosować »leniwe« podejście?”, dokładnie opisujemy, jak tworzyć „leniwe” sekwencje i jak z nich korzystać.

Wymuszanie realizacji sekwencji Przy wyświetlaniu długich sekwencji w środowisku REPL czasem warto zastosować funkcję take, aby środowisko nie przetwarzało całej sekwencji. W innych sytuacjach występuje odwrotny problem. Możliwe, że utworzyłeś „leniwą” sekwencję, ale chcesz, aby została przetworzona w całości. Problem zwykle pojawia się, kiedy kod generujący sekwencję powoduje efekty uboczne. Przyjrzyjmy się poniższej sekwencji, która powoduje efekty uboczne (z uwagi na funkcję println). (def x (for [i (range 1 3)] (do (println i) i))) -> #'user/x

Początkujący użytkownicy języka Clojure są zaskoczeni tym, że powyższy kod nie wyświetla żadnych danych. Ponieważ w definicji x elementy nie są używane, Clojure nie przetwarza wyrażenia listowego, aby je utworzyć. Aby wymusić przetwarzanie, należy zastosować instrukcję doall. (doall coll)

Funkcja doall powoduje, że Clojure przechodzi po elementach sekwencji i zwraca je jako wynik. (doall x) | 1 | 2 -> (1 2)

98



Programowanie w języku Clojure

Można też zastosować funkcję dorun. (dorun coll)

Funkcja dorun przechodzi po elementach sekwencji, ale nie zachowuje wcześniejszych elementów w pamięci. Dlatego pozwala ona poruszać się po kolekcjach zbyt dużych, aby zmieścić je w pamięci. (def x (for [i (range 1 3)] (do (println i) i))) -> #'user/x (dorun x) | 1 | 2 -> nil

Zwrócona wartość nil to dowód na to, że funkcja dorun nie przechowuje referencji do całej sekwencji. Funkcje dorun i doall pomagają radzić sobie z efektami ubocznymi, natomiast ogólnie język Clojure zniechęca do tworzenia kodu mającego efekty uboczne. Dlatego obie funkcje rzadko są potrzebne.

3.4. W Clojure Java jest sekwencyjna Abstrakcję sekwencji (opartą na operacjach first i rest) można stosować do dowolnych struktur, które obejmują więcej niż jeden element. W świecie Javy jako sekwencje można zapisywać na przykład wyniki:  pracy interfejsów API kolekcji,  wyrażeń regularnych,  poruszania się po systemie plików,  przetwarzania danych XML-owych,  relacyjnych baz danych. Clojure zapewnia nakładkę na interfejsy API Javy i pozwala stosować bibliotekę sekwencji w niemal dowolnym kontekście.

Przekształcanie kolekcji Javy na sekwencje Jeśli zaczniesz stosować funkcje związane z sekwencjami dla kolekcji Javy, odkryjesz, że kolekcje te działają jak sekwencje. Kolekcje działające jak sekwencje to kolekcje sekwencyjne (ang. seq-able). Sekwencyjne są na przykład tablice.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



99

; Funkcja String.getBytes zwraca tablicę bajtów. (first (.getBytes "hello")) -> 104 (rest (.getBytes "hello")) -> (101 108 108 111) (cons (int \h) (.getBytes "ello")) -> (104 101 108 108 111)

Sekwencyjne są też kolekcje Hashtable i Map. ; Instrukcja System.getProperties zwraca kolekcję Hashtable. (first (System/getProperties)) -> # (rest (System/getProperties)) -> (# \W (rest "Witaj") -> (\i \t \a \j) (cons \W "witaj") -> (\W \i \t \a \j)

Język Clojure automatycznie umieszcza kolekcje w sekwencjach, ale nie przywraca automatycznie kolekcji do pierwotnego typu. Dla większości typów kolekcji jest to naturalne, jednak w przypadku łańcuchów znaków często warto przekształcić wynik z powrotem na łańcuch znaków. Zastanówmy się nad odwracaniem kolejności znaków w łańcuchu. W Clojure służy do tego funkcja reverse. ; Prawdopodobnie nie takiego wyniku oczekiwałeś. (reverse "witaj") -> (\j \a \t \i \w)

Aby przekształcić sekwencję z powrotem na łańcuch znaków, należy użyć wyrażenia (apply str seq). (apply str (reverse "witaj")) -> "jatiw"

100



Programowanie w języku Clojure

Kolekcje Javy są sekwencyjne, jednak w większości sytuacji nie dają żadnych korzyści w porównaniu z wbudowanymi kolekcjami języka Clojure. Kolekcje Javy warto stosować tylko przy korzystaniu ze starszych interfejsów API Javy.

Przekształcanie wyrażeń regularnych na sekwencje W wyrażeniach regularnych języka Clojure na zapleczu używa się biblioteki java.util.regex. Na najniższym poziomie daje to dostęp do zmiennego obiektu Matcher Javy. Za pomocą funkcji re-matcher można utworzyć obiekt Matcher dla wyrażenia regularnego i łańcuch znaków, a następnie wywołać instrukcję loop z funkcją re-find, aby przejść po dopasowanych elementach. (re-matcher regexp string) src/examples/sequences.clj

; Nie stosuj tego podejścia! (let [m (re-matcher #"\w+" "the quick brown fox")] (loop [match (re-find m)] (when match (println match) (recur (re-find m))))) | the | quick | brown | fox -> nil

Dużo lepszym rozwiązaniem jest użycie funkcji wyższego rzędu re-seq. (re-seq regexp string)

Funkcja re-seq udostępnia niezmienną sekwencję z dopasowanymi elementami. Dzięki temu można korzystać z wszystkich funkcji języka Clojure przeznaczonych do obsługi sekwencji. Wypróbujmy tę funkcję w środowisku REPL. (re-seq #"\w+" "the quick brown fox") -> ("the" "quick" "brown" "fox") (sort (re-seq #"\w+" "the quick brown fox")) -> ("brown" "fox" "quick" "the") (drop 2 (re-seq #"\w+" "the quick brown fox")) -> ("brown" "fox") (map #(.toUpperCase %) (re-seq #"\w+" "the quick brown fox")) -> ("THE" "QUICK" "BROWN" "FOX")

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



101

Funkcja re-seq to doskonały przykład na to, że dobre abstrakcje pozwalają znacznie skrócić kod. Elementy dopasowane do wyrażenia regularnego nie są niczym wyjątkowym, dlatego do ich obsługi nie trzeba stosować specjalnych metod. Elementy te można umieścić w sekwencji, podobnie jak wszelkie inne dane. Z uwagi na dużą liczbę funkcji do obsługi sekwencji otrzymujemy w ten sposób za darmo funkcje, które trzeba by napisać specjalnie dla wyrażeń regularnych.

Przekształcanie systemu plików na sekwencje Sekwencje można tworzyć także dla systemu plików. Zacznijmy od bezpośredniego wywołania instrukcji java.io.File. (import '(java.io File)) (.listFiles (File. ".")) -> [Ljava.io.File;@1f70f15e

Dane wyjściowe [Ljava.io.File… to reprezentacja tablicy obiektów File uzyskana przez wywołanie funkcji toString() Javy. Funkcje do obsługi sekwencji automatycznie wywołują dla takich obiektów instrukcję seq, natomiast środowisko REPL tego nie robi. Trzeba więc samodzielnie wywołać instrukcję seq. (seq (.listFiles (File. ".")) ) -> (# # ...)

Jeśli domyślny format wyświetlania plików Ci nie odpowiada, możesz odwzorować nazwy plików na łańcuchy znaków za pomocą funkcji getName. ; Rozwiązanie z nadmiarowym wywołaniem. (map #(.getName %) (seq (.listFiles (File. ".")))) -> ("concurrency" "sequences" ...)

Przy stosowaniu funkcji w rodzaju map wywoływanie instrukcji seq jest niepotrzebne. Funkcje biblioteki sekwencji automatycznie wywołują instrukcję seq, dlatego programista nie musi tego robić. Wcześniejsze wyrażenie można uprościć w następujący sposób. (map #(.getName %) (.listFiles (File. "."))) -> ("concurrency" "sequences" ...)

Często programista chce rekurencyjnie przejść po całym drzewie katalogów. Clojure umożliwia poruszanie się w trybie przeszukiwania w głąb za pomocą instrukcji file-seq. Po wywołaniu funkcji file-seq z przykładowego kodu z książki zobaczysz dużą liczbę plików. (count (file-seq (File. "."))) -> 104 ; Łączna liczba plików będzie większa!

102



Programowanie w języku Clojure

Co zrobić, aby wyświetlić tylko niedawno zmodyfikowane pliki? Wystarczy napisać predykat recently-modified?, który sprawdza, czy plik zmodyfikowano w ciągu ostatniej pół godziny. src/examples/sequences.clj

(defn minutes-to-millis [mins] (* mins 1000 60)) (defn recently-modified? [file] (> (.lastModified file) (- (System/currentTimeMillis) (minutes-to-millis 30))))

Wprowadź następującą instrukcję3: (filter recently-modified? (file-seq (File. "."))) -> (./sequences ./sequences/sequences.clj)

Przekształcanie strumienia na sekwencję Na sekwencję można przekształcić także wiersze dowolnego obiektu Reader Javy. Służy do tego instrukcja line-seq. Aby otrzymać obiekt Reader, należy użyć biblioteki clojure.java.io języka Clojure. Biblioteka ta obejmuje funkcję reader, która zwraca czytnik dla strumienia, pliku, adresu URL lub identyfikatora URI. (use '[clojure.java.io :only (reader)]) ; Czytnik pozostaje otwarty. (take 2 (line-seq (reader "src/examples/utils.clj"))) -> ("(ns examples.utils" " (:import [java.io BufferedReader InputStreamReader]))")

Ponieważ czytniki mogą reprezentować zasoby, które nie są przechowywane w pamięci i wymagają zamknięcia, kod tworzący czytnik należy umieścić w instrukcji with-open. Utwórzmy wyrażenie, które korzysta z przeznaczonej dla sekwencji funkcji count i określa liczbę wierszy w pliku. Do poprawnego zamknięcia czytnika służy instrukcja with-open. (with-open [rdr (reader "src/examples/utils.clj")] (count (line-seq rdr))) -> 64

Aby kod był bardziej przydatny, dodajmy filtr, który powoduje, że zliczane są tylko niepuste wiersze. (with-open [rdr (reader "src/examples/utils.clj")] (count (filter #(re-find #"\S" %) (line-seq rdr)))) -> 55

3

Na Twoim komputerze wynik będzie inny od pokazanego.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



103

Zastosowanie sekwencji dla systemu plików i zawartości poszczególnych plików pozwala szybko utworzyć ciekawe narzędzia. Zbudujmy program z trzema opisanymi poniżej predykatami.  Predykat non-blank? wykrywa niepuste wiersze.  Predykat non-svn? wykrywa pliki, które nie są metadanymi systemu Subversion.  Predykat clojure-source? wykrywa pliki z kodem źródłowym w języku Clojure. Teraz utwórzmy funkcję clojure-loc, która zlicza wiersze kodu języka Clojure w drzewie katalogów. W funkcji tej wykorzystujemy funkcje przeznaczone do obsługi sekwencji — reduce, for, count i filter. src/examples/sequences.clj

(use '[clojure.java.io :only (reader)]) (defn non-blank? [line] (if (re-find #"\S" line) true false)) (defn non-svn? [file] (not (.contains (.toString file) ".svn"))) (defn clojure-source? [file] (.endsWith (.toString file) ".clj")) (defn clojure-loc [base-file] (reduce + (for [file (file-seq base-file) :when (and (clojure-source? file) (non-svn? file))] (with-open [rdr (reader file)] (count (filter non-blank? (line-seq rdr)))))))

Teraz za pomocą funkcji w samym języku Clojure.

clojure-loc

można ustalić, ile wierszy kodu jest

(clojure-loc (java.io.File. "/home/abedra/src/opensource/clojure/clojure")) -> 38716

Funkcja clojure-loc wykonuje jedno konkretne zadanie, jednak ponieważ jest oparta na funkcjach przeznaczonych do obsługi sekwencji i prostych predykatach, można łatwo ją zmodyfikować pod kątem innych operacji.

Przekształcanie danych w XML-u na sekwencje W języku Clojure w sekwencjach można też umieszczać dane w XML-u. W dalszych przykładach używamy następującego pliku XML.

104



Programowanie w języku Clojure

data/sequences/compositions.xml

The Art of the Fugue Fantaisie-Impromptu Op. 66 Requiem

Funkcja clojure.xml/parse przetwarza plik lub strumień z danymi w XML-u albo identyfikator URI prowadzący do takich danych, a zwraca drzewo z danymi w postaci odwzorowania języka Clojure. Zagnieżdżone dane przechowywane są w wektorach. (use '[clojure.xml :only (parse)]) (parse (java.io.File. "data/sequences/compositions.xml")) -> {:tag :compositions, :attrs nil, :content [{:tag :composition, ... itd. ...

Uzyskanym odwzorowaniem można manipulować bezpośrednio, a można też użyć funkcji xml-seq i wyświetlić drzewa w formie sekwencji. (xml-seq root)

Tu w celu pobrania samych nazwisk kompozytorów używamy wyrażenia listowego dla wyniku wywołania funkcji xml-seq. src/examples/sequences.clj

(for [x (xml-seq (parse (java.io.File. "data/sequences/compositions.xml"))) :when (= :composition (:tag x))] (:composer (:attrs x))) -> ("J. S. Bach" "F. Chopin" "W. A. Mozart")

3.5. Funkcje przeznaczone dla konkretnych struktur Funkcje przeznaczone do obsługi sekwencji umożliwiają pisanie bardzo ogólnego kodu. Czasem warto jednak wykorzystać cechy konkretnej struktury danych. Clojure udostępnia funkcje, które są przeznaczone dla list, wektorów, odwzorowań, struktur i zbiorów.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



105

W tym podrozdziale pokrótce przedstawiamy wybrane z tych funkcji. Kompletną listę funkcji języka Clojure przeznaczonych dla konkretnych struktur znajdziesz w obszarze Data Structures w witrynie poświęconej temu językowi4.

Funkcje przeznaczone dla list Język Clojure udostępnia tradycyjne funkcje peek i pop służące do pobierania pierwszego elementu listy oraz jej pozostałych wartości. (peek coll) (pop coll)

Wywołajmy funkcje peek i pop dla prostej listy. (peek '(1 2 3)) -> 1 (pop '(1 2 3)) -> (2 3)

Funkcja peek działa tak samo jak instrukcja first, jednak działanie poleceń pop i rest nie jest takie samo. Funkcja pop zgłasza wyjątek, jeśli sekwencja jest pusta. (rest ()) -> () (pop ()) -> java.lang.IllegalStateException: Can't pop empty list

Funkcje przeznaczone dla wektorów Funkcje peek i pop są dostępne także dla wektorów, jednak tu zwracają elementy z końca wektora. (peek [1 2 3]) -> 3 (pop [1 2 3]) -> [1 2]

Funkcja get zwraca wartość elementu o podanym indeksie lub wykracza poza wektor. (get [:a :b :c] 1) -> :b (get [:a :b :c] 5) -> nil

4

http://clojure.org/data_structures

nil,

jeśli indeks

106



Programowanie w języku Clojure

Same wektory to też funkcje. Przyjmują argument w postaci indeksu i zwracają wartość lub — jeśli indeks wykracza poza wektor — zgłaszają wyjątek. ([:a :b :c] 1) -> :b ([:a :b :c] 5) -> java.lang.ArrayIndexOutOfBoundsException: 5

Funkcja assoc wiąże nową wartość z konkretnym indeksem. (assoc [0 1 2 3 4] 2 :two) -> [0 1 :two 3 4]

Funkcja subvec zwraca fragment wektora. (subvec avec start end?)

Jeśli argument end nie jest określony, domyślnie przyjmowany jest koniec wektora. (subvec [1 2 3 4 5] 3) -> [4 5] (subvec [1 2 3 4 5] 1 3) -> [2 3]

Można też oczywiście zasymulować działanie funkcji instrukcji drop i take.

subvec

przez połączenie

(take 2 (drop 1 [1 2 3 4 5])) -> (2 3)

Różnica między tymi dwoma podejściami polega na tym, że funkcje take i drop są ogólne, dlatego działają dla dowolnych sekwencji. Jednak funkcja subvec dla wektorów działa znacznie szybciej. Przeznaczone dla konkretnych struktur funkcje (na przykład subvec) działające identycznie jak ich odpowiedniki z biblioteki sekwencji są udostępniane zwykle z uwagi na wydajność. W dokumentacji takich funkcji wydajność jest szczegółowo opisana.

Funkcje przeznaczone dla odwzorowań Clojure udostępnia kilka funkcji do odczytu kluczy i wartości z odwzorowań. Funkcja keys zwraca sekwencję kluczy, a vals — sekwencję wartości. (keys map) (vals map)

Użyjmy funkcji keys i values dla prostego odwzorowania. (keys {:sundance "spaniel", :darwin "beagle"}) -> (:sundance :darwin) (vals {:sundance "spaniel", :darwin "beagle"}) -> ("spaniel" "beagle")

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



107

Funkcja get zwraca wartość klucza lub nil. (get map key value-if-not-found?)

W środowisku REPL można sprawdzić, czy funkcja sposób dla istniejących i nieistniejących kluczy.

get

działa w oczekiwany

(get {:sundance "spaniel", :darwin "beagle"} :darwin) -> "beagle" (get {:sundance "spaniel", :darwin "beagle"} :snoopy) -> nil

Istnieje mechanizm jeszcze prostszy od funkcji get. Odwzorowania są funkcjami dla kluczy. Można więc pominąć instrukcję get i umieścić odwzorowanie na pozycji funkcji (na początku konstrukcji). ({:sundance "spaniel", :darwin "beagle"} :darwin) -> "beagle" ({:sundance "spaniel", :darwin "beagle"} :snoopy) -> nil

Funkcjami są także słowa kluczowe. Przyjmują jako argument kolekcję i wyszukują w niej same siebie. Ponieważ :darwin i :sundance to słowa kluczowe, we wcześniejszych konstrukcjach można zmienić kolejność elementów. (:darwin {:sundance "spaniel", :darwin "beagle"} ) -> "beagle" (:snoopy {:sundance "spaniel", :darwin "beagle"} ) -> nil

Jeśli przy wyszukiwaniu klucza w odwzorowaniu zwracana wartość to nil, nie wiadomo, czy klucz jest nieobecny w odwzorowaniu, czy istnieje, ale ma wartość nil. Funkcja contains? rozwiązuje ten problem i pozwala stwierdzić, czy klucz znajduje się w odwzorowaniu. (contains? map key)

Utwórzmy odwzorowanie z elementem o wartości nil. (def score {:stu nil :joey 100})

Klucz :stu znajduje się w odwzorowaniu, ale po zobaczeniu wartości nil można zacząć podejrzewać, że jest inaczej. (:stu score) -> nil

Po zastosowaniu funkcji contains? można się przekonać, że :stu uczestniczy w grze (znajduje się w odwzorowaniu), ale nie radzi sobie zbyt dobrze (nie ma żadnych punktów).

108



Programowanie w języku Clojure

(contains? score :stu) -> true

Inne podejście polega na wywołaniu funkcji get i przekazaniu opcjonalnego trzeciego argumentu, zwracanego, jeśli klucza nie ma w odwzorowaniu. (get score :stu :score-not-found) -> nil (get score :aaron :score-not-found) -> :score-not-found

Domyślna zwracana wartość :score-not-found pozwala stwierdzić, że w odwzorowaniu nie ma klucza :aaron, natomiast występuje tu klucz :stu o wartości nil. Jeśli nil występuje jako wartość w odwzorowaniu, można użyć funkcji contains? lub trzyargumentowej wersji funkcji get do sprawdzenia istnienia klucza. Język Clojure udostępnia też kilka funkcji do tworzenia nowych odwzorowań.  Funkcja assoc zwraca odwzorowanie z dodaną parą klucz-wartość.  Funkcja dissoc zwraca odwzorowanie z usuniętym kluczem.  Funkcja select-keys zwraca odwzorowanie i zachowuje tylko przekazane klucze.  Funkcja merge łączy odwzorowania. Jeśli dany klucz znajduje się w kilku odwzorowaniach, zachowywana jest jego wartość z odwzorowania pierwszego od prawej. Aby sprawdzić działanie tych funkcji, utwórzmy dane dotyczące utworu muzycznego. src/examples/sequences.clj

(def song {:name "Agnus Dei" :artist "Krzysztof Penderecki" :album "Polish Requiem" :genre "Classical"})

Następnie utwórzmy różne zmodyfikowane wersje kolekcji z danymi dotyczącymi utworu. (assoc song :kind "MPEG Audio File") -> {:name "Agnus Dei", :album "Polish Requiem", :kind "MPEG Audio File", :genre "Classical", :artist "Krzysztof Penderecki"} (dissoc song :genre) -> {:name "Agnus Dei", :album "Polish Requiem", :artist "Krzysztof Penderecki"} (select-keys song [:name :artist]) -> {:name "Agnus Dei", :artist "Krzysztof Penderecki"}

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



109

(merge song {:size 8118166, :time 507245}) -> {:name "Agnus Dei", :album "Polish Requiem", :genre "Classical", :size 8118166, :artist "Krzysztof Penderecki", :time 507245}

Warto pamiętać, że sama kolekcja song nigdy się nie zmienia. Każda z zastosowanych funkcji zwraca nową kolekcję. Najciekawszą funkcją do tworzenia odwzorowań jest merge-with. (merge-with merge-fn & maps)

Funkcja merge-with działa podobnie jak funkcja merge, pozwala jednak określić funkcję, która łączy wartości tych samych kluczy z różnych kolekcji. Użyjmy funkcji merge-with i concat do utworzenia sekwencji wartości powiązanych z poszczególnymi kluczami. (merge-with concat {:rubble ["Barney"], :flintstone ["Fred"]} {:rubble ["Betty"], :flintstone ["Wilma"]} {:rubble ["Bam-Bam"], :flintstone ["Pebbles"]}) -> {:rubble ("Barney" "Betty" "Bam-Bam"), :flintstone ("Fred" "Wilma" "Pebbles")}

Zaczynamy od trzech różnych kolekcji z imionami członków rodzin. W kolekcjach tych kluczami są nazwiska. Instrukcje łączą te kolekcje w pojedynczą kolekcję z kluczami w postaci nazwisk.

Funkcje przeznaczone dla zbiorów Oprócz przeznaczonych dla zbiorów funkcji z przestrzeni nazw clojure język Clojure udostępnia grupę funkcji z przestrzeni nazw clojure.set. Aby móc stosować te funkcje bez podawania pełnych nazw, należy wywołać w środowisku REPL instrukcję (use 'clojure.set). W dalszych przykładach potrzebne są też podane poniżej zmienne. src/examples/sequences.clj

(def languages #{"java" "c" "d" "clojure"}) (def beverages #{"java" "chai" "pop"})

Pierwsza grupa funkcji z przestrzeni nazw clojure.set wykonuje operacje z teorii zbiorów.  Funkcja union zwraca zbiór wszystkich elementów ze zbiorów wejściowych.  Funkcja intersection zwraca zbiór wszystkich elementów występujących w obu zbiorach wejściowych.

110

Programowanie w języku Clojure

 



Funkcja difference zwraca zbiór wszystkich elementów występujących w pierwszym zbiorze wejściowym, ale bez elementów pojawiających się w drugim zbiorze. Funkcja select zwraca zbiór wszystkich elementów spełniających predykat.

Napiszmy wyrażenie, które znajduje sumę zbiorów z nazwami języków i napojów. (union languages beverages) -> #{"java" "c" "d" "clojure" "chai" "pop"}

Teraz pobierzmy nazwy języków, które nie są jednocześnie nazwami napojów. (difference languages beverages) -> #{"c" "d" "clojure"}

Jeśli lubisz kiepskie gry słowne, może zaciekawi Cię to, że niektóre elementy znajdują się zarówno w zbiorze z nazwami języków, jak i w zbiorze z nazwami napojów. (intersection languages beverages) -> #{"java"}

Nazwy zaskakująco wielu języków składają się z tylko jednej litery. (select #(= 1 (.length %)) languages) -> #{"c" "d"}

Suma i różnica to operacje z teorii zbiorów, a także z algebry relacji, która stanowi podstawę języków zapytań (na przykład SQL-a). Algebra relacji obejmuje sześć podstawowych operacji — opisane wcześniej sumę zbiorów i różnicę zbiorów, a także operacje zmiany nazwy, selekcji, projekcji i obliczania iloczynu wektorowego. Działanie podstawowych operacji relacyjnych można zrozumieć na przykładzie relacyjnych baz danych (zobacz tabelę poniżej). Algebra relacji

Baza danych

System typów w Clojure

Relacja

Tabela

Wszystko podobne do zbioru (set)

Krotka

Wiersz

Wszystko podobne do odwzorowania (map)

Dalsze przykłady działają na przechowywanej w pamięci bazie danych utworów muzycznych. Przed przejściem do tych przykładów należy wczytać wspomnianą bazę. src/examples/sequences.clj

(def compositions #{{:name "The Art of the Fugue" :composer "J. S. Bach"} {:name "Musical Offering" :composer "J. S. Bach"} {:name "Requiem" :composer "Giuseppe Verdi"}

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



111

{:name "Requiem" :composer "W. A. Mozart"}}) (def composers #{{:composer "J. S. Bach" :country "Niemcy"} {:composer "W. A. Mozart" :country "Austria"} {:composer "Giuseppe Verdi" :country "Włochy"}}) (def nations #{{:nation "Niemcy" :language "niemiecki"} {:nation "Austria" :language "niemiecki"} {:nation "Włochy" :language "włoski"}})

Funkcja rename zmienia nazwy kluczy (kolumn bazy danych) na nowe na podstawie odwzorowania między pierwotnymi nazwami. (rename relation rename-map)

Można zmodyfikować bazę przez użycie dla kluczy nazwy title zamiast name. (rename compositions {:name :title}) -> #{{:title "Requiem", :composer "Giuseppe Verdi"} {:title "Musical Offering", :composer "J.S. Bach"} {:title "Requiem", :composer "W. A. Mozart"} {:title "The Art of the Fugue", :composer "J.S. Bach"}}

Funkcja select zwraca odwzorowania, dla których predykat ma wartość true. Predykat jest tu odpowiednikiem klauzuli WHERE z SQL-owej instrukcji SELECT. (select pred relation)

Napiszmy wyrażenie select, które wyszukuje wszystkie utwory o tytule "Requiem". (select #(= (:name %) "Requiem") compositions) -> #{{:name "Requiem", :composer "W. A. Mozart"} {:name "Requiem", :composer "Giuseppe Verdi"}}

Funkcja project zwraca tylko fragmenty odwzorowań pasujące do zbioru kluczy. (project relation keys)

Funkcja project działa podobnie jak SQL-owa instrukcja SELECT (w której określa się podzbiór kolumn). Napiszmy projekcję, która zwraca tylko tytuły utworów. (project compositions [:name]) -> #{{:name "Musical Offering"} {:name "Requiem"} {:name "The Art of the Fugue"}}

Ostatnia podstawowa operacja relacyjna, iloczyn wektorowy, jest podstawą różnego rodzaju złączeń w relacyjnych bazach danych. Iloczyn wektorowy zwraca każdą możliwą kombinację wierszy z różnych tabel. W Clojure można go otrzymać stosunkowo łatwo, używając wyrażenia listowego. (for [m compositions c composers] (concat m c)) -> ... 4 x 3 = 12 wierszy ...

112



Programowanie w języku Clojure

Choć iloczyn wektorowy jest ciekawy, zwykle potrzebny jest tylko podzbiór pełnego iloczynu. Można na przykład złączyć zbiory (przez wywołanie instrukcji join) na podstawie wspólnych kluczy. (join relation-1 relation-2 keymap?)

Złączmy kompozytorów utworów i ich tytuły na podstawie wspólnego klucza :composer. (join compositions composers) -> #{{:name "Requiem", :country "Austria", :composer "W. A. Mozart"} {:name "Musical Offering", :country "Germany", :composer "J. S. Bach"} {:name "Requiem", :country "Italy", :composer "Giuseppe Verdi"} {:name "The Art of the Fugue", :country "Germany", :composer "J. S. Bach"}}

Jeśli nazwy kluczy z dwóch relacji nie pasują do siebie, można przekazać odwzorowanie keymap, które odwzorowuje nazwy kluczy z relacji relation-1 na odpowiadające im klucze z relacji relation-2. Można na przykład złączyć relację composers, w której występuje klucz :country, z relacją nations, obejmującą klucz :nation. Oto potrzebny kod: (join composers nations {:country :nation}) -> #{{:language "German", :nation "Austria", :composer "W. A. Mozart", :country "Austria"} {:language "German", :nation "Germany", :composer "J. S. Bach", :country "Germany"} {:language "Italian", :nation "Italy", :composer "Giuseppe Verdi", :country "Italy"}}

Podstawowe operacje relacyjne można łączyć ze sobą. Załóżmy, że programista chce ustalić zbiór wszystkich państw, z których pochodzą kompozytorzy requiem. Można użyć instrukcji select do znalezienia wszystkich tego typu utworów, następnie złączyć je z odpowiednimi kompozytorami, a w ostatnim kroku wywołać instrukcję project, aby ograniczyć wyniki do nazw państw. (project (join (select #(= (:name %) "Requiem") compositions) composers) [:country]) -> #{{:country "Italy"} {:country "Austria"}}

Analogia między algebrą relacji z języka Clojure a relacyjnymi bazami danych jest pouczająca. Warto jednak pamiętać, że algebra relacji w Clojure jest narzędziem ogólnego przeznaczenia. Można ją stosować do dowolnych relacyjnych danych ze zbiorów. Co więcej, algebra relacji pozwala korzystać z wszystkich możliwości języków Clojure i Java.

Rozdział 3. • Ujednolicanie danych za pomocą sekwencji



113

3.6. Podsumowanie W Clojure wszystkie kolekcje są ujednolicone w ramach jednej abstrakcji — sekwencji. Po ponad 10 latach dominacji programowania obiektowego bibliotekę sekwencji z języka Clojure można uznać za „zemstę czasowników”. Sekwencje w Clojure są zaimplementowane za pomocą mechanizmów programowania funkcyjnego: niezmiennych danych, rekurencyjnych definicji i „leniwego” wykonania. W następnym rozdziale dowiesz się, jak stosować te techniki, co pozwoli Ci wykorzystać dodatkowe możliwości omawianego języka.

114



Programowanie w języku Clojure

Rozdział 4.

Programowanie funkcyjne

P

rogramowanie funkcyjne to obszerne zagadnienie. Nie da się go nauczyć w 21 dni1 ani omówić w jednym rozdziale książki. Można jednak stosunkowo szybko osiągnąć pierwszy poziom wtajemniczenia i poznać techniki „leniwego” wartościowania i rekurencyjne z języka Clojure. Tego właśnie dotyczy ten rozdział. Oto, czego dowiesz się z tego rozdziału:  W podrozdziale 4.1, „Zagadnienia z obszaru programowania funkcyjnego”, znajdziesz krótki przegląd pojęć i pomysłów z zakresu programowania funkcyjnego. Podrozdział ten obejmuje także „Sześć reguł programowania funkcyjnego w języku Clojure”, do których odwołujemy się dalej w rozdziale.  W podrozdziale 4.2, „Jak stosować »leniwe« podejście?”, poznasz wartość „leniwych” sekwencji. Tworzymy tu kilka implementacji funkcji generującej liczby (inaczej ciąg) Fibonacciego. Zaczynamy od bardzo kiepskiego rozwiązania i dochodzimy do eleganckiego, „leniwego” podejścia.

1

http://norvig.com/21-days.html

116

 



Programowanie w języku Clojure

Wprawdzie „leniwe” sekwencje są wygodne, jednak rzadko trzeba korzystać z nich bezpośrednio. W podrozdziale 4.3, „Leniwsze niż leniwe”, zobaczysz, jak zmienić podejście do problemów, tak aby móc rozwiązywać je bezpośrednio za pomocą biblioteki sekwencji opisanej w rozdziale 3., „Ujednolicanie danych za pomocą sekwencji”. W podrozdziale 4.4, „Jeszcze o rekurencji”, omawiamy pewne zaawansowane zagadnienia. Niektórzy programiści nigdy nie będą potrzebować opisanych tu technik. Jeśli dopiero zaczynasz przygodę z programowaniem funkcyjnym, możesz pominąć ten podrozdział.

4.1. Zagadnienia z obszaru programowania funkcyjnego Programowanie funkcyjne prowadzi do powstawania kodu, który jest łatwy w pisaniu, czytaniu, testowaniu i ponownym użyciu. Dalej opisujemy to podejście.

Czyste funkcje Programy zbudowane są z czystych funkcji. Są to funkcje, które nie powodują efektów ubocznych. Oznacza to, że nie korzystają z żadnych danych oprócz otrzymanych argumentów, a na zewnętrzny kod wpływają tylko poprzez zwracaną wartość. Czystymi funkcjami są na przykład funkcje matematyczne. Dwa plus dwa to cztery niezależnie od tego, gdzie i kiedy padnie to pytanie. Ponadto pytanie to nie powoduje żadnych innych operacji oprócz zwrócenia odpowiedzi. Zwracanie danych przez program nie jest czystym podejściem. Na przykład wywołanie instrukcji println wywołuje w zewnętrznym świecie zmiany, ponieważ prowadzi do umieszczenia danych w strumieniu wyjściowym. Ponadto efekt wywołania instrukcji println zależy od stanu poza funkcją. Standardowy strumień wyjścia może być zamknięty lub uszkodzony, a program może przekierowywać dane gdzie indziej. Jeśli zaczniesz pisać czyste funkcje, szybko zauważysz, że powiązane z nimi są niezmienne dane. Przyjrzyj się funkcji mystery. (defn mystery [input] (if input data-1 data-2))

Rozdział 4. • Programowanie funkcyjne



117

Jeśli mystery to czysta funkcja, to niezależnie od sposobu jej działania data-1 i data-2 muszą być niezmienne! W przeciwnym razie zmiany w danych powodowałyby, że funkcja mogłaby zwrócić różne wartości dla tego samego argumentu input. Jedne zmienne dane mogą zakłócić proces i sprawić, że cały łańcuch wywołań funkcji przestanie być czysty. Dlatego po zdecydowaniu się na pisanie czystych funkcji w dużych częściach aplikacji należy używać niezmiennych danych.

Trwałe struktury danych W języku Clojure niezmienne dane są bardzo ważne w modelu programowania funkcyjnego i zarządzania stanem. W obszarze programowania funkcyjnego czyste funkcje nie mogą powodować efektów ubocznych, na przykład aktualizować stanu zmiennych obiektów. W zakresie stanu typy referencyjne języka Clojure wymagają stosowania niezmiennych struktur danych, jeśli realizowane mają być gwarancje z obszaru współbieżności. Problemem w omawianym podejściu jest wydajność. Jeśli wszystkie dane są niezmienne, „aktualizacja” polega na tworzeniu kopii pierwotnych danych z wprowadzonymi zmianami. Prowadzi to do szybkiego zużycia pamięci. Załóżmy, że książka adresowa zajmuje pięć megabajtów pamięci. Wprowadzamy pięć drobnych poprawek. Jeśli książka adresowa jest zapisana w zmiennej strukturze, nadal zajmuje pięć megabajtów. Jednak jeżeli przy każdej aktualizacji trzeba skopiować całą książkę, wersja niezmienna będzie zajmować 25 megabajtów! Struktury danych w Clojure nie działają w opisanym wcześniej naiwnym modelu „kopiuj wszystko”. Wszystkie struktury danych języka Clojure są trwałe. Oznacza to, że zachowywane są dawne kopie danych dzięki zastosowaniu wydajnego współużytkowania struktury między starszymi i nowszymi wersjami. Współużytkowanie struktur najłatwiej przedstawić za pomocą list. Załóżmy, że lista obejmuje dwa elementy. (def a '(1 2)) -> #'user/a

Następnie na podstawie listy a można utworzyć b z dodatkowym elementem. (def b (cons 0 a)) -> #'user/b

W b można ponownie wykorzystać całą strukturę listy a, zamiast tworzyć odrębną prywatną kopię.

118



Programowanie w języku Clojure

Wszystkie struktury danych języka Clojure współużytkują dane (jeśli jest to możliwe). Dla struktur innych niż proste listy mechanizm ten jest oczywiście bardziej skomplikowany. Jeśli interesują Cię szczegóły, zapoznaj się z następującymi artykułami: 2  Ideal Hash Trees Phila Bagwella, 3  Understanding Clojure’s PersistentVector Implementation Karla Krukowa.

„Leniwe” podejście i rekurencja W programach funkcyjnych często wykorzystuje się rekurencję i „leniwe” podejście. Rekurencja polega na wywoływaniu funkcji przez nią samą (bezpośrednio lub pośrednio). W „leniwym” podejściu wartościowanie wyrażenia jest odkładane do momentu, w którym potrzebny jest wynik. Wartościowanie „leniwego” wyrażenia to jego realizacja. W Clojure funkcje i wyrażenia nie są „leniwe”. Jednak sekwencje przeważnie są. Ponieważ tak duża część programowania w Clojure polega na manipulowaniu sekwencjami, programiści uzyskują wiele korzyści w pełni „leniwego” języka. Przede wszystkim mogą tworzyć skomplikowane wyrażenia za pomocą „leniwych” sekwencji i ponosić koszty obliczania tylko tych elementów, które są potrzebne. Techniki w „leniwym” podejściu związane są z czystymi funkcjami. Moment wywołania czystej funkcji nie ma znaczenia, ponieważ zawsze zwraca ona tę samą wartość. Zwykłe funkcje gorzej współdziałają z „leniwymi” technikami. Programista musi bezpośrednio kontrolować moment wywoływania takich funkcji, ponieważ uruchomienie ich w innym czasie może prowadzić do innych efektów!

Przejrzystość referencyjna W „leniwym” podejściu zawsze możliwe musi być zastąpienie wywołania funkcji zwracanym przez nią wynikiem. Funkcje, które to umożliwiają, są przejrzyste referencyjnie (ang. referentially transparent), ponieważ wywołanie takiej funkcji można zastąpić bez zmieniania działania programu. Funkcje tego rodzaju, 2 3

http://lampwww.epfl.ch/papers/idealhashtress.pdf http://tinyurl.com/clojure-persistent-vector

Rozdział 4. • Programowanie funkcyjne



119

oprócz możliwości stosowania „leniwego” podejścia, mają też inne zalety, ponieważ pozwalają na:  memoizację, czyli automatycznie zapisywanie wyników w pamięci podręcznej;  automatyczne równoległe wykonanie, czyli przeniesienie wykonywania funkcji na inny procesor lub komputer. Czyste funkcje są z definicji przejrzyste referencyjnie. Większość innych funkcji taka nie jest. Bezpieczeństwo nielicznych zwykłych funkcji przejrzystych referencyjnie trzeba udowodnić przez analizę kodu.

Zalety programowania funkcyjnego Wyjaśniliśmy już wiele pojęć, a wcześniej obiecaliśmy, że dzięki podejściu funkcyjnemu kod będzie łatwiejszy w pisaniu, czytaniu, testowaniu i łączeniu. Oto, jak osiągnąć takie efekty. Kod funkcyjny jest łatwiejszy w pisaniu, ponieważ potrzebne informacje są łatwo dostępne — znajdują się na liście argumentów funkcji. Nie trzeba sprawdzać informacji z zasięgu globalnego, sesji, aplikacji lub wątku. Z tych samych powodów kod funkcyjny jest łatwiejszy do czytania. Kod, który łatwiej się czyta i pisze, łatwiej się także testuje. Ponadto kod funkcyjny jest łatwiejszy do testowania także z innych względów. Dla dużych projektów często trudno jest skonfigurować odpowiednie środowisko wykonywania testów. Dla kodu funkcyjnego jest to dużo prostsze, ponieważ takie środowisko nie istnieje (ważne są tylko argumenty funkcji). Podejście funkcyjne ułatwia wielokrotne wykorzystanie kodu. W celu ponownego wykorzystania kodu trzeba:  znaleźć i zrozumieć fragment przydatnego kodu,  połączyć kod wielokrotnego użytku z innym kodem. Czytelność kodu funkcyjnego pomaga znaleźć i zrozumieć potrzebne funkcje, jednak jeszcze cenniejsza jest zaleta w postaci łatwości łączenia kodu. Łączenie kodu to poważny problem. Przez lata programiści stosowali hermetyzację (ang. encapsulation) do tworzenia kodu, który można łączyć z innymi fragmentami. Hermetyzacja pozwala ukryć kod. W tym podejściu dostęp do danych możliwy jest tylko poprzez publiczny interfejs API.

120



Programowanie w języku Clojure

Hermetyzacja jest pomocna, ale niewystarczająca. Nawet przy stosowaniu hermetycznych obiektów, przy próbie złożenia całego systemu występuje zbyt wiele zaskakujących interakcji. Problemem są efekty uboczne. Funkcje, które nie są czyste, naruszają hermetyzację, ponieważ prowadzą do (niewidocznego!) kontaktu ze światem zewnętrznym i zmiany działania kodu. Czyste funkcje są naprawdę hermetyczne i można je łączyć ze sobą. Można umieścić je w dowolnym miejscu systemu, i zawsze będą działać tak samo.

Sześć reguł Choć zalety programowania funkcyjnego są bardzo atrakcyjne, stosowanie tego podejścia wymaga całkowitej zmiany nastawienia w porównaniu z programowaniem imperatywnym, które obecnie jest dominującym stylem. Ponadto w języku Clojure zastosowano nietypowe podejście do programowania funkcyjnego, aby zachować równowagę między akademicką ścisłością a praktycznymi aspektami związanymi z poprawnym działaniem języka w maszynach JVM. Dlatego w krótkim czasie trzeba przyswoić dużo informacji. Nie obawiaj się jednak. Jeśli dopiero uczysz się programowania funkcyjnego, opisanych tu sześć reguł programowania funkcyjnego w języku Clojure pomoże Ci zrobić pierwsze kroki na drodze do opanowania tego podejścia w stylu charakterystycznym dla tego języka. 1. Unikaj bezpośredniego stosowania rekurencji. Maszyny JVM nie potrafią optymalizować rekurencyjnych wywołań, a rekurencyjne programy w języku Clojure mogą zająć całą pamięć na stosie. 2. Instrukcję recur stosuj do tworzenia wartości skalarnych lub krótkich, stałych sekwencji. Język Clojure optymalizuje wywołania z instrukcją recur. 3. Przy tworzeniu sekwencji o zmiennej długości lub dużych stosuj „leniwe” podejście (nie korzystaj z rekurencji). Przy tym podejściu jednostki wywołujące mogą korzystać tylko z potrzebnej im części sekwencji. 4. Uważaj, aby nie realizować większego fragmentu „leniwych” sekwencji, niż jest to konieczne. 5. Poznaj bibliotekę sekwencji. Często można pisać kod bez stosowania instrukcji recur i interfejsów API dla „leniwego” podejścia. 6. Dziel zadania. Dziel nawet pozornie proste problemy na mniejsze fragmenty. Często pozwala to znaleźć w bibliotece sekwencji rozwiązanie prowadzące do powstawania bardziej ogólnego kodu wielokrotnego użytku.

Rozdział 4. • Programowanie funkcyjne



121

Ważne są zwłaszcza reguły 5. i 6. Jeśli dopiero zaczynasz przygodę z programowaniem funkcyjnym, możesz zinterpretować je tak: „pomiń ten rozdział i stosuj techniki z rozdziału 3., »Ujednolicanie danych za pomocą sekwencji«, do czasu wystąpienia problemów”. Sześć przedstawionych tu reguł (podobnie jak większość zasad) to tylko wskazówki. Kiedy lepiej poznasz programowanie funkcyjne, będziesz wiedział, kiedy warto łamać te zasady. Teraz pora przejść do pisania kodu funkcyjnego.

4.2. Jak stosować „leniwe” podejście? W programach funkcyjnych często stosuje się definicje rekurencyjne. Definicja rekurencyjna składa się z dwóch części. Oto one:  przypadek bazowy, w którym bezpośrednio wymienione są pewne elementy sekwencji;  indukcja, określająca reguły łączenia elementów sekwencji w celu uzyskania dalszych elementów. W tym podrozdziale zajmujemy się przekształcaniem definicji rekurencyjnej na działający kod. Taki kod można uzyskać na kilka sposobów. Oto one:  prosta rekurencja, polegająca na wywoływaniu funkcji w niej samej w celu zrealizowania etapu indukcji;  rekurencja końcowa, kiedy to funkcja wywołuje siebie tylko na końcu; to podejście pozwala zastosować ważną optymalizację;  „leniwe” sekwencje, które pozwalają pominąć rekurencję i obliczać wartość później, kiedy jest potrzebna. Wybór odpowiedniego podejścia jest ważny. Zła implementacja definicji rekurencyjnej może prowadzić do powstania bardzo niewydajnego kodu, zajęcia całej dostępnej pamięci na stosie i awarii, zajęcia całej dostępnej pamięci na stercie i awarii, lub wystąpienia wszystkich tych problemów. W języku Clojure prawidłowym rozwiązaniem często jest zastosowanie „leniwego” podejścia. Wszystkie opisane techniki przedstawiamy na przykładzie generowania liczb Fibonacciego. Liczby te zostały nazwane w uznaniu zasług włoskiego matematyka Leonardo Fibonacciego z Pizy (ok. 1170 – 1250), były jednak znane matematykom indyjskim już w 200 roku p.n.e. Liczby Fibonacciego mają wiele ciekawych cech i często pojawiają się w algorytmach, strukturach danych,

122

Programowanie w języku Clojure



a nawet biologii4. Dla liczb Fibonacciego można podać bardzo prostą definicję rekurencyjną. Oto ona:  Przypadek bazowy: F0, zerowa liczba Fibonacciego, to 0. F1, pierwsza liczba Fibonacciego, to 1.  Indukcja: dla n > 1 Fn równa się Fn-1 + Fn-2. Oto dziesięć pierwszych liczb Fibonacciego uzyskanych na podstawie tej definicji: (0 1 1 2 3 5 8 13 21 34)

Zacznijmy od zaimplementowania ciągu Fibonacciego za pomocą prostej rekurencji. Poniższa funkcja języka Clojure zwraca n-tą liczbę Fibonacciego. src/examples/functional.clj

1 ; Zły pomysł. 2 (defn stack-consuming-fibo [n] 3 (cond 4 (= n 0) 0 5 (= n 1) 1 6 :else (+ (stack-consuming-fibo (- n 1)) 7 (stack-consuming-fibo (- n 2)))))

W wierszach 4. i 5. zdefiniowany jest przypadek podstawowy, a w wierszu 6. — indukcja. Kod jest rekurencyjny, ponieważ funkcja stack-consuming-fibo wywołuje samą siebie w wierszach 6. i 7. Sprawdźmy, czy funkcja wartości.

stack-consuming-fibo

działa prawidłowo dla

n

o małej

(stack-consuming-fibo 9) -> 34

Do tej pory wszystko działa dobrze. Jednak przy obliczaniu większych liczb Fibonacciego, na przykład F1000000, pojawia się problem. (stack-consuming-fibo 1000000) -> StackOverflowError clojure.lang.Numbers.minus (Numbers.java:1837)

Z powodu rekurencji każde wywołanie funkcji stack-consuming-fibo dla n > 1 powoduje dwa następne wywołania tej funkcji. Na poziomie maszyny JVM tym wywołaniom odpowiadają wywołania metody, a każde z nich prowadzi do zaalokowania struktury danych w postaci ramki stosu5.

4 5

http://pl.wikipedia.org/wiki/Ciąg_Fibonacciego Więcej informacji o zarządzaniu stosem przez maszynę JVM znajdziesz w punkcie „Runtime Data Areas” specyfikacji ze strony http://tinyurl.com/jvm-spec-toc.

Rozdział 4. • Programowanie funkcyjne



123

Funkcja stack-consuming-fibo tworzy ramki stosu w liczbie proporcjonalnej do n, co powoduje szybkie wyczerpanie się stosu maszyny JVM i zgłoszenie wyjątku StackOverflowError. Ponadto łączna liczba ramek stosu rośnie wykładniczo względem n, dlatego wydajność kodu jest bardzo niska, nawet jeśli nie następuje przepełnienie stosu. Wywołania funkcji języka Clojure zużywają stos, ponieważ wymagają alokacji ramek stosu zajmujących pamięć tej struktury. W języku Clojure prawie zawsze należy unikać rekurencji zużywającej stos, takiej, jaką zastosowano w funkcji stack-consuming-fibo.

Rekurencja końcowa W programach funkcyjnych problem zajmowania stosu można rozwiązać za pomocą rekurencji końcowej (czasem nazywanej też rekurencją ogonową). Funkcja napisana w tym modelu jest rekurencyjna, ale rekurencyjne wywołanie musi znajdować się na samym końcu — w wyrażeniu zwracającym wartość funkcji. Pozwala to zastosować w językach optymalizację TCO (ang. tail-call optimization), polegającą na przekształceniu rekurencji końcowej na iteracje, które nie zajmują stosu. Obliczanie liczb Fibonacciego w funkcji stack-consuming-fibo nie odbywa się za pomocą rekurencji końcowej, ponieważ operacja dodawania (+) znajduje się po obu wywołaniach tej funkcji. Aby zastosować rekurencję końcową, trzeba utworzyć funkcję, której argumenty obejmują na tyle dużo informacji, żeby można było przejść do kolejnego kroku indukcyjnego. Nie należy przy tym wykonywać dodatkowych operacji (na przykład dodawania) po wywołaniu funkcji, ponieważ rekurencja nie będzie wtedy znajdować się w ostatnim wyrażeniu. Przy obliczaniu liczb Fibonacciego funkcja musi znać dwie takie liczby, a także liczbę porządkową n, co pozwala odliczać do zera przy obliczaniu nowych liczb. Oto kod funkcji tail-fibo: src/examples/functional.clj

1 (defn tail-fibo [n] 2 (letfn [(fib 3 [current next n] 4 (if (zero? n) 5 current 6 (fib next (+ current next) (dec n))))] 7 (fib 0N 1N n)))

W wierszu 2. pojawia się makro letfn. (letfn fnspecs & body) ; fnspecs ==> [(fname [params*] exprs)+]

124



Programowanie w języku Clojure

Makro letfn działa podobnie jak let, ale jest przeznaczone dla funkcji lokalnych. Każda funkcja zadeklarowana w takim makro może wywoływać siebie samą lub inną funkcję w tym samym bloku letfn. W wierszu 3. zadeklarowano, że funkcja fib przyjmuje trzy argumenty — bieżącą liczbę Fibonacciego (current), następną liczbę Fibonacciego (next) i liczbę kroków do wykonania (n). W wierszu 5. kod zwraca wartość current, jeśli nie trzeba wykonywać dalszych kroków, a w wierszu 6. funkcja kontynuuje obliczenia i zmniejsza liczbę kroków o jeden. W wierszu 7. uruchamiana jest rekurencja dla wartości z przypadku bazowego (0 i 1) oraz wartości n określającej szukaną liczbę Fibonacciego. Funkcja tail-fibo działa dobrze dla małych wartości n. (tail-fibo 9) -> 34N

Jednak choć zastosowano rekurencję końcową, dla dużych wartości zawodzi.

n

funkcja

(tail-fibo 1000000) -> StackOverflowError java.lang.Integer.numberOfLeadingZeros (Integer.java:1054)

Źródłem problemu jest tu maszyna JVM. Choć języki funkcyjne, na przykład Haskell, potrafią przeprowadzać optymalizację TCO, maszyny JVM nie zaprojektowano z myślą o językach funkcyjnych. Żaden język działający bezpośrednio na maszynie JVM nie potrafi automatycznie przeprowadzać optymalizacji TCO6. Brak optymalizacji TCO jest niefortunny, jednak nie stanowi to przeszkody dla autorów programów funkcyjnych. Clojure udostępnia kilka programowych rozwiązań — bezpośrednią autorekurencję (instrukcja recur), „leniwe” sekwencje i bezpośrednią wzajemną rekurencję (instrukcja trampoline).

Stosowanie autorekurencji za pomocą instrukcji recur Specjalnym przypadkiem rekurencji, który można zoptymalizować w maszynie JVM, jest autorekurencja. Na szczęście funkcja tail-fibo jest przykładem zastosowania tego podejścia — wywołuje samą siebie bezpośrednio, a nie poprzez serię funkcji pośrednich. 6

W dzisiejszych maszynach JVM języki potrafią wykonywać automatyczną optymalizację TCO dla niektórych typów rekurencji, ale nie dla wszystkich. Ponieważ nie istnieje uniwersalne rozwiązanie, w języku Clojure trzeba bezpośrednio określać działanie kodu. Kiedy (i jeśli) optymalizacja TCO zostanie powszechnie wprowadzona w maszynach JVM, będzie obsługiwana także w języku Clojure.

Rozdział 4. • Programowanie funkcyjne



125

W języku Clojure można przekształcić funkcję z rekurencją końcową na funkcję z bezpośrednią autorekurencją. Umożliwia to instrukcja recur. Funkcja recur-fibo to przekształcona w ten sposób wersja funkcji tail-fibo. src/examples/functional.clj

1 ; Lepiej, ale jeszcze nie idealnie. 2 (defn recur-fibo [n] 3 (letfn [(fib 4 [current next n] 5 (if (zero? n) 6 current 7 (recur next (+ current next) (dec n))))] 8 (fib 0N 1N n)))

Najważniejsza różnica między funkcjami tail-fibo i recur-fibo występuje w wierszu 7., gdzie zamiast wywołania fib znajduje się instrukcja recur. Funkcja recur-fibo nie zajmuje stosu w trakcie obliczania liczb Fibonacciego i pozwala wyznaczyć wartość Fn dla dużego n (jeśli użytkownik jest cierpliwy). (recur-fibo 9) -> 34N (recur-fibo 1000000) -> 195 ... 208 982 innych cyfr ... 875N

Pełna wartość F1000000 znajduje się w przykładowym kodzie w pliku output/ f-1000000. Funkcja recur-fibo oblicza jedną liczbę Fibonacciego. Co jednak zrobić, jeśli potrzebujemy kilku takich liczb? Kilkukrotne wywołanie funkcji recur-fibo to marnotrawstwo, ponieważ praca wykonana w poprzednich wywołaniach nie jest zapisywana na potrzeby późniejszych wywołań. Ile jednak wartości warto zapisywać w pamięci podręcznej? Które z nich wybrać? Te kwestie powinna określać jednostka wywołująca funkcję, a nie jej autor. Najlepsze podejście to definiowanie sekwencji z interfejsem API, w którym nie ma referencji do konkretnego przedziału potrzebnego klientowi. Dzięki temu to klient za pomocą instrukcji take i drop może wskazać potrzebny przedział. Właśnie tak działają „leniwe” sekwencje.

„Leniwe” sekwencje Do tworzenia „leniwych” sekwencji służy makro lazy-seq. (lazy-seq & body)

126



Programowanie w języku Clojure

Makro lazy-seq wywołuje kod ciała (sekcja body) tylko wtedy, kiedy jest to potrzebne — czyli po bezpośrednim lub pośrednim wywołaniu sekwencji. Wtedy makro zapisuje w pamięci podręcznej wyniki kolejnych wywołań. Za pomocą makra lazy-seq można zdefiniować „leniwą” wersję funkcji do generowania liczb Fibonacciego. src/examples/functional.clj

1 (defn lazy-seq-fiboLine 2 ([] 3 (concat [0 1] (lazy-seq-fibo 0N 1N))) 4 ([a b] 5 (let [n (+ a b)] 6 (lazy-seq 7 (cons n (lazy-seq-fibo b n))))))

W wierszu 3. pozbawione argumentów ciało makra zwraca połączenie wartości dla przypadku podstawowego ([0 1]), a następnie wywołuje dwuargumentowe ciało w celu obliczenia pozostałych wartości. W wierszu 5. dwuargumentowe ciało oblicza wartość następnego n ciągu, a w wierszu 7. wywołuje operację cons, aby połączyć n z pozostałymi wartościami. Najważniejszy jest tu wiersz 6., który sprawia, że kod działa w „leniwy” sposób. Bez tego wiersza rekurencyjne wywołanie lazy-seq-fibo z wiersza 7. byłoby wykonywane natychmiast, a dalsze rekurencyjne wywołania tej funkcji powodowałyby wyczerpanie pamięci na stosie. Jest to ilustracja ogólnego wzorca — należy umieścić rekurencyjną część ciała funkcji w wywołaniu lazy-seq, aby zamiast rekurencji zastosować „leniwe” podejście. Funkcja lazy-seq-fibo działa poprawnie dla małych wartości. (take 10 (lazy-seq-fibo)) -> (0 1 1N 2N 3N 5N 8N 13N 21N 34N)

Funkcja ta działa prawidłowo także dla dużych wartości. Użyjmy instrukcji (rem ... 1000), aby wyświetlić tylko ostatnie trzy cyfry milionowej liczby Fibonacciego. (rem (nth (lazy-seq-fibo) 1000000) 1000) -> 875N

Podejście zastosowane w funkcji lazy-seq-fibo jest zgodne z regułą 3. „Leniwe” podejście posłużyło tu do obsługi nieskończonych sekwencji. Jednak — jak często się to zdarza — nie trzeba bezpośrednio wywoływać funkcji lazy-seq. Zgodnie z regułą 5. można ponownie wykorzystać gotowe funkcje biblioteki sekwencji. Funkcje te zwracają „leniwe” sekwencje. Przyjrzyj się zastosowaniu funkcji iterate.

Rozdział 4. • Programowanie funkcyjne



127

(take 5 (iterate (fn [[a b]] [b (+ a b)]) [0 1])) -> ([0 1] [1 1] [1 2] [2 3] [3 5])

Funkcja iterate zaczyna od wyświetlenia pierwszej pary liczb Fibonacciego — [0 1]. Ponieważ używane są pary, funkcja otrzymuje wystarczające informacje (dwie wartości) do wyznaczenia następnych liczb ciągu. Liczba Fibonacciego w tym podejściu to pierwsza wartość każdej pary. Wartości te można wyodrębnić przez wywołanie instrukcji map first dla całej sekwencji. Uzyskujemy w ten sposób przedstawioną poniżej definicję funkcji fibo, zaproponowaną przez Christophe’a Granda. src/examples/functional.clj

(defn fibo [] (map first (iterate (fn [[a b]] [b (+ a b)]) [0N 1N])))

Funkcja fibo zwraca „leniwe” sekwencje, ponieważ jest oparta na funkcjach map i iterate, które też zwracają sekwencje tego rodzaju. Ponadto funkcja fibo jest prosta. To najkrótsza z przedstawionych do tej pory implementacji. Jeśli jednak jesteś przyzwyczajony do pisania kodu w imperatywnym stylu (z pętlami), wybranie funkcji fibo zamiast innych rozwiązań może nie być takie proste! Trudno jest jednocześnie nauczyć się myśleć z uwzględnieniem rekurencji, „leniwego” podejścia i ograniczeń związanych z rekurencją w maszynach JVM. Pomogą Ci w tym reguły. Ciąg Fibonacciego jest nieskończony. Zgodnie z regułą 3. prawidłowym podejściem w Clojure jest stosowanie „leniwych” sekwencji, a według reguły 5. należy pozwolić wykonywać większość zadań istniejącym funkcjom. „Leniwe” funkcje zajmują część pamięci na stosie i stercie. Nie zużywają jednak zasobów w ilości proporcjonalnej do wielkości całej (potencjalnie nieskończonej!) sekwencji. To użytkownik wybiera, ile zasobów chce zająć przy poruszaniu się po sekwencji. Jeśli interesuje go milionowa liczba Fibonacciego, może pobrać ją za pomocą funkcji fibo bez konieczności zajmowania pamięci na stosie i stercie dla wszystkich wcześniejszych wartości. Nie ma nic za darmo, jednak dzięki „leniwym” sekwencjom można uzyskać dostęp do nieskończonego menu, ale płacić tylko za te pozycje, których potrzebujemy w danym momencie. W czasie pisania programów w języku Clojure należy stosować „leniwe” sekwencje zamiast pętli i rekurencji, jeśli potrzebne są sekwencje o zmiennej wielkości lub duże.

Moment realizacji „Leniwe” sekwencje zajmują dużą ilość zasobów tylko w momencie realizacji, czyli utworzenia fragmentu sekwencji w pamięci. Język Clojure zaprojektowano tak, aby był „leniwy” i odraczał realizowanie sekwencji do momentu, w którym

128



Programowanie w języku Clojure

jest to niezbędne. Na przykład funkcja take zwraca „leniwą” sekwencję i w ogóle jej nie realizuje. Można się o tym przekonać na przykład przez utworzenie zmiennej na miliardową liczbę Fibonacciego. (def lots-o-fibs (take 1000000000 (fibo))) -> #'user/lots-o-fibs

Kod tworzący funkcję lots-o-fibs zwraca sterowanie niemal natychmiast, ponieważ prawie nic nie robi. Przy wywołaniu funkcji, która używa wartości zwracanych przez lots-o-fibs, Clojure oblicza te wartości. Jednak nawet w tej sytuacji wykonywane są tylko niezbędne operacje. Poniższy kod zwraca setną liczbę Fibonacciego z funkcji lots-o-fibs i nie oblicza milionów innych wartości, które funkcja ta może zwrócić. (nth lots-o-fibs 100) -> 354224848179261915075N

Większość funkcji do obsługi sekwencji zwraca te ostatnie w „leniwej” postaci. Jeśli nie masz pewności, czy funkcja zwraca „leniwe” sekwencje, dowiesz się tego z dokumentacji danej funkcji. (doc take) ------------------------clojure.core/take ([n coll]) Returns a lazy seq of the first n items in coll, or all items if there are fewer than n.

Jednak środowisko REPL nie działa w trybie „leniwym”. Domyślnie środowisko to wyświetla całą kolekcję. Dlatego umieściliśmy generowanie pierwszego miliarda liczb Fibonacciego w funkcji lots-o-fibs, zamiast wyznaczać je w środowisku REPL. Nie stosuj w tym środowisku poniższego kodu. ; Nie używaj takiego kodu. (take 1000000000 (fibo))

Wywołanie tego kodu prowadzi do próby wyświetlenia miliarda liczb Fibonacciego, przy czym realizowana jest cała kolekcja. Prawdopodobnie zdążysz się znudzić i zamkniesz środowisko REPL, zanim Clojure wyczerpie dostępną pamięć. Aby móc wygodnie korzystać z „leniwych” sekwencji, możesz określić, ile elementów środowisko ma wyświetlać. W tym celu należy określić wartość *print-length*. (set! *print-length* 10) -> 10

Jeśli kolekcja ma więcej niż 10 elementów, środowisko wyświetli tylko pierwszych dziesięć, a następnie wielokropek. Dlatego można bezpiecznie wyświetlić miliard liczb Fibonacciego.

Rozdział 4. • Programowanie funkcyjne



129

(take 1000000000 (fibo)) -> (0N 1N 1N 2N 3N 5N 8N 13N 21N 34N ...)

Można nawet uruchomić wyświetlanie wszystkich takich liczb. (fibo) -> (0N 1N 1N 2N 3N 5N 8N 13N 21N 34N ...)

„Leniwe” sekwencje są bardzo wartościowe. Sprawiają, że kod wykonuje tylko niezbędne zadania, i przeważnie nie wymagają ręcznej konfiguracji. Jeśli chcesz wymusić pełną realizację sekwencji, możesz użyć funkcji doall lub dorun (omawiamy je w punkcie „Wymuszanie realizacji sekwencji”).

Utrata głowy W czasie korzystania z „leniwych” sekwencji trzeba pamiętać o pewnej kwestii. „Leniwe” sekwencje pozwalają zdefiniować długą (potencjalnie nieskończoną) sekwencję, a następnie przetwarzać w pamięci jej małe fragmenty. To sprytne rozwiązanie nie powiedzie się, jeśli programista (lub interfejs API) przechowuje referencję do niepotrzebnego fragmentu sekwencji. Najczęściej zdarza się to, jeśli programista przypadkowo zapisuje głowę (pierwszy element) sekwencji. W przykładach z poprzedniego punktu każdą wersję funkcji generującej liczby Fibonacciego zdefiniowano za pomocą funkcji zwracającej sekwencję, a nie przy użyciu samych sekwencji. Można też zdefiniować sekwencję bezpośrednio, jako zmienną z najogólniejszego poziomu. src/examples/functional.clj

; Zapisywanie głowy (unikaj tego podejścia!). (def head-fibo (lazy-cat [0N 1N] (map + head-fibo (rest head-fibo))))

W tej definicji użyto instrukcji lazy-cat. Działa ona podobnie jak concat, jednak powoduje przetwarzanie argumentów dopiero wtedy, kiedy są potrzebne. Jest to bardzo zgrabna definicja, ponieważ rekurencja jest zdefiniowana przez odwzorowanie sumy każdego elementu ciągu Fibonacciego i każdego elementu z reszty tego ciągu. Funkcja head-fibo działa prawidłowo dla małych liczb Fibonacciego. (take 10 head-fibo) -> (0N 1N 1N 2N 3N 5N 8N 13N 21N 34N)

Jednak nie radzi sobie zbyt dobrze dla dużych liczb. (nth head-fibo 1000000) -> java.lang.OutOfMemoryError: GC overhead limit exceeded

130



Programowanie w języku Clojure

Problem polega na tym, że zdefiniowana na najwyższym poziomie zmienna head-fibo przechowuje głowę kolekcji. Z tego powodu mechanizm przywracania pamięci nie odzyskuje pamięci zajmowanej przez pobrane już elementy kolekcji. Dlatego każdy fragment sekwencji liczb Fibonacciego jest zapisywany w pamięci podręcznej na czas istnienia wartości używanej w funkcji head-fibo. Czas ten zwykle odpowiada czasowi pracy programu. Jeśli nie chcesz zapisywać sekwencji w pamięci podręcznej, nie przechowuj referencji do głowy sekwencji. Na przykładzie funkcji head-fibo pokazaliśmy, że „leniwe” sekwencje zwykle należy udostępniać jako funkcję, która zwraca sekwencję, a nie jako zmienną zawierającą taką sekwencję. Jeśli w jednostce wywołującej funkcję trzeba zapisać sekwencję w pamięci podręcznej, można utworzyć zmienną na poziomie tej jednostki. W przypadku „leniwych” sekwencji warto stracić głowę.

4.3. Leniwsze niż leniwe „Leniwe” sekwencje w języku Clojure są doskonałym „leniwym” mechanizmem na poziomie języka. Jednak programista może być jeszcze bardziej leniwy i wymyślić rozwiązanie, które w ogóle nie wymaga bezpośredniego manipulowania sekwencjami. Często problem można rozwiązać przez zastosowanie kilku istniejących funkcji przeznaczonych do obsługi sekwencji. Nie trzeba wtedy kłopotać się pisaniem kodu z wykorzystaniem instrukcji recur lub „leniwych” sekwencji. W ramach przykładu zaimplementujmy kilka rozwiązań pewnego problemu7. Dostępna jest sekwencja wyników rzutu monetą. Reszki są oznaczone literą :h, a orły — literą :t. [:h :t :t :h :h :h]

Ile razy w sekwencji wystąpiły dwie reszki pod rząd? Dla przykładowej sekwencji odpowiedź to dwa. W rzutach o indeksach 3 i 4 wypadła reszka, podobnie jak w rzutach o indeksach 4 i 5. Sekwencja rzutów monetą może być bardzo długa, ale skończona. Ponieważ szukamy odpowiedzi skalarnej (liczby), zgodnie z regułą 2. możemy użyć instrukcji recur. 7

Pozdrawiamy tu Jeffa Browna, który przedstawił ten problem przy śniadaniu na konferencji No Fluff, Just Stuff.

Rozdział 4. • Programowanie funkcyjne



131

src/examples/functional.clj

1 (defn count-heads-pairs [coll]Line 2 (loop [cnt 0 coll coll] 3 (if (empty? coll) 4 cnt 5 (recur (if (= :h (first coll) (second coll)) 6 (inc cnt) 7 cnt) 8 (rest coll)))))

Ponieważ funkcja ma zliczać elementy, w instrukcji loop (w wierszu 2.) wprowadzane jest wiązanie cnt o początkowej wartości 0. W instrukcji loop znajduje się też wiązanie coll, które pozwala skrócić kolekcję przy każdym wywołaniu instrukcji recur. Wiersz 3. obejmuje przypadek podstawowy rekurencji. Jeśli sekwencja rzutów monetą jest pusta, wiadomo, że liczba dwóch kolejnych wystąpień reszki to zero. W wierszu 5. znajduje się istota funkcji. Kod z tego wiersza zwiększa licznik cnt o jeden, jeśli elementy first i second z kolekcji coll to reszki (:h). Spróbujmy wprowadzić kilka sekwencji wejściowych, aby sprawdzić, czy funkcja count-heads-pairs działa w opisany sposób. (count-heads-pairs [:h :h :h :t :h]) -> 2 (count-heads-pairs [:h :t :h :t :h]) -> 0

Choć funkcja count-heads-pairs działa, jej kod jest mało czytelny. Najważniejsza informacja (o wyrzuceniu dwóch reszek pod rząd) jest całkowicie niewidoczna z uwagi na szablonowy kod z instrukcjami loop i recur. Aby rozwiązać problem, należy uwzględnić reguły 5. i 6. Trzeba więc podzielić problem i wykorzystać bibliotekę sekwencji języka Clojure. Pierwsza trudność związana jest z tym, że prawie wszystkie funkcje przeznaczone do obsługi sekwencji wykonują operacje na każdym kolejnym elemencie sekwencji. Nie jest to pomocne, ponieważ chcemy sprawdzić każdy element w kontekście jego sąsiadów. Należy więc przekształcić sekwencję. Zapis: [:h :t :t :h :h :h]

należy zastąpić sekwencją par przyległych elementów: [[:h :t] [:t :t] [:t :h] [:h :h] [:h :h]]

Napiszmy funkcję by-pairs przeznaczoną do przekształcania sekwencji w ten sposób. Ponieważ wielkość danych wyjściowych zwracanych przez tę funkcję zależy od danych wejściowych, zgodnie z regułą 3. należy zastosować „leniwą” sekwencję.

132



Programowanie w języku Clojure

src/examples/functional.clj

1 ; Nadmiernie złożone. Dalej opisujemy lepsze rozwiązania. 2 (defn by-pairs [coll] 3 (let [take-pair (fn [c] 4 (when (next c) (take 2 c)))] 5 (lazy-seq 6 (when-let [pair (seq (take-pair coll))] 7 (cons pair (by-pairs (rest coll)))))))

W wierszu 3. zdefiniowana jest funkcja, która przyjmuje pierwszą parę elementów z kolekcji. Wiersz 5. sprawia, że rekurencja działa w trybie „leniwym”. W wierszu 6. znajduje się instrukcja warunkowa. Jeśli następna para nie zawiera dwóch elementów, doszliśmy (prawie) do końca listy, dlatego można zakończyć pracę funkcji. Jeżeli elementy są dwa, w wierszu 7. należy kontynuować tworzenie nowej sekwencji przez połączenie za pomocą instrukcji cons nowej pary z parami z dalszej części kolekcji (zwracanymi przez instrukcję rest). Sprawdźmy, czy funkcja by-pairs działa. (by-pairs [:h :t :t :h :h :h]) -> ((:h :t) (:t :t) (:t :h) (:h :h) (:h :h))

Teraz, kiedy rzuty monetą mają postać par wyników, można łatwo opisać działanie funkcji count-heads-pairs: „Zlicz pary wyników obejmujące tylko reszki”. Ten opis można bezpośrednio przekształcić na istniejące funkcje z biblioteki sekwencji. „Zlicz” odpowiada oczywiście funkcji count, a człon „obejmujące tylko reszki” wymaga zastosowania instrukcji filter. src/examples/functional.clj

(defn count-heads-pairs [coll] (count (filter (fn [pair] (every? #(= :h %) pair)) (by-pairs coll))))

Ta wersja jest zdecydowanie czytelniejsza od implementacji opartej na instrukcji recur. Wyraźnie widać tu, że zliczamy pary kolejnych wystąpień reszek. Można jednak jeszcze bardziej uprościć kod. Język Clojure udostępnia bardziej ogólną wersję funkcji by-pairs — instrukcję partition. (partition size step? coll)

Instrukcja partition dzieli kolekcję na fragmenty o wielkości podzielić wektor z reszkami i orłami na sekwencję par. (partition 2 [:h :t :t :h :h :h]) -> ((:h :t) (:t :h) (:h :h))

size.

Można więc

Rozdział 4. • Programowanie funkcyjne



133

Wynik jest nieco odmienny niż przy wywołaniu funkcji by-pairs, która każdy element umieszcza w dwóch parach. Jednak podobny efekt można uzyskać także za pomocą funkcji partition. Opcjonalny argument step określa, jak daleko funkcja partition powinna przejść w kolekcji, aby rozpocząć tworzenie następnego fragmentu. Jeśli wartość step nie jest określona, ma tę samą wartość co size. Aby funkcja partition działała podobnie jak by-pairs, należy ustawić size na 2, a step — na 1. (partition 2 1 [:h :t :t :h :h :h]) -> ((:h :t) (:t :t) (:t :h) (:h :h) (:h :h)) (by-pairs [:h :t :t :h :h :h]) -> ((:h :t) (:t :t) (:t :h) (:h :h) (:h :h))

Rodzaje definicji W książce tworzymy zmienne za pomocą różnych postaci instrukcji def, na przykład defn, defmacro i defmulti. Wszystkie te wersje to nakładki na specjalną konstrukcję def. Instrukcja defonce sprawdza, czy zmienna istnieje, i ustawia podstawowe wiązanie dla zmiennej wtedy i tylko wtedy, jeśli nie jest ono jeszcze określone. (defonce a-symbol initial-value?)

Instrukcja defn- działa podobnie jak defn , jednak generuje funkcję prywatną, dostępną tylko w przestrzeni nazw, w której zdefiniowano daną funkcję. (defn- name & args-as-for-defn)

Także dla wielu innych wersji instrukcji def istnieje odmiana z dywizem, tworząca funkcje prywatne.

Innym usprawnieniem jest idiom oparty na funkcjach count i filter, pozwalający zliczać pary obejmujące dwie reszki. Funkcje te stosuje się razem na tyle często, że warto umieścić je w funkcji count-if. src/examples/functional.clj

(def ^{:doc "Zlicza elementy pasujące do filtra"} count-if (comp count filter))

Funkcja comp służy do składania przynajmniej dwóch funkcji. (comp f & fs)

134



Programowanie w języku Clojure

Funkcja złożona to nowa funkcja, która stosuje do argumentów funkcję podaną po prawej stronie, do wyniku stosuje funkcję następną od prawej itd. Tak więc funkcja count-if najpierw stosuje funkcję filter, a następnie wywołuje dla wyniku funkcję count. (count-if odd? [1 2 3 4 5]) -> 3

Funkcje count-if i partition można wykorzystać do utworzenia funkcji count-runs, która jest bardziej ogólna od funkcji count-heads-pairs. src/examples/functional.clj

(defn count-runs "Zlicza serie o długości n, w których pred to true dla elementów kolekcji coll." [n pred coll] (count-if #(every? pred %) (partition n 1 coll)))

Funkcja count-runs to najlepsze rozwiązanie — zarówno prostsze, jak i bardziej ogólne od wcześniejszej funkcji count-heads-pairs. Nową funkcję można zastosować do zliczania par reszek. (count-runs 2 #(= % :h) [:h :t :t :h :h :h]) -> 2

Przy użyciu tej funkcji równie łatwo można zliczyć pary orłów. (count-runs 2 #(= % :t) [:h :t :t :h :h :h]) -> 1

Zamiast par można też zliczyć serie trzech reszek. (count-runs 3 #(= % :h) [:h :t :t :h :h :h]) -> 1

Jeśli nadal chcesz stosować funkcję count-heads-pairs, możesz ją zaimplementować za pomocą funkcji count-runs. src/examples/functional.clj

(def ^{:doc "Zlicza serie dwóch reszek "} count-heads-pairs (partial count-runs 2 #(= % :h)))

W tej wersji funkcji count-heads-pairs wykorzystujemy instrukcję utworzenia nowej funkcji.

partial

do

(partial f & partial-args)

Instrukcja partial powoduje częściowe wywołanie (ang. partial application) funkcji. W wywołaniu tej instrukcji należy podać funkcję f i fragment listy argumentów. Pozostałą część listy argumentów należy określić później, w wywołaniu funkcji utworzonej za pomocą instrukcji partial. Tak więc wyrażenie:

Rozdział 4. • Programowanie funkcyjne



135

(partial count-runs 1 #(= % :h))

to zwięźlejszy sposób zapisania poniższego kodu: (fn [coll] (count-runs 1 #(= % :h) coll))

Technika ta jest podobna do rozwijania funkcji (ang. currying), choć między tymi podejściami występują pewne różnice.

Rozwijanie i częściowe wywoływanie funkcji Rozwinięcie funkcji prowadzi do uzyskania nowej funkcji, która przyjmuje jeden argument i zwraca pierwotną funkcję o stałej wartości tego argumentu (angielska nazwa techniki, currying, pochodzi od Haskella Curry’ego, amerykańskiego logika, znanego przede wszystkich z prac z dziedziny logiki kombinatorycznej). W języku Clojure rozwijanie funkcji można zaimplementować tak: ; Prawie jak rozwinięcie funkcji. (defn faux-curry [& args] (apply partial partial args))

Rozwijanie funkcji jest przydatne na przykład przy częściowym wywoływaniu funkcji. Poniżej pokazujemy, jak wygląda częściowe wywoływanie funkcji w języku Clojure. (def add-3 (partial + 3)) (add-3 7) -> 10

A oto częściowe wywołanie funkcji z wykorzystaniem instrukcji faux-curry. (def add-3 ((faux-curry +) 3)) (add-3 7) -> 10

Jeśli programiście zależy wyłącznie na częściowym wywołaniu funkcji, rozwijanie funkcji to tylko krok pośredni. Funkcja faux-curry nie jest prawdziwym rozwinięciem funkcji. Rzeczywiste rozwinięcie funkcji po ustaleniu argumentów zwraca wynik, a nie funkcję bezargumentową. O różnicy między oboma podejściami można się przekonać na przykładzie funkcji true?, która przyjmuje tylko jeden argument. ; Pozorne rozwinięcie funkcji. ((faux-curry true?) (= 1 1)) -> # ; Prawdziwe rozwinięcie funkcji. ((curry true?) (= 1 1)) -> true

136



Programowanie w języku Clojure

Ponieważ funkcje w języku Clojure mają listy argumentów o zmiennej długości, Clojure nie potrafi wykryć, kiedy wszystkie argumenty są już ustalone. Jednak programista wie, kiedy skończył dodawać argumenty. Po zastosowaniu rozwijania do dowolnej liczby argumentów wystarczy wywołać funkcję. Sprowadza się to do umieszczenia dodatkowej pary nawiasów wokół wcześniejszego wyrażenia. (((faux-curry true?) (= 1 1))) -> true

Brak rozwijania funkcji w języku Clojure nie stanowi istotnego problemu, ponieważ dostępna jest instrukcja partial, która w większości sytuacji pozwala uzyskać pożądany efekt. Czasem pojęcia „rozwijanie funkcji” i „częściowe wywołanie” używane są zamiennie. W tym podrozdziale poznałeś wiele nowych konstrukcji. Nie pozwól, aby szczegóły przesłoniły Ci to, co najważniejsze — przez łączenie istniejących funkcji z biblioteki sekwencji można tworzyć rozwiązania prostsze i bardziej ogólne niż pisane od podstaw. Ponadto nie trzeba wtedy przejmować się „leniwym” podejściem i rekurencją. Można natomiast pracować na wyższym poziomie abstrakcji i pozwolić językowi Clojure obsługiwać zarówno „leniwe” podejście, jak i rekurencję.

4.4. Jeszcze o rekurencji Twórcy języka Clojure włożyli dużo pracy w zachowanie równowagi między możliwościami programowania funkcyjnego a praktycznymi aspektami korzystania z maszyn JVM. Widać to na przykład w uzasadnionym zastosowaniu bezpośredniej optymalizacji TCO przy korzystaniu z instrukcji loop i recur. Jednak próba połączenia najlepszych rozwiązań z obu światów może wymagać pogodzenia się z niekorzystnymi kompromisami. Dlatego warto zastanowić się, czy w języku Clojure występują ukryte kompromisy projektowe, które — choć początkowo niewidoczne — mogą w przyszłości utrudniać pracę. Na to pytanie nigdy i dla żadnego języka nie da się wyczerpująco odpowiedzieć. Zastanówmy się jednak nad tą kwestią w kontekście złożonej rekurencji. Przyjrzyjmy się najpierw rekurencji wzajemnej.

Rozdział 4. • Programowanie funkcyjne



137

Rekurencja wzajemna związana jest z wywoływaniem siebie nawzajem przez przynajmniej dwie funkcje. Zamiast sekwencji wywołań A wywołuje A, która wywołuje A, mamy A wywołuje B, która wywołuje A. Oto prosty przykład. Funkcje my-odd? i my-even? można zdefiniować za pomocą rekurencji wzajemnej. src/examples/functional.clj

(declare my-odd? my-even?) (defn my-odd? [n] (if (= n 0) false (my-even? (dec n)))) (defn my-even? [n] (if (= n 0) true (my-odd? (dec n))))

Ponieważ funkcje my-odd? i my-even? wywołują siebie nawzajem, trzeba utworzyć obie zmienne przed zdefiniowaniem funkcji. Można użyć do tego instrukcji def, jednak makro declare pozwala utworzyć obie zmienne (bez początkowego wiązania) w jednym wierszu kodu. Sprawdźmy, czy funkcje my-odd? i my-even? działają dla małych wartości. (map my-even? (range 10)) -> (true false true false true false true false true false) (map my-odd? (range 10)) -> (false true false true false true false true false true)

Funkcje te zajmują ramki stosu w liczbie proporcjonalnej do wartości argumentu, dlatego nie można ich stosować dla dużych liczb. (my-even? (* 1000 1000 1000)) -> StackOverflowError clojure.lang.Numbers$LongOps.equiv (Numbers.java:490)

Przypomina to problem, który wymagał zastosowania instrukcji recur. Jednak tu nie można użyć tej instrukcji, ponieważ działa ona dla autorekurencji, a nie dla rekurencji wzajemnej. Oczywiście, sprawdzanie, czy wartości są nieparzyste, czy parzyste, można zaimplementować w wydajniejszy sposób bez stosowania rekurencji. W Clojure w implementacji funkcji odd? i even? wykorzystano bitowy operator „i” (bit-and). ; Z biblioteki core.clj. (defn even? [n] (zero? (bit-and n 1))) (defn odd? [n] (not (even? n))

138

Programowanie w języku Clojure



Wybraliśmy sprawdzanie parzystości z uwagi na prostotę. Inne problemy rekurencyjne nie są tak proste i nie zawsze mają eleganckie nierekurencyjne rozwiązania. Dalej omawiamy cztery sposoby radzenia sobie z takimi problemami. Oto one:  przekształcanie na autorekurencję,  rekurencja wzajemna z instrukcją trampoline,  zastępowanie rekurencji „leniwym” podejściem,  przyspieszanie rekurencji za pomocą memoizacji.

Przekształcanie na autorekurencję Rekurencja wzajemna jest często dobrym sposobem na modelowanie różnych, ale podobnych warunków. Na przykład parzystość i nieparzystość to różne warunki, ale ściśle powiązane ze sobą. Rekurencję wzajemną można przekształcić na autorekurencję przez opracowanie jednej abstrakcji, która dotyczy jednocześnie kilku warunków. Parzystość i nieparzystość można ująć za pomocą jednego warunku — parzystości. Zdefiniujmy funkcję parity, obejmującą instrukcję recur i zwracającą wartość 0 dla liczb parzystych i 1 dla nieparzystych. src/examples/functional.clj

(defn parity (loop [n n (if (= n par (recur

[n] par 0] 0) (dec n) (- 1 par)))))

Sprawdźmy, czy funkcja działa dla małych wartości. (map parity (range 10)) -> (0 1 0 1 0 1 0 1 0 1)

Teraz można łatwo zaimplementować funkcje funkcji parity.

my-odd?

i

my-even?

za pomocą

src/examples/functional.clj

(defn my-even? [n] (= 0 (parity n))) (defn my-odd? [n] (= 1 (parity n)))

Parzystość to prosty warunek. Niestety, wielu przypadków rekurencji wzajemnej nie da się uprościć do eleganckiej autorekurencji. Jeśli w trakcie próby przekształcenia rekurencji wzajemnej na autorekurencję okazuje się, że nowy kod jest pełen wyrażeń warunkowych, które utrudniają zrozumienie definicji, należy zrezygnować z opisywanego podejścia.

Rozdział 4. • Programowanie funkcyjne



139

Rekurencja wzajemna z instrukcją trampoline Technika oparta na instrukcji trampoline służy do optymalizowania rekurencji wzajemnej. Instrukcja ta działa jak instrukcja recur, tyle że jest stosowana „po fakcie”. Używa się jej w jednostce wywołującej, a nie w kodzie z implementacją rekurencji. Ponieważ jednostka wywołująca może w instrukcji trampoline uruchomić więcej niż jedną funkcję, instrukcja ta pozwala zoptymalizować rekurencję wzajemną. Funkcja trampoline języka Clojure wywołuje jedną z wzajemnie rekurencyjnych funkcji. (trampoline f & partial-args)

Jeśli zwrócona wartość nie jest funkcją, instrukcja trampoline działa jak bezpośrednie wywołanie funkcji. Spróbujmy zastosować instrukcję trampoline do prostych funkcji języka Clojure. (trampoline list) -> () (trampoline + 1 2) -> 3

Jeśli zwracana wartość jest funkcją, instrukcja trampoline przyjmuje, że funkcję tę należy wywołać rekurencyjnie, i robi to. Instrukcja trampoline korzysta z instrukcji recur, dlatego wywołuje otrzymaną funkcję dopóty, dopóki zwraca ona funkcje. W punkcie „Rekurencja końcowa” zaimplementowaliśmy funkcję tail-fibo. Pokazaliśmy, że funkcja zajmuje pamięć na stosie, i zastąpiliśmy rekurencję końcową instrukcją recur. Teraz przedstawiamy inną możliwość. Kod funkcji tail-fibo można uruchamiać w instrukcji trampoline. W tym celu rekurencyjne zwracanie sterowania należy umieścić wewnątrz funkcji. Wymaga to dodania tylko jednego znaku, #, w celu zaimplementowania funkcji anonimowej. src/examples/trampoline.clj

1 ; To tylko przykład. Nie pisz kodu w ten sposób. - (defn trampoline-fibo [n] (let [fib (fn fib [f-2 f-1 current] (let [f (+ f-2 f-1)] 5 (if (= n current) f #(fib f-1 f (inc current)))))] (cond (= n 0) 0 10 (= n 1) 1 :else (fib 0N 1 2))))

140



Programowanie w języku Clojure

Jedyna różnica między tym kodem a pierwotną wersją funkcji tail-fibo to znak # w wierszu 7. Spróbujmy wywołać funkcję trampoline-fibo w instrukcji trampoline. (trampoline trampoline-fibo 9) -> 34N

Ponieważ instrukcja trampoline automatycznie korzysta z instrukcji recur, potrafi obsługiwać duże wartości wejściowe bez zgłaszania błędu StackOverflowError. (rem (trampoline trampoline-fibo 1000000) 1000) -> 875N

Funkcję tail-fibo wywołujemy w instrukcji trampoline, aby móc porównać instrukcje trampoline-fibo i trampoline. W kontekście autorekurencji (na przykład dla funkcji trampoline-fibo) stosowanie instrukcji trampoline nie daje żadnych korzyści, dlatego należy używać instrukcji recur. Jednak w kontekście rekurencji wzajemnej instrukcja trampoline daje wyjątkowe korzyści. Zastanów się nad wzajemnie rekurencyjną definicją funkcji my-odd? i my-even?, zaprezentowaną na początku podrozdziału 4.4, „Jeszcze o rekurencji”. Tę nieprawidłową, zużywającą pamięć na stosie implementację można przekształcić na wersję opartą na instrukcji trampoline. Należy wykorzystać do tego technikę zastosowaną do przekształcenia funkcji tail-fibo — wystarczy poprzedzić rekurencyjne wywołania symbolem #. src/examples/trampoline.clj

1 (declare my-odd? my-even?) - (defn my-odd? [n] (if (= n 0) 5 false #(my-even? (dec n)))) - (defn my-even? [n] (if (= n 0) 10 true #(my-odd? (dec n))))

Jedyna różnica w porównaniu z pierwotną implementacją to nakładki # w wierszach 6. i 11. Po wprowadzeniu tych zmian można użyć instrukcji trampoline dla większych wartości n bez obaw o wyczerpanie pamięci na stosie. (trampoline my-even? 1000000) -> true

Instrukcja trampoline to specjalne rozwiązanie konkretnego problemu. Wymaga zmodyfikowania pierwotnych funkcji tak, aby zwracały inny typ w celu określenia rekurencji. Jeśli jedna z innych technik opisanych w książce pozwala utworzyć bardziej eleganckie rozwiązanie, to świetnie. Jeżeli jednak nie jest to możliwe, warto mieć możliwość zastosowania instrukcji trampoline.

Rozdział 4. • Programowanie funkcyjne



141

Zastępowanie rekurencji „leniwym” podejściem Spośród wszystkich opisanych tu technik eliminowania lub optymalizowania rekurencji prawdopodobnie najczęściej będziesz stosował „leniwe” podejście. Tu implementujemy funkcję replace opracowaną przez Eugene’a Wallingforda w celu zademonstrowania rekurencji wzajemnej (zobacz http://www.cs.uni.edu/ ~wallingf/patterns/recursion.html). Funkcja replace działa dla struktury danych o nazwie s-list. Ta struktura to lista, która może obejmować zarówno symbole, jak i listy symboli. Funkcja replace przyjmuje argumenty s-list, oldsym i newsym, po czym zastępuje wszystkie wystąpienia symbolu oldsym symbolem newsym. Na przykład poniższe wywołanie funkcji replace powoduje zastąpienie wszystkich wystąpień litery b literą a. (replace '((a b) (((b g r) (f r)) c (d e)) b) 'b 'a) -> ((a a) (((a g r) (f r)) c (d e)) a)

Poniżej przedstawiamy dość dosłowne „tłumaczenie” implementacji w języku Scheme, przedstawionej w pracy Wallingforda. Funkcje z języka Scheme zastąpiliśmy funkcjami języka Clojure, zmieniliśmy nazwę funkcji na replace-symbol, aby uniknąć konfliktu z funkcją replace języka Clojure, i skróciliśmy nazwy, aby dopasować je do strony w książce. Oprócz tego zachowaliśmy strukturę oryginału. src/examples/wallingford.clj

; Zbyt dosłowne przeniesienie rozwiązania z innego języka – nie stosuj tego podejścia. (declare replace-symbol replace-symbol-expression) (defn replace-symbol [coll oldsym newsym] (if (empty? coll) () (cons (replace-symbol-expression (first coll) oldsym newsym) (replace-symbol (rest coll) oldsym newsym)))) (defn replace-symbol-expression [symbol-expr oldsym newsym] (if (symbol? symbol-expr) (if (= symbol-expr oldsym) newsym symbol-expr) (replace-symbol symbol-expr oldsym newsym)))

Funkcje replace-symbol i replace-symbol-expression są wzajemnie rekurencyjne, dlatego mocno zagnieżdżona struktura może spowodować wyczerpanie się pamięci na stosie. Aby zademonstrować problem, utwórzmy funkcję deeply-nested, tworzącą głęboko zagnieżdżone listy z jednym elementem bottom.

142



Programowanie w języku Clojure

src/examples/replace_symbol.clj

(defn deeply-nested [n] (loop [n n result '(bottom)] (if (= n 0) result (recur (dec n) (list result)))))

Wypróbujmy funkcję deeply-nested dla małych wartości n. (deeply-nested 5) -> ((((((bottom)))))) (deeply-nested 25) -> (((((((((((((((((((((((((bottom)))))))))))))))))))))))))

Język Clojure udostępnia ustawienie *print-level*, pozwalające określić, jak bardzo mechanizm wyświetlania wyników tego języka zagłębia się w zagnieżdżone struktury danych. Wybierzmy rozsądną wartość, aby mechanizm nie próbował wyświetlać głęboko zagnieżdżonych struktur. Jeśli poziom zagnieżdżenia jest większy, mechanizm wyświetla symbol # i kończy pracę. (set! *print-level* 25) -> 25 (deeply-nested 5) -> ((((((bottom)))))) (deeply-nested 25) -> (((((((((((((((((((((((((#)))))))))))))))))))))))))

Teraz spróbujmy użyć funkcji replace-symbol do zmiany elementu bottom na deepest na różnych poziomach zagnieżdżenia. Okazuje się, że przy dużym zagnieżdżeniu funkcja wyczerpuje pamięć na stosie. W niektórych maszynach JVM w celu spowodowania błędu trzeba użyć wartości większej niż (zastosowana tutaj) 10000. (replace-symbol (deeply-nested 5) 'bottom 'deepest) -> ((((((deepest)))))) (replace-symbol (deeply-nested 10000) 'bottom 'deepest) -> java.lang.StackOverflowError

Wszystkie rekurencyjne wywołania funkcji replace-symbol znajdują się w instrukcji cons. Aby wyłamać się z rekurencji, wystarczy umieścić rekurencyjne wywołania w instrukcji lazy-seq. To naprawdę jest aż tak proste. Wyłamać się z rekurencyjnego generowania sekwencji można przez umieszczenie kodu w instrukcji lazy-seq. Poniżej przedstawiamy poprawioną wersję rozwiązania. Ponieważ zastosowanie „leniwego” podejścia było tak proste, nie mogliśmy oprzeć się pokusie, aby dodatkowo przekształcić kod na postać bardziej typową dla języka Clojure.

Rozdział 4. • Programowanie funkcyjne



143

src/examples/replace_symbol.clj

1 2 3 4 5 6 7 8 9

(defn- coll-or-scalar [x & _] (if (coll? x) :collection :scalar)) (defmulti replace-symbol coll-or-scalar) (defmethod replace-symbol :collection [coll oldsym newsym] (lazy-seq (when (seq coll) (cons (replace-symbol (first coll) oldsym newsym) (replace-symbol (rest coll) oldsym newsym))))) (defmethod replace-symbol :scalar [obj oldsym newsym] (if (= obj oldsym) newsym obj))

W wierszu 4. instrukcja lazy-seq powoduje zablokowanie rekurencji, co zapobiega przepełnieniu stosu przez głęboko zagnieżdżone struktury. Inne usprawnienie znajduje się w wierszu 2. Zamiast dwóch różnych funkcji do obsługi symboli i list znajduje się tu jedna wielometoda replace-symbol, obejmująca metodę dla list i metodę dla symboli (wielometody omawiamy szczegółowo w rozdziale 8., „Wielometody”). Pozwala to pominąć instrukcję if i poprawić czytelność. Upewnijmy się, że usprawniona wersja funkcji z głęboko zagnieżdżonymi strukturami.

replace-symbol

radzi sobie

(replace-symbol (deeply-nested 10000) 'bottom 'deepest) -> (((((((((((((((((((((((((#)))))))))))))))))))))))))

„Leniwe” podejście jest niezwykle wartościowe. Często można napisać rekurencyjne, a nawet wzajemnie rekurencyjne funkcje i wyłamać się z rekurencji za pomocą „leniwego” podejścia.

Przyspieszanie rekurencji za pomocą memoizacji Aby przedstawić bardziej złożoną rekurencję wzajemną, badamy sekwencje kobiet i mężczyzn wymyślone przez Hofstadtera. Pierwsze sekwencje Hofstadtera opisano w książce Gödel, Escher, Bach: An Eternal Golden Braid [Hof99]. Oto definicja sekwencji kobiet i mężczyzn8: F(0) = 1; M(0) = 0 F(n) = n - M(F(n-1)), n > 0 M(n) = n - F(M(n-1)), n > 0 Na tej podstawie można opracować prostą definicję w języku Clojure.

8

http://en.wikipedia.org/wiki/Hofstadter_sequence

144



Programowanie w języku Clojure

src/examples/male_female.clj

; Nie stosuj tego rozwiązania. (declare m f) (defn m [n] (if (zero? n) 0 (- n (f (m (dec n)))))) (defn f [n] (if (zero? n) 1 (- n (m (f (dec n))))))

Definicja w języku Clojure jest czytelna i ściśle odpowiada definicji matematycznej. Jednak dla dużych wartości n wydajność kodu jest fatalna. Każda wartość z sekwencji wymaga obliczenia od początku dwóch innych wartości (z których każda wymaga wyznaczenia dwóch następnych). Na jednym z używanych przez nas komputerów MacBook Pro9 obliczenie wyniku dla danych (m 250) zajęło ponad pół minuty. (time (m 250)) "Elapsed time: 38443.902083 msecs" -> 155

Czy można zachować przejrzystą definicję opartą na wzajemnej rekurencji i uzyskać wysoką wydajność? Tak, za pomocą memoizacji. Memoizacja przyspiesza działanie kosztem zajmowania pamięci, ponieważ powoduje zapisywanie w pamięci podręcznej wyników wcześniejszych obliczeń. Funkcja z memoizacją najpierw sprawdza dane wejściowe w odwzorowaniu z wcześniejszymi takimi danymi i odpowiadającymi im wynikami. Jeśli w odwzorowaniu znajdują się potrzebne dane, można natychmiast zwrócić wynik (bez konieczności ponownego przeprowadzania obliczeń). Powiążmy m i f z ich wersjami z memoizacją. Umożliwia to funkcja języka Clojure.

memoize

src/examples/memoized_male_female.clj

(def m (memoize m)) (def f (memoize f))

Teraz Clojure musi obliczyć F i M tylko raz dla każdego n. Prowadzi to do znacznego przyspieszenia pracy. Tym razem obliczenia dla danych (m 250) są tysiące razy szybsze. (time (m 250)) "Elapsed time: 5.190739 msecs" -> 155 9

3,06 GHz Intel Core 2 Duo, 4 GB 667 MHz DDR2 SDRAM, Ubuntu 10.10, SSD.

Rozdział 4. • Programowanie funkcyjne



145

Ponadto po utworzeniu pamięci podręcznej z danymi wynik „obliczeń” dla zapisanych w pamięci danych jest zwracany niemal natychmiast. (time (m 250)) "Elapsed time: 0.065877 msecs" -> 155

Jednak sama memoizacja to za mało, ponieważ pozwala skrócić rekurencję tylko wtedy, jeśli pamięć podręczna jest zapełniona. Jeżeli jest pusta, a użytkownik żąda wyniku dla dużego f lub m, przepełnienie stosu nastąpi jeszcze przed zapełnieniem pamięci. (m 10000) -> java.lang.StackOverflowError

Ostatnia sztuczka polega na upewnieniu się, że pamięć podręczna jest budowana „od dołu do góry”. Wymaga to udostępnienia sekwencji zamiast funkcji. Utwórzmy sekwencje m-seq i f-seq przez odwzorowanie m i f na liczby całkowite. src/examples/male_female_seq.clj

(def m-seq (map m (iterate inc 0))) (def f-seq (map f (iterate inc 0)))

Teraz jednostki wywołujące mogą uzyskać M(n) lub F(n) przez pobranie z sekwencji n-tej (nth) wartości. (nth m-seq 250) -> 155

Technika ta działa dość szybko nawet dla dużych wartości n. (time (nth m-seq 10000)) "Elapsed time: 0.735 msecs" -> 6180

Zastosowane tu podejście obejmuje następujące kroki:  zdefiniowanie wzajemnie rekurencyjnych funkcji w standardowy sposób;  wykorzystanie memoizacji do skrócenia rekurencji w przypadku już obliczonych wartości;  udostępnienie sekwencji, tak aby zapisywać wartości w pamięci podręcznej, zanim będą potrzebne. Podejście to powoduje zajmowanie pamięci na stercie, ponieważ wszystkie wcześniejsze wartości są zapisywane w pamięci podręcznej. Jeśli stanowi to problem, w niektórych sytuacjach można go rozwiązać przez zastosowanie zaawansowanej obsługi pamięci podręcznej.

146



Programowanie w języku Clojure

4.5. Podsumowanie W tym rozdziale dowiedziałeś się, że w modelu programowania funkcyjnego w języku Clojure zachowano równowagę między akademicką ścisłością a wydajnością języka w maszynach JVM. Clojure udostępnia wiele technik, w tym autorekurencję z wykorzystaniem instrukcji recur, rekurencję wzajemną z wykorzystaniem instrukcji trampoline, „leniwe” sekwencje i memoizację. Co więcej, do wykonywania wielu standardowych zadań programistycznych można wykorzystać bibliotekę sekwencji. Nie trzeba przy tym samemu definiować rekurencji i „leniwych” sekwencji. Funkcje w rodzaju partition pozwalają łatwo tworzyć przejrzyste, zwięzłe rozwiązania. Gdyby Clojure był językiem czysto funkcyjnym, przeszlibyśmy teraz do wyzwań związanych z pracą w środowisku bez zmiennego stanu. Możliwe, że zaczęlibyśmy od omówienia monad state. Jednak innowacyjny mechanizm języka Clojure — interfejsy API do bezpośredniego zarządzania zmiennym stanem — sprawia, że możemy skierować się w zupełnie inną stronę. Model referencji w języku Clojure umożliwia manipulowanie stanem na cztery sposoby. Właśnie nimi zajmujemy się w rozdziale 5., „Stan”.

Rozdział 5.

Stan

S

tan to wartość elementu o określonej tożsamości w danym momencie.

W zdaniu tym ujęliśmy wiele informacji. Zacznijmy od znaczenia słowa wartość — jest to niezmienna, trwała struktura danych. Jeśli można pracować, używając tylko wartości, sytuacja jest prosta. Opisaliśmy to w rozdziale 4., „Programowanie funkcyjne”.

Uwzględnienie upływu czasu utrudnia pracę. Czy Piast Gliwice to ten sam klub co w zeszłym roku? A w roku 1955? Skład drużyny wyznacza tożsamość, dla której wartości z czasem się zmieniają. Aktualizowanie tożsamości nie prowadzi do usunięcia dawnych wartości. Aktualizowanie tożsamości nie ma żadnego wpływu na istniejące wartości. Właściciel Piasta Gliwice może wymienić wszystkich zawodników lub z nudów wyprzedać drużynę, jednak w niczym nie zmienia to historii zespołu z dawnych lat. Model referencji obowiązujący w języku Clojure ściśle oddziela tożsamość od wartości. W Clojure prawie wszystko jest wartością. W obszarze tożsamości Clojure udostępnia cztery typy referencyjne. Oto one:  referencje, służące do obsługi skoordynowanych i synchronicznych zmian współużytkowanego stanu;  atomy, przeznaczone do wprowadzania nieskoordynowanych i synchronicznych zmian współużytkowanego stanu;  agenty, służące do wprowadzania asynchronicznych zmian współużytkowanego stanu;  zmienne, używane do zarządzania stanem lokalnym dla wątku.

148



Programowanie w języku Clojure

W rozdziale omawiamy każdy z tych typów. W końcowej części rozdziału tworzymy przykładową aplikację. Gra Snake służy do pokazania, jak podzielić model aplikacji na niezmienne i zmienne komponenty. Zanim zaczniemy, warto zastanowić się nad zagadnieniami z pogranicza stanu, współbieżności i równoległości. Omawiamy też trudności związane z tradycyjnymi podejściami opartymi na blokadach.

5.1. Współbieżność, równoległość i blokady W programach współbieżnych modeluje się wykonywanie więcej niż jednej operacji naraz. W programie równoległym operację, która może być realizowana sekwencyjnie, dzieli się na odrębne fragmenty, wykonywane współbieżnie w celu przyspieszenia pracy kodu. Jest wiele powodów, dla których warto pisać współbieżne i równoległe programy. Oto one:  Przez wiele dziesięcioleci poprawa wydajności wynikała z budowania coraz wydajniejszych rdzeni. Obecnie — i w najbliższej przyszłości — usprawnienia wynikają ze stosowania coraz większej liczby rdzeni. Używany dziś sprzęt jest bardziej współbieżny niż kiedykolwiek wcześniej. Dlatego także systemy muszą być współbieżne, aby można było wykorzystać możliwości sprzętu.  Kosztowne obliczenia trzeba przeprowadzać równolegle na wielu rdzeniach (lub w wielu komputerach), aby można je było szybko ukończyć.  Zadania, które są zablokowane w oczekiwaniu na zasoby, powinny umożliwić innym zadaniom korzystanie z dostępnych procesorów.  Interfejsy użytkownika muszą reagować w czasie wykonywania przez program długich zadań.  Logicznie niezależne operacje łatwiej jest zaimplementować, jeśli platforma potrafi wykryć i wykorzystać tę niezależność. Współbieżność unaocznia, że podgląd danych może być potrzebny dla więcej niż jednego obserwatora (na przykład wątku). Stanowi to poważny problem w językach, w których nie ma podziału na wartości i tożsamość1. W takich językach dane są traktowane jak księga główna banku z tylko jednym wierszem. 1

http://www.infoq.com/presentations/Simple-Made-Easy

Rozdział 5. • Stan



149

Każda nowa operacja powoduje wymazanie historii, co może naruszyć efekty pracy wszystkich pozostałych wątków systemu. Choć współbieżność unaocznia problemy, nie należy myśleć, że więcej niż jeden obserwator pojawia się tylko przy stosowaniu współbieżności. Jeśli w programie występują dwie zmienne powiązane z tymi samymi danymi, zmienne te to dwóch różnych obserwatorów. Jeżeli w programie dopuszczalna jest jakakolwiek zmienność, trzeba starannie zastanowić się nad stanem. W zmiennych językach problem rozwiązywany jest za pomocą blokad i kopiowania defensywnego. Wróćmy do analogii z księgą główną. Załóżmy, że bank zatrudnia strażników (blokady) do nadzorowania poczynań osób korzystających z księgi. Nikt nie może jej modyfikować, kiedy ktokolwiek z niej korzysta. Kiedy kolejka się wydłuża, bank może zażądać od osób czytających księgę, aby wykonały jej prywatną kopię i zwolniły oryginał, co pozwala na kontynuowanie transakcji. Także kopie muszą być nadzorowane przez strażników! Już sam opis nie wygląda dobrze, a sytuacja staje się jeszcze gorsza na poziomie implementacji. Niełatwo jest ustalić, co i gdzie należy blokować. Pomyłki mogą prowadzić do poważnych problemów. Sytuacja wyścigu między wątkami może skutkować uszkodzeniem danych, a zakleszczenie powoduje zatrzymanie pracy całego programu. W książce Java Concurrency in Practice [Goe06] opisano szczegółowo te i inne problemy, a także ich rozwiązania. To fantastyczna książka, trudno jednak w czasie jej lektury nie zadać sobie pytania o to, czy nie istnieje inna możliwość. Model zarządzania stanem i tożsamością obowiązujący w języku Clojure pozwala rozwiązać opisane problemy. Większość kodu programów pisze się tu w podejściu funkcyjnym. Krótkie fragmenty, w których zmienność jest naprawdę potrzebna, odróżniają się od reszty kodu. Trzeba jawnie wybrać w nich jeden z czterech modeli obsługi referencji. Za pomocą tych modeli można podzielić modele na dwie warstwy.  Model funkcyjny, pozbawiony zmiennego stanu. Standardowo większość kodu znajduje się w tej warstwie. Jest ona czytelniejsza, a także łatwiejsza do testowania i równoległego wykonywania.  Modele oparte na referencjach, używane we fragmentach aplikacji, w których wygodniej jest korzystać ze zmiennego stanu (mimo związanych z tym wad). Omawianie stanu w języku Clojure zaczynamy od najczęściej używanego modelu referencji — programowej pamięci transakcyjnej (ang. software transactional memory — STM).

150



Programowanie w języku Clojure

5.2. Referencje i pamięć STM Większość obiektów w języku Clojure jest niezmienna. Jeśli potrzebne są zmienne dane, trzeba określić to bezpośrednio, na przykład przez utworzenie zmiennej referencji do niezmiennego obiektu. Do tworzenia referencji służy następująca instrukcja: (ref initial-state)

Można na przykład utworzyć referencję do odtwarzanej piosenki z listy. (def current-track (ref "Mars, the Bringer of War")) -> #'user/current-track

Referencja jest nakładką zabezpieczającą dostęp do jej wewnętrznego stanu. Aby odczytać zawartość referencji, należy wywołać instrukcję deref. (deref reference)

Funkcję deref można skrócić do makra odczytu @. Użyjmy obu postaci (deref i @) do pobrania wartości referencji current-track. (deref current-track) -> "Mars, the Bringer of War" @current-track -> "Mars, the Bringer of War"

Zauważ, że w tym przykładzie model zastosowany w języku Clojure pasuje do świata rzeczywistego. Utwór jest niezmienną encją. Po zakończeniu odtwarzania nie zmienia się w inny utwór. Jednak obecnie odtwarzany utwór to prowadząca do encji referencja, która może się zmieniać.

Instrukcja ref-set Za pomocą instrukcji ref-set można zmienić encję, na którą wskazuje referencja. (ref-set reference new-value)

Wywołajmy instrukcję ref-set, aby odtworzyć inną ścieżkę. (ref-set current-track "Venus, the Bringer of Peace") -> java.lang.IllegalStateException: No transaction running

Ups. Ponieważ referencje są zmienne, trzeba zabezpieczyć operację aktualizacji. W wielu językach służą do tego blokady. W języku Clojure można zamiast nich użyć transakcji. Transakcje zapisuje się w instrukcji dosync. (dosync & exprs)

Rozdział 5. • Stan



151

Po umieszczeniu instrukcji ref-set w dosync wszystko działa prawidłowo. (dosync (ref-set current-track "Venus, the Bringer of Peace")) -> "Venus, the Bringer of Peace"

Referencja current-track prowadzi teraz do innego utworu.

Właściwości transakcji Transakcje w pamięci STM, podobnie jak transakcje w bazie danych, mają kilka ważnych właściwości.  Aktualizacje są atomowe (ang. atomic). Jeśli w transakcji aktualizowana jest więcej niż jedna referencja, w kodzie spoza transakcji ogólne skutki wszystkich aktualizacji pojawiają się tak, jakby były efektem jednego zdarzenia.  Aktualizacje są spójne (ang. consistent). W referencjach można określić funkcje do sprawdzania poprawności. Jeśli któraś z tych funkcji zgłasza problem, cała transakcja jest wycofywana.  Aktualizacje są izolowane (ang. isolated). W działających transakcjach niedostępne są efekty pracy częściowo ukończonych innych transakcji. W bazach danych aktualizacje są też trwałe (ang. durable). Ponieważ w języku Clojure transakcje odbywają się w pamięci, nie można zagwarantować trwałości aktualizacji. Aby przeprowadzać trwałe transakcje w języku Clojure, należy wykorzystać bazę danych. Wszystkie cztery właściwości transakcji noszą nazwę ACID (od angielskich nazw tych cech). Bazy danych zapewniają wszystkie te właściwości, natomiast pamięć STM w języku Clojure ma cechy ACI. Jeśli w jednej transakcji modyfikowana jest więcej niż jedna referencja, z perspektywy kodu spoza transakcji wszystkie modyfikacje są wprowadzane w tym samym momencie. Dlatego można mieć pewność, że aktualizacje referencji current-track i current-composer są skoordynowane. (def current-track (ref "Venus, the Bringer of Peace")) -> #'user/current-track (def current-composer (ref "Holst")) -> #'user/current-composer (dosync (ref-set current-track "Credo") (ref-set current-composer "Byrd")) -> "Byrd"

Ponieważ aktualizacje są wprowadzane w ramach transakcji, żaden inny wątek nie ma dostępu do nowego utworu i dawnego kompozytora (lub na odwrót).

152



Programowanie w języku Clojure

Instrukcja alter Przykład z referencją current-track jest zwodniczo prosty, ponieważ aktualizacje referencji są tu całkowicie niezależne od wcześniejszego stanu. Utwórzmy bardziej skomplikowany przykład — prostą aplikację do prowadzenia rozmów, w której transakcje służą do aktualizowania istniejących informacji. Zbudujmy najpierw rekord na wiadomości. Rekord ten obejmuje nadawcę i tekst. src/examples/chat.clj

(defrecord Message [sender text])

Teraz można generować wiadomości przez tworzenie egzemplarzy tego rekordu. (user.Message. "Aaron" "Hello") -> #:user.Message{:sender "Aaron", :text "Hello"}

Użytkownicy aplikacji chcą najpierw zobaczyć najnowszą wiadomość, dlatego dobrą strukturą danych jest tu lista. Utwórzmy referencję messages, która prowadzi do początkowo pustej listy. (def messages (ref ()))

Teraz potrzebna jest funkcja do dodawania nowej wiadomości na początek listy powiązanej z referencją messages. Można zastosować proste rozwiązanie — wywołać instrukcję deref w celu pobrania listy wiadomości, zastosować instrukcję cons, aby dołączyć nową informację, a następnie użyć instrukcji ref-set w celu powiązania zaktualizowanej listy z nazwą messages. ; Zły pomysł. (defn naive-add-message [msg] (dosync (ref-set messages (cons msg @messages))))

Istnieje jednak lepsze podejście. Można wczytywać i aktualizować listę w jednym kroku. Instrukcja alter języka Clojure powoduje, że w transakcji funkcja aktualizująca dane jest wywoływana dla wskazywanego obiektu. (alter ref update-fn & args...)

Instrukcja alter zwraca w ramach transakcji nową wartość referencji. Po udanym zakończeniu transakcji referencja przyjmuje ostatnią ustawioną dla niej wartość. Zastosowanie instrukcji alter zamiast ref-set pozwala zwiększyć czytelność kodu. (defn add-message [msg] (dosync (alter messages conj msg)))

Warto zauważyć, że do aktualizacji służy tu funkcja conj (to skrót od ang. conjoin), a nie cons. Wynika to z tego, że funkcja conj przyjmuje argumenty w kolejności dostosowanej do instrukcji alter.

Rozdział 5. • Stan



153

(cons item sequence) (conj sequence item)

Funkcja alter wywołuje funkcję update-fn, której pierwszym argumentem jest bieżąca wartość referencji (jak oczekuje tego funkcja conj). Jeśli planujesz pisać własne funkcje przeprowadzające aktualizację, zawsze powinny one mieć taką samą strukturę jak funkcja conj. (your-func thing-that-gets-updated & optional-other-args)

Spróbujmy dodać kilka wiadomości, aby zobaczyć, czy kod działa zgodnie z oczekiwaniami. (add-message (user.Message. "użytkownik 1" "witaj")) -> (#:user.Message{:sender "użytkownik 1", :text "witaj"}) (add-message (user.Message. "użytkownik 2" "jak leci")) -> (#:user.Message{:sender "użytkownik 2", :text "jak leci"} #:user.Message{:sender "użytkownik 1", :text "witaj"})

Instrukcja alter jest podstawowym mechanizmem do zarządzania pamięcią STM w języku Clojure i do aktualizowania referencji. Jeśli jednak wiesz coś więcej o działaniu pamięci STM, w niektórych scenariuszach możesz zoptymalizować pracę transakcji.

Działanie pamięci STM — technika MVCC Do zarządzania pamięcią STM w języku Clojure stosuje się technikę MVCC (ang. multiversion concurrency control). Wykorzystuje się ją także w kilku znanych bazach danych. Dalej opisujemy działanie techniki MVCC w języku Clojure. Transakcja A rozpoczyna się od określenia punktu, który jest liczbą odgrywającą w pamięci STM rolę niepowtarzalnego znacznika czasu. Transakcja A ma dostęp do praktycznie prywatnej kopii każdej potrzebnej referencji. Kopie te są powiązane ze wspomnianym punktem. Dzięki trwałym strukturom danych z języka Clojure (punkt „Trwałe struktury danych”) można łatwo udostępnić takie kopie. W transakcji A operacje na referencji są wykonywane (i zwracają wartości) dla prywatnej kopii danych powiązanych z referencją. Te dane to wartość wewnątrztransakcyjna. Jeśli w dowolnym momencie system pamięci STM wykryje, że inna transakcja ustawiła lub zmodyfikowała referencję, którą chce ustawić lub zmodyfikować transakcja A, ta ostatnia zostanie ponowiona. Zgłoszenie wyjątku w bloku dosync powoduje zakończenie transakcji A bez jej ponawiania.

154



Programowanie w języku Clojure

Po zatwierdzeniu transakcji A jej — wcześniej prywatne — dane stają się widoczne w zewnętrznym kodzie i są wiązane z konkretnym punktem na osi czasu transakcji. Czasem model charakterystyczny dla instrukcji alter jest zbyt ostrożny. Co zrobić, jeśli nie ma znaczenia, czy inna transakcja wywołała instrukcję alter w trakcie wykonywania danej transakcji? Jeśli w takiej sytuacji i tak należy zatwierdzić zmiany, można uzyskać lepszą wydajność, zastępując instrukcję alter poleceniem commute.

Instrukcja commute Instrukcja commute to specjalna wersja polecenia współbieżność.

alter,

pozwalająca na większą

(commute ref update-fn & args...)

Oczywiście, związane są z tym pewne koszty. Aktualizacje z instrukcji commute muszą być przemienne. Oznacza to, że kolejność aktualizacji musi być bez znaczenia. Pozwala to systemowi pamięci STM swobodnie określać kolejność takich aktualizacji. Aby zastosować instrukcję commute, wystarczy podać ją zamiast polecenia w kodzie funkcji add-message.

alter

(defn add-message-commute [msg] (dosync (commute messages conj msg)))

Instrukcja commute zwraca nową wartość referencji. Jednak ostatnia wartość wewnątrztransakcyjna dostępna w instrukcji commute nie zawsze odpowiada końcowej wartości referencji z transakcji. Wynika to z przestawiania kolejności operacji. Jeśli inna transakcja zmodyfikuje referencję zmienianą za pomocą instrukcji commute, system pamięci STM nie ponawia transakcji. Zamiast tego ponownie uruchamia funkcję commute (niejako poza kolejnością). Transakcja nigdy nie widzi wartości referencji używanej ostatecznie w funkcji commute. Ponieważ system pamięci STM języka Clojure może zmieniać kolejność instrukcji commute bez wiedzy poniższych, instrukcje te można stosować tylko wtedy, jeśli kolejność ich wykonywania nie ma znaczenia. W aplikacji do prowadzenia rozmów nie jest to prawdą. Na liście wiadomości należy zachować odpowiedni porządek. Jeśli aplikacja zmieni kolejność wypowiedzi, na uzyskanej liście znajdą się one w nieprawidłowym porządku.

Rozdział 5. • Stan



155

Jednak w praktyce aktualizacje wiadomości są wystarczająco przechodnie. Zmiana uporządkowania wiadomości przez system pamięci STM odbywa się w skali mikrosekund. W aplikacjach do prowadzenia rozmów zmiana kolejności wypowiedzi często zachodzi w dużo większej skali z uwagi na opóźnienia w pracy sieci i długi czas reakcji użytkownika. Przypomnij sobie, ile razy w trakcie pogawędek internetowych wypowiadałeś się „poza kolejnością”, ponieważ wiadomość od rozmówcy nie zdążyła do Ciebie dotrzeć. Ponieważ nie da się rozwiązać problemu większych zmian kolejności, w aplikacji do prowadzenia rozmów można pominąć mniejsze przestawienia wypowiedzi, wynikające z pracy systemu pamięci STM języka Clojure.

Wybieraj instrukcję alter Aktualizacje często nie są przechodnie. Dotyczy to na przykład licznika, który zwraca sekwencję rosnących liczb. Licznik tego rodzaju można zastosować do generowania niepowtarzalnych identyfikatorów w systemie. Taki licznik może być zwykłą referencją do liczby. src/examples/concurrency.clj

(def counter (ref 0))

Nie należy używać instrukcji commute do aktualizowania licznika. Instrukcja commute zwraca wartość wewnątrztransakcyjną z momentu uruchomienia instrukcji commute, jednak z uwagi na zmianę kolejności operacji wartość końcowa transakcji może być inna. Może to prowadzić do tego, że więcej niż jedna jednostka wywołująca otrzyma tę samą wartość licznika. Dlatego w zamian należy stosować instrukcję alter. (defn next-counter [] (dosync (alter counter inc)))

Spróbujmy kilkakrotnie wywołać instrukcję licznik działa w oczekiwany sposób.

next-counter,

aby sprawdzić, czy

(next-counter) -> 1 (next-counter) -> 2

Zwykle należy stosować instrukcję alter zamiast commute. Instrukcja alter działa w zrozumiały sposób i jest odporna na błędy, natomiast instrukcja commute wymaga starannego przemyślenia działania transakcji. Przy stosowaniu instrukcji alter w sytuacji, kiedy wystarczy użyć instrukcji commute, najgorsze, co może się zdarzyć, to spadek wydajności. Jeśli jednak programista korzysta z instrukcji commute, kiedy niezbędna jest instrukcja alter, może wystąpić błąd trudny do wykrycia za pomocą automatycznych testów.

156



Programowanie w języku Clojure

Sprawdzanie poprawności referencji Transakcje w bazie danych zachowują spójność za pomocą różnych testów integralności. W pamięci STM w języku Clojure można uzyskać podobny efekt przez określenie funkcji do sprawdzania poprawności w miejscu tworzenia referencji. (ref initial-state options*) ; Wybrane opcje: ; :validator validate-fn ; :meta metadata-map

Opcje instrukcji ref pozwalają podać opcjonalną funkcję do sprawdzania poprawności, która może zgłaszać wyjątek, aby zablokować zakończenie transakcji. Zauważ, że options to nie odwzorowanie. Jest to tylko sekwencja par klucz-wartość dodana do wywołania funkcji. Wróćmy do przykładu aplikacji do prowadzenia rozmów. Dodajmy do referencji funkcję do sprawdzania poprawności, która gwarantuje, że we wszystkich wiadomościach pola :sender i :text mają wartości różne od nil. messages

src/examples/chat.clj

(def validate-message-list (partial every? #(and (:sender %) (:text %)))) (def messages (ref () :validator validate-message-list))

Sprawdzanie poprawności działa tu jak ograniczenie klucza dla tabeli w transakcji bazodanowej. Jeśli ograniczenie nie jest przestrzegane, należy wycofać całą transakcję. Spróbujmy dodać błędną wiadomość, na przykład prosty łańcuch znaków. (add-message "nieprawidłowa wiadomość") -> java.lang.IllegalStateException: Invalid reference state @messages -> (

Wiadomości zgodne z ograniczeniem działają prawidłowo. (add-message (user.Message. "Aaron" "Poprawna wiadomość")) -> (#:user.Message{:sender "Aaron", :text "Poprawna wiadomość"})

Referencje doskonale nadają się do skoordynowanego dostępu do współużytkowanego stanu, ale nie wszystkie zadania wymagają takiej koordynacji. Przy aktualizowaniu izolowanych danych lepiej jest korzystać z atomów.

Rozdział 5. • Stan



157

5.3. Nieskoordynowane i synchroniczne aktualizacje za pomocą atomów Atomy są mechanizmem prostszym od referencji. Aktualizacje kilku referencji można skoordynować w ramach transakcji, natomiast atomy służą do aktualizowania pojedynczej wartości bez koordynacji z innymi danymi. Do tworzenia atomów służy instrukcja instrukcja ref.

atom.

Ma ona podobną sygnaturę jak

(atom initial-state options?) ; Wybrane opcje: ; :validator validate-fn ; :meta metadata-map

Wróćmy do odtwarzacza muzyki. Bieżący utwór (current-track) można zapisać w formie atomu, a nie referencji. (def current-track (atom "Venus, the Bringer of Peace")) -> #'user/current-track

Następnie można przeprowadzić dereferencję atomu, aby uzyskać jego wartość (odbywa się to tak samo jak dla referencji). (deref current-track) -> "Venus, the Bringer of Peace" @current-track -> "Venus, the Bringer of Peace"

Atomy nie biorą udziału w transakcjach, dlatego nie trzeba używać instrukcji dosync. W celu ustawienia wartości atomu wystarczy wywołać instrukcję reset!. (reset! an-atom newval)

Możemy na przykład ustawić atom current-track na wartość "Credo". (reset! current-track "Credo") -> "Credo"

Co zrobić, aby skoordynować aktualizację wartości current-track i current-composer przy korzystaniu z atomu? Krótka odpowiedź to taka, że nie da się tego zrobić. Na tym polega różnica między referencjami a atomami. Jeśli konieczny jest skoordynowany dostęp, należy zastosować referencję. Dłuższa odpowiedź brzmi: „można skoordynować aktualizację, ale wymaga to zmiany modelu problemu”. Spróbujmy umieścić tytuł utworu i nazwisko kompozytora w odwzorowaniu, a następnie zapisać całe odwzorowanie w jednym atomie. (def current-track (atom {:title "Credo" :composer "Byrd"})) -> #'user/current-track

158



Programowanie w języku Clojure

Teraz można zaktualizować obie wartości w jednej instrukcji reset!. (reset! current-track {:title "Spem in Alium" :composer "Tallis"}) -> {:title "Spem in Alium", :composer "Tallis"}

Możliwe, że chcesz posłuchać kilku utworów jednego kompozytora. W takiej sytuacji można zmienić tytuł nagrania, ale zachować nazwisko kompozytora. Umożliwia to instrukcja swap!. (swap! an-atom f & args)

Instrukcja swap! aktualizuje wartość an-atom przez wywołanie funkcji f dla bieżącej wartości an-atom i przekazanie do tej funkcji dodatkowych argumentów args. Aby zmienić tylko tytuł utworu, należy użyć instrukcji swap! i przy użyciu polecenia assoc zaktualizować tylko pole :title. (swap! current-track assoc :title "Sancte Deus") -> {:title "Sancte Deus", :composer "Tallis"}

Instrukcja swap! zwraca nową wartość. Wywołania instrukcji swap! można ponawiać, jeśli inne wątki próbują zmodyfikować ten sam atom. Dlatego funkcja przekazana do instrukcji swap! nie powinna mieć efektów ubocznych. Zarówno referencje, jak i atomy służą do wykonywania synchronicznych aktualizacji. Kiedy funkcja aktualizująca dane zwraca sterowanie, wartość jest już zmodyfikowana. Jeśli taki poziom kontroli nie jest potrzebny i dopuszczalne jest późniejsze przeprowadzenie aktualizacji w trybie asynchronicznym, warto użyć agenta.

5.4. Stosowanie agentów do asynchronicznego aktualizowania danych W niektórych aplikacjach zadania można wykonywać niezależnie przy minimalnej koordynacji między zadaniami. W języku Clojure służą do tego agenty. Agenty mają wiele wspólnych cech z referencjami. Agenty, podobnie jak referencje, tworzy się przez umieszczenie w nich początkowego stanu. (agent initial-state)

Utwórzmy agenta counter, obejmującego początkową wartość licznika równą 0. (def counter (agent 0)) -> #'user/counter

Rozdział 5. • Stan



159

Po utworzeniu agenta można przekazać do niego (za pomocą instrukcji send) funkcję aktualizującą stan. Instrukcja send powoduje umieszczenie funkcji update-fn w kolejce. Funkcja ta jest później wykonywana w wątku z puli. (send agent update-fn & args)

Wywoływanie instrukcji send dla agenta przypomina wywoływanie instrukcji commute dla referencji. Poniższa instrukcja powoduje, że licznik counter jest zwiększany (za pomocą funkcji inc). (send counter inc) -> #

Warto zauważyć, że wywołanie instrukcji send nie powoduje zwrócenia nowej wartości agenta. Zamiast tego zwracany jest sam agent. Wynika to z tego, że instrukcja send nie zna nowej wartości. Instrukcja ta jedynie umieszcza funkcję inc w kolejce w celu późniejszego wykonania, po czym natychmiast zwraca sterowanie. Choć instrukcja send nie zna nowej wartości agenta, wartość tę może znać środowisko REPL. W zależności od tego, czy jako pierwszy wykonywany jest wątek agenta, czy wątek środowiska REPL, w poprzednim wyniku po dwukropku może pojawić się 1 lub 0. Bieżącą wartość agenta można sprawdzić tak jak wartość referencji, czyli za pomocą instrukcji deref lub @. Do momentu sprawdzania wartości licznika counter funkcja inc prawie na pewno zakończy pracę w wątku z puli i zwiększy wartość do 1. @counter -> 1

Jeśli sytuacja wyścigu między środowiskiem REPL a wątkiem agenta Cię niepokoi, możesz zastosować pewne rozwiązanie. Kiedy chcesz mieć pewność, że agent ukończył przekazane mu zadania, możesz wywołać instrukcję await lub await-for. (await & agents) (await-for timeout-millis & agents)

Funkcje te powodują, że bieżący wątek wstrzymuje działanie do czasu wykonania wszystkich operacji agenta. Wersja await-for zwraca nil po upłynięciu limitu czasu i wartość różną od nil, jeśli operacje wykonano przed upływem tego limitu. Wersja await nie jest powiązana z limitem, dlatego zachowaj ostrożność — w tym podejściu oczekiwanie może trwać w nieskończoność.

160

Programowanie w języku Clojure



Sprawdzanie poprawności agentów i obsługa błędów Agenty mają też inne punkty wspólne z referencjami. Także dla agentów można określić funkcję sprawdzającą poprawność. (agent initial-state options*) ; Wybrane opcje: ; :validator validate-fn ; :meta metadata-map

Utwórzmy nową wersję licznika tość to liczba.

counter

z funkcją sprawdzającą, czy jego war-

(def counter (agent 0 :validator number?)) -> #'user/counter

Ustawmy agenta na wartość różną od liczby przez podanie funkcji aktualizującej, która ignoruje bieżącą wartość i zwraca łańcuch znaków. (send counter (fn [_] "boo")) -> #

Wszystko wygląda (na razie) dobrze, ponieważ instrukcja send natychmiast zwraca sterowanie. Po tym, jak agent próbuje zaktualizować swoją wartość w wątku z puli, wchodzi w niestandardowy stan. Błąd można wykryć przy próbie dereferencji agenta. @counter -> java.lang.Exception: Agent has errors

W celu wykrycia konkretnego błędu (lub błędów) należy wywołać instrukcję agent-errors, która zwraca sekwencję błędów zgłoszonych w czasie działania agenta. (agent-errors counter) -> (#)

Jeśli stan agenta jest nieprawidłowy, wszystkie późniejsze próby pobrania danych agenta prowadzą do zwrócenia błędu. Aby przywrócić agenta do stanu używalności, należy wywołać instrukcję clear-agent-errors. (clear-agent-errors agent)

Instrukcja ta przywraca stan agenta sprzed pojawienia się błędu. Usuńmy teraz błędy z licznika counter i sprawdźmy, czy jego stan jest taki sam jak przed wystąpieniem problemów. (clear-agent-errors counter) -> nil @counter -> 0

Opisaliśmy już podstawy korzystania z agentów. Pora zastosować je w połączeniu z referencjami i transakcjami.

Rozdział 5. • Stan



161

Stosowanie agentów w transakcjach Transakcje nie powinny powodować efektów ubocznych, ponieważ Clojure może ponawiać transakcję dowolną liczbę razy. Jednak czasem programista chce, aby udana transakcja powodowała efekty uboczne. Agenty to umożliwiają. Przekazanie operacji do agenta w ramach transakcji sprawia, że operacja ta jest przesyłana tylko raz — wtedy i tylko wtedy, jeśli transakcja kończy się powodzeniem. Kiedy jest to przydatne? Załóżmy, że udane zakończenie transakcji sprawia, iż agent zapisuje dane do pliku. Można zastosować takiego agenta w aplikacji do prowadzenia rozmów (opartej na instrukcji commute), aby automatycznie archiwizował wiadomości. Utwórzmy najpierw agenta backup-agent przechowującego nazwę pliku (filename), w którym mają znaleźć się wiadomości. src/examples/concurrency.clj

(def backup-agent (agent "output/messages-backup.clj"))

Następnie utwórzmy zmodyfikowaną wersję funkcji add-message. Nowa funkcja, add-message-with-backup, ma wykonywać dwie dodatkowe operacje. Oto one:  Pobieranie w instrukcji let wartości zwracanej przez funkcję commute (wartość ta to wiadomości znajdujące się w bazie danych).  W ramach transakcji przekazywanie za pomocą instrukcji send zadania do agenta archiwizującego, który zapisuje bazę danych z wiadomościami w pliku o nazwie filename. Dla uproszczenia zadanie zwraca nazwę pliku (filename), dlatego agent przy archiwizowaniu kolejnych danych używa tego samego pliku. (defn add-message-with-backup [msg] (dosync (let [snapshot (commute messages conj msg)] (send-off backup-agent (fn [filename] (spit filename snapshot) filename)) snapshot)))

Nowa funkcja różni się od poprzedniej ważnym szczegółem — w celu komunikowania się z agentem wywołuje instrukcję send-off zamiast send. Instrukcja send-off to odmiana polecenia send przeznaczona dla zadań, które mogą blokować pracę aplikacji (tu tym zadaniem jest zapis danych do pliku). Zadania określone w instrukcji send-off są wykonywane z wykorzystaniem wątków z odrębnej, rozszerzalnej puli. Nigdy nie należy przekazywać funkcji blokującej pracę do instrukcji send, ponieważ może to zablokować działanie innych agentów. Dodajmy kilka wiadomości za pomocą funkcji add-message-with-backup.

162

Programowanie w języku Clojure



(add-message-with-backup (user.Message. "Jan" "Wiadomość pierwsza")) -> (#:user.Message{:sender "Jan", :text "Wiadomość pierwsza"}) (add-message-with-backup (user.Message. "Ania" "Wiadomość druga")) -> (#:user.Message{:sender "Ania", :text "Wiadomość druga"} #:user.Message{:sender "Jan", :text "Wiadomość pierwsza"})

Można sprawdzić zarówno przechowywaną w pamięci referencję messages, jak i zarchiwizowany plik messages-backup, aby stwierdzić, że mają tę samą strukturę. Strategię archiwizowania przedstawioną w przykładzie można na kilka sposobów wzbogacić. Można archiwizować dane rzadziej niż po każdej aktualizacji lub archiwizować tylko informacje zmodyfikowane od czasu ostatniego zapisu. Ponieważ pamięć STM w języku Clojure ma właściwości ACI z listy ACID, a zapis danych do pliku zapewnia ostatnią brakującą literę akronimu (ang. durability, czyli trwałość), mogłoby się wydawać, że pamięć STM w połączeniu z agentem archiwizującym to baza danych. Nie jest to prawda. Transakcje w języku Clojure gwarantują jedynie przekazanie zadania do agenta za pomocą instrukcji send lub send-off. Zadania te nie są jednak wykonywane zgodnie z regułami ACI. Dlatego może się zdarzyć, że transakcja zakończy pracę, a użytkownik wyciągnie wtyczkę z kontaktu, zanim agent zapisze dane w bazie. Wniosek z tego jest prosty — jeśli sytuacja wymaga zastosowania prawdziwej bazy danych, należy to zrobić.

Ujednolicony model aktualizowania Pokazaliśmy już, że dla referencji, atomów i agentów istnieją funkcje pozwalające aktualizować stan przez zastosowanie innej funkcji do poprzedniego stanu. Ten ujednolicony model obsługi współużytkowanego stanu jest jednym z ważnych aspektów języka Clojure. Model ten i funkcje pomocnicze przedstawiamy w poniższej tabeli. Mechanizm aktualizacji

Funkcja dla referencji

Funkcja dla atomów

Funkcja dla agentów

Zastosowanie funkcji

alter

swap!

send-off

Funkcja (blokująca)

commute

Brak

Brak

Funkcja (nieblokująca)

Brak

Brak

send

Proste ustawianie wartości

ref-set

reset!

Brak

Rozdział 5. • Stan



163

Ujednolicony model aktualizacji to najważniejszy mechanizm aktualizowania referencji, atomów i agentów. Funkcje pomocnicze zapewniają optymalizacje i opcje charakterystyczne dla poszczególnych interfejsów API.  Możliwość zastosowania optymalizacji w postaci instrukcji commute pojawia się przy koordynowaniu aktualizacji. Ponieważ tylko referencje umożliwiają koordynowane aktualizacje, wspomnianą instrukcję stosuje się tylko do referencji.  Aktualizowanie referencji i atomów odbywa się w wątku, w którym wywołano dany element, dlatego szeregowanie zadań jest niemożliwe. Agenty są aktualizowane później, za pomocą wątku z puli, dlatego ważny jest wybór mechanizmu blokującego lub nieblokującego. Ostatni typ referencyjny z języka Clojure, zmienne, znacznie różni się od pozostałych. Dla zmiennych nie obowiązuje ujednolicony model aktualizacji. Służą one do zarządzania prywatnym stanem lokalnym dla wątku.

5.5. Zarządzanie stanem specyficznym dla wątku za pomocą zmiennych Wywołanie instrukcji def lub defn prowadzi do utworzenia dynamicznej zmiennej, często nazywanej po prostu zmienną. We wszystkich dotychczasowych przykładach w książce przekazywaliśmy do instrukcji def wartość początkową, która stawała się podstawowym wiązaniem zmiennej. Poniższy kod tworzy podstawowe wiązanie zmiennej foo z wartością 10. (def ^:dynamic foo 10) -> #'user/foo

Wiązanie zmiennej foo jest współużytkowane przez wszystkie wątki. Można sprawdzić wartość zmiennej foo w głównym wątku. foo -> 10

Można też sprawdzić wartość zmiennej w innym wątku. Utwórzmy nowy wątek dla funkcji wyświetlającej zmienną foo (należy pamiętać o uruchomieniu wątku za pomocą instrukcji start). user=> (.start (Thread. (fn [] (println foo)))) -> nil | 10

164

Programowanie w języku Clojure



W przykładzie tym instrukcja świetla wartość zmiennej foo.

start()

zwraca

nil,

a następnie nowy wątek wy-

Większość zmiennych na stałe zachowuje wiązanie podstawowe. Jednak za pomocą makra binding można utworzyć wiązanie lokalne dla wątku. (binding [bindings] & body)

Wiązania mają dynamicznie określany zasięg. Oznacza to, że są widoczne wszędzie tam, gdzie są używane w danym wątku (przestają istnieć dopiero po wyjściu wątku z zasięgu, w którym utworzono wiązanie). W innych wątkach wiązanie nie jest dostępne. Strukturalnie instrukcja binding przypomina polecenie let. Utwórzmy wiązanie zmiennej foo lokalne dla wątku, po czym sprawdźmy wartość zmiennej. (binding [foo 42] foo) -> 42

Aby dostrzec różnicę między instrukcjami binding i let, można utworzyć prostą funkcję, która wyświetla wartość zmiennej foo. (defn print-foo [] (println foo)) -> #'user/print-foo

Spróbujmy wywołać funkcję print-foo w instrukcjach let i binding. (let [foo "foo w let"] (print-foo)) | 10 (binding [foo "powiązane foo"] (print-foo)) | powiązane foo

Jak widać, efekty wywołania instrukcji let są ograniczone do tej konstrukcji, dlatego przy pierwszym wywołaniu funkcja print-foo wyświetla wiązanie podstawowe 10. Jednak efekt wywołania instrukcji binding jest widoczny w łańcuchu wywołań rozpoczynającym się od tej instrukcji, dlatego drugie wywołanie funkcji print-foo powoduje wyświetlenie tekstu powiązane foo.

Modyfikacje na odległość Zmienne przeznaczone do wiązania dynamicznego nazywa się czasem specjalnymi zmiennymi. W dobrym stylu jest zaczynanie i kończenie nazw takich zmiennych gwiazdkami. W Clojure wiązanie dynamiczne stosuje się między innymi dla opcji obowiązujących w całych wątkach, na przykład dla standardowych strumieni wejścia-wyjścia, takich jak *in*, *out* i *err*. Wiązania dynamiczne umożliwiają wprowadzanie modyfikacji na odległość. Zmiana wartości dynamicznego wiązania pozwala zmodyfikować działanie innych funkcji bez zmieniania ich argumentów.

Rozdział 5. • Stan



165

Jedną z odmian wprowadzania modyfikacji na odległość jest czasowa zmiana działania funkcji. W niektórych językach technikę tę uznaje się za przykład programowania aspektowego. W Clojure jest ona efektem ubocznym wiązania dynamicznego. Załóżmy, że funkcja wykonuje kosztowne obliczenia. Aby to zasymulować, napiszmy funkcję slow-double, która usypia na jedną dziesiątą sekundy, a następnie podwaja wartość danych wejściowych. (defn ^:dynamic slow-double [n] (Thread/sleep 100) (* n 2))

Następnie napiszmy funkcję o nazwie calls-slow-double, która wywołuje funkcję slow-double dla każdego elementu z kolekcji [1 2 1 2 1 2]. (defn calls-slow-double [] (map slow-double [1 2 1 2 1 2]))

Pora wywołać funkcję calls-slow-double. Powinna ona działać przez nieco ponad sześć dziesiątych sekundy (z uwagi na sześć wewnętrznych wywołań funkcji slow-double). Warto zauważyć, że trzeba zastosować instrukcję dorun. W przeciwnym razie instrukcja map języka Clojure „przechytrzy” programistę i natychmiast zwróci „leniwą” sekwencję. (time (dorun (calls-slow-double))) | "Elapsed time: 601.418 msecs" -> nil

Analiza kodu pozwala stwierdzić, że funkcja calls-slow-double jest powolna, ponieważ nieustannie wykonuje te same operacje. Jeden razy dwa to dwa niezależnie od tego, ile razy to sprawdzić. Obliczenia przeprowadzane w funkcji slow-double dobrze nadają się do zastosowania memoizacji. Funkcja z tym mechanizmem przechowuje w pamięci podręcznej odwzorowanie z wcześniejszych danych wejściowych na obliczone dane wyjściowe. Jeśli późniejsze wywołanie pasuje do zawartości pamięci podręcznej, wynik można zwrócić niemal natychmiast. Pozwala to skrócić czas (obliczeń wykonywanych przez funkcję dla tych samych danych wejściowych) kosztem zwiększenia zajmowanego miejsca (na pamięć podręczną). Clojure udostępnia instrukcję memoize, która przyjmuje funkcję i zwraca jej wersję z memoizacją. (memoize function)

Funkcja slow-double dobrze nadaje się do zastosowania memoizacji, ale na razie działa bez tego mechanizmu. Klienty, na przykład calls-slow-double, już używają wolnej wersji bez memoizacji. Dynamiczne wiązanie pozwala rozwiązać

166



Programowanie w języku Clojure

ten problem. Wystarczy utworzyć wiązanie dla wersji funkcji slow-double z memoizacją, a następnie wywołać instrukcję calls-slow-double w ramach wiązania. (defn demo-memoize [] (time (dorun (binding [slow-double (memoize slow-double)] (calls-slow-double)))))

Po zastosowaniu funkcji slow-double z memoizacją funkcja calls-slow-double działa trzykrotnie szybciej i kończy pracę w około dwie dziesiąte sekundy. (demo-memoize) "Elapsed time: 203.115 msecs"

Przykład ten ilustruje możliwości i zagrożenia związane z wprowadzaniem modyfikacji na odległość. Przez dynamiczne ponowne powiązanie funkcji, na przykład slow-double, można zmienić działanie innych funkcji, takich jak calls-slow-double, bez wiedzy autorów tych ostatnich. Przy stosowaniu konstrukcji służących do wiązania leksykalnego, takich jak let, można łatwo dostrzec cały zakres zmian. Przy używaniu wiązania dynamicznego sytuacja się komplikuje. Technika ta pozwala zmieniać działanie innych konstrukcji z innych plików, daleko od miejsca wiązania. Jeśli wiązanie dynamiczne stosuje się stosunkowo rzadko, daje bardzo duże możliwości. Nie traktuj go jednak jak podstawowego mechanizmu wielokrotnego wykorzystania kodu. Funkcje z wiązaniem dynamicznym nie są czystymi funkcjami i szybko mogą prowadzić do utraty zalet funkcyjnego podejścia charakterystycznego dla języka Clojure.

Korzystanie z interfejsów API Javy opartych na wywołaniach zwrotnych Niektóre interfejsy API Javy są oparte na wywoływanych zwrotnie metodach obsługi zdarzeń. Frameworki do tworzenia graficznych interfejsów użytkownika, na przykład Swing, wykorzystują takie metody do reagowania na działania użytkowników. Parsery danych w formacie XML, na przykład SAX, wymagają zaimplementowania przez użytkownika interfejsu do obsługi wywołań zwrotnych. Wywoływane zwrotnie metody pisze się zwykle pod kątem zmiennych obiektów. Ponadto takie metody działają przeważnie w jednym wątku. W języku Clojure najlepszym sposobem na wykorzystanie takich interfejsów jest zastosowanie wiązań dynamicznych. Używa się przy tym zmiennych referencji, które wyglądają prawie jak zmienne, ale ponieważ stosuje się je w kontekście jednego wątku, problemy ze współbieżnością nie występują.

Rozdział 5. • Stan



167

Język Clojure udostępnia specjalną konstrukcję set! służącą do tworzenia wiązań dynamicznych lokalnych dla wątku. (set! var-symbol new-value)

Nie należy nadużywać instrukcji set!. Jedyne miejsce w całym rdzeniu języka Clojure, w którym występuje ta instrukcja, to używana w Clojure implementacja parsera SAX ContentHandler. Parser ContentHandler otrzymuje wywołania zwrotne po napotkaniu określonych elementów w strumieniu danych XML-owych. W bardziej złożonych sytuacjach parser musi śledzić, w którym miejscu strumienia się znajduje. Wymaga to przechowywania stosu otwartych elementów, bieżących danych itd. W kontekście języka Clojure bieżącą pozycję parsera ContentHandler można traktować jak zmienny wskaźnik do określonego miejsca w niezmiennym strumieniu danych XML-owych. W parserze ContentHandler nie trzeba stosować referencji, ponieważ wszystkie operacje odbywają się w jednym wątku. Zamiast referencji w parserze wykorzystuje się zmienne dynamiczne i instrukcję set!. Poniżej przedstawiamy odpowiedni kod. ; Kod z pliku xml.clj języka Clojure zmodyfikowany w celu skoncentrowania się na ; stosowaniu zmiennych dynamicznych. (startElement [uri local-name q-name #^Attributes atts] ; Szczegóły pominięto. (set! *stack* (conj *stack* *current*)) (set! *current* e) (set! *state* :element)) nil) (endElement [uri local-name q-name] ; Szczegóły pominięto. (set! *current* (push-content (peek *stack*) *current*)) (set! *stack* (pop *stack*)) (set! *state* :between) nil)

Parser SAX wywołuje funkcję startElement po napotkaniu początkowego znacznika XML-owego. Ta wywoływana zwrotnie funkcja aktualizuje trzy zmienne lokalne dla wątku. Zmienna *stack* przechowuje stos wszystkich elementów, w których zagnieżdżony jest bieżący element. Zmienna *current* przechowuje bieżący element, a zmienna *state* służy do śledzenia rodzaju danych (ma to znaczenie zwłaszcza w danych znakowych, których tu nie przedstawiamy). Funkcja endElement działa odwrotnie do funkcji startElement — pobiera kolekcję *stack* i umieszcza jej wierzchołek w zmiennej *current*.

168

Programowanie w języku Clojure



Warto zauważyć, że jest to standardowy sposób pisania kodu. Obiekty są zmienne, a programy działają w jednym wątku, dlatego współbieżność nie jest tu problemem. Język Clojure umożliwia stosowanie tego podejścia w wyjątkowych sytuacjach. Należy z niego korzystać tylko w celu tworzenia kodu współdziałającego z Javą. Zastosowanie instrukcji set! w parserze ContentHandler nie powoduje ujawniania zmiennych danych w innych miejscach języka Clojure. W języku tym parser ContentHandler służy do tworzenia typowej dla języka niezmiennej struktury. Poznałeś już cztery różne modele zarządzania stanem. Ponieważ Clojure jest oparty na Javie, można też korzystać z opartego na blokadach modelu z Javy. Modele i ich zastosowania przedstawiamy w poniższej tabeli. Model

Zastosowanie

Funkcje

Referencje i pamięć STM

Aktualizacje skoordynowane i synchroniczne

Czyste

Atomy

Aktualizacje nieskoordynowane i synchroniczne

Czyste

Agenty

Aktualizacje nieskoordynowane i asynchroniczne

Dowolne

Zmienne

Zasięg dynamiczny lokalny dla wątku

Dowolne

Blokady Javy

Aktualizacje skoordynowane i synchroniczne

Dowolne

Wykorzystajmy te modele w projekcie niewielkiej, ale kompletnej aplikacji.

5.6. Gra Snake w języku Clojure W grze Snake użytkownik kontroluje węża, który goni jabłko na planszy w postaci siatki. Kiedy wąż zjada jabłko, wydłuża się o jeden segment, a na planszy pojawia się nowy owoc. Jeśli wąż osiąga określoną długość, gracz zwycięża, natomiast zetknięcie się ze sobą różnych części węża oznacza porażkę. Przed przejściem do kodu wypróbuj gotową wersję gry. W środowisku REPL z kodem z książki wpisz następującą instrukcję: (use 'examples.snake) (game) -> [# # #]

Wybierz okno z grą Snake. Do kontrolowania węża służą strzałki.

Rozdział 5. • Stan



169

W projekcie gry wykorzystujemy funkcyjny charakter języka Clojure i obsługę zmiennego stanu. Kod gry podzielony jest na trzy warstwy.  W modelu funkcyjnym stosujemy czyste funkcje do obsługi jak największej części gry.  W modelu zmiennego stanu zarządzamy zmiennym stanem gry. Wykorzystujemy tu modele referencji opisane wcześniej w rozdziale. Stan zmienny jest znacznie trudniejszy do testowania, dlatego staramy się ograniczać ilość kodu w tym modelu.  Interfejs GUI jest oparty na Swingu i służy do wyświetlania gry oraz przyjmowania danych wejściowych od użytkownika. Dzięki podziałowi na warstwy tworzenie, testowanie i konserwowanie gry jest proste. W trakcie rozwijania gry dodawaj nowe fragmenty do pliku reader/snake.clj z przykładowym kodem. W pliku znajdują się już instrukcje import i use dodające potrzebne klasy Swinga i biblioteki języka Clojure. src/reader/snake.clj

(ns reader.snake (:import (java.awt Color Dimension) (javax.swing JPanel JFrame Timer JOptionPane) (java.awt.event ActionListener KeyListener)) (:use examples.import-static)) (import-static java.awt.event.KeyEvent VK_LEFT VK_RIGHT VK_UP VK_DOWN)

Teraz można utworzyć model funkcyjny.

Model funkcyjny Najpierw utwórzmy zestaw stałych opisujących czas, miejsce i ruch. (def (def (def (def (def (def

width 75) height 50) point-size 10) turn-millis 75) win-length 5) dirs { VK_LEFT [-1 0] VK_RIGHT [ 1 0] VK_UP [ 0 -1] VK_DOWN [ 0 1]})

Stałe width i height określają wielkość planszy, a stała point-size służy do przekształcania punktów z gry na piksele na ekranie. Stała turn-millis wyznacza „puls” gry. Określa, ile milisekund mija między kolejnymi aktualizacjami planszy. W stałej win-lenght zapisujemy, ile segmentów musi mieć wąż, aby gra zakończyła się zwycięstwem (pięć to niska, mało ambitna wartość odpowiednia na

170



Programowanie w języku Clojure

potrzeby testów). Stała dirs obejmuje odwzorowanie stałych symbolicznych (określających cztery kierunki) na odpowiednie wektory. Ponieważ we frameworku Swing zdefiniowane są stałe VK_ dla różnych kierunków, wykorzystujemy te stałe, zamiast definiować własne. Teraz utwórzmy proste funkcje matematyczne potrzebne w grze. (defn add-points [& pts] (vec (apply map + pts))) (defn point-to-screen-rect [pt] (map #(* point-size %) [(pt 0) (pt 1) 1 1]))

Funkcja add-points dodaje do siebie punkty widoczne na ekranie. Za pomocą tej funkcji można obliczyć nową pozycję poruszającego się obiektu, na przykład przesunąć obiekt z pozycji [10,10] o jeden punkt w lewo. (add-points [10 10] [-1 0]) -> [9 10]

Inne implementacje gry Snake Istnieje wiele odmian węży. Ciekawe może być porównanie przedstawionej tu wersji gry z innymi. Oto krótka lista wybranych wersji:  Gra Snake Davida Van Hornaa napisana w języku Typed Scheme. W tej wersji nie występuje zmienny stan.  Jeremy Read napisał grę Snake w Javieb. Opisują ją tak: „Kod jest tak krótki, jak to możliwe w Javie, a przy tym wciąż czytelny”.  Abhishek Reddy napisał krótką (35-wierszową) wersję gry Snakec w języku Clojure. Celem Abhisheka było maksymalne skrócenie kodu.  Dale Vaillancourt jest autorem gry Wormd.  Mark Volkmann napisał grę Snake w języku Clojuree, koncentrując się na czytelności kodu. Każda z tych implementacji gry jest napisana w innym stylu. Jaki styl Ty byś wybrał? a. http://planet.plt-scheme.org/package-source/dvanhorn/snake.plt/ 1/0/main.ss b. http://www.plt1.com/1069/smaller-snake/ c. http://www.plt1.com/1070/even-smaller-snake/ d. http://www.ccs.neu.edu/home/cce/acl2/worm.html. Obejmuje sprawdzanie poprawności z wykorzystaniem narzędzia do dowodzenia twierdzeń, ACL2. e. http://www.ociweb.com/mark/programming/ClojureSnake.html

Rozdział 5. • Stan



171

Funkcja point-to-screen-rect przekształca punkt planszy na prostokąt na ekranie. (point-to-screen-rect [5 10]) -> (50 100 10 10)

Napiszmy teraz funkcję do tworzenia nowego jabłka. (defn create-apple [] {:location [(rand-int width) (rand-int height)] :color (Color. 210 50 90) :type :apple})

Jabłka zajmują jeden punkt, :location, który zawsze znajduje się na planszy. Bardziej skomplikowane jest tworzenie węża. (defn create-snake [] {:body (list [1 1]) :dir [1 0] :type :snake :color (Color. 15 160 70)})

Ponieważ wąż może zajmować wiele punktów na planszy, posiada ciało (:body), któremu odpowiada lista punktów. Ponadto wąż zawsze porusza się w pewnym kierunku, określonym przez :dir. Utwórzmy teraz funkcję move, służącą do poruszania węża. Powinna to być czysta funkcja, zwracająca nowego węża. Należy też uwzględnić w niej opcję grow, pozwalającą wydłużyć węża po zjedzeniu jabłka. (defn move [{:keys [body dir] :as snake} & grow] (assoc snake :body (cons (add-points (first body) dir) (if grow body (butlast body)))))

W funkcji move wykorzystujemy stosunkowo skomplikowane wyrażenie binding. Człon {:keys [body dir]} sprawia, że dla nazw body i dir tworzone są odrębne wiązania, a fragment :as snake wiąże nazwę snake z całym wężem. Funkcja działa w następujący sposób: 1. Instrukcja add-points tworzy nowy punkt, który wyznacza głowę pierwotnego węża przesuniętą z uwzględnieniem kierunku ruchu. 2. Instrukcja cons dodaje nowy punkt na początek węża. Jeśli należy wydłużyć węża, jego pierwotne segmenty zostają zachowane. W przeciwnym razie usuwany jest ostatni segment (butlast). 3. Instrukcja assoc zwraca nowego węża. Jest to kopia dawnego węża ze zaktualizowanym ciałem (:body).

172



Programowanie w języku Clojure

Przetestujmy funkcję move przez przesunięcie i wydłużenie węża. (move (create-snake)) -> {:body ([2 1]), ; Itd. (move (create-snake) :grow) -> {:body ([2 1] [1 1]), ; Itd.

Teraz należy napisać funkcję win?. Sprawdza ona, czy gra zakończyła się zwycięstwem. (defn win? [{body :body}] (>= (count body) win-length))

Przetestujmy funkcję win? dla węży o różnej długości. Zauważ, że funkcja win? wiąże tylko :body, dlatego nie trzeba tworzyć „prawdziwego” węża. Wystarczy utworzyć jakąkolwiek strukturę z elementem :body. (win? {:body [[1 1]]}) -> false (win? {:body [[1 1] [1 2] [1 3] [1 4] [1 5]]}) -> true

Jeśli głowa węża zetknie się z resztą jego ciała, gracz przegrywa. Funkcja head-overlaps-body? służy do wykrywania zetknięcia i pozwala zdefiniować funkcję lose?. (defn head-overlaps-body? [{[head & body] :body}] (contains? (set body) head)) (def lose? head-overlaps-body?)

Przetestujmy funkcję lose? dla sytuacji, w których głowa styka lub nie styka się z ciałem. (lose? {:body [[1 1] [1 2] [1 3]]}) -> false (lose? {:body [[1 1] [1 2] [1 1]]}) -> true

Wąż zjada jabłko, jeśli głowa znajduje się w tym samym miejscu co owoc. Do wykrywania tej sytuacji służy funkcja eats?. (defn eats? [{[snake-head] :body} {apple :location}] (= snake-head apple))

Warto zauważyć, jak przejrzysta jest funkcja eats?. Wszystkie zadania wykonywane są w wiązaniach. Wyrażenie {[snake-head] :body} wiąże głowę (snake-head) z pierwszym elementem kolekcji :body, a wyrażenie {apple :location} wiąże nazwę apple z położeniem jabłka (:location). Przetestujmy funkcję eats? w środowisku REPL.

Rozdział 5. • Stan



173

(eats? {:body [[1 1] [1 2]]} {:location [2 2]}) -> false (eats? {:body [[2 2] [1 2]]} {:location [2 2]}) -> true

Potrzebny jest też sposób na zmianę kierunku węża. Służy do tego funkcja turn aktualizująca kierunek (:dir). (defn turn [snake newdir] (assoc snake :dir newdir))

Funkcja turn zwraca nowego węża poruszającego się w określonym kierunku. (turn (create-snake) [0 -1]) -> {:body ([1 1]), :dir [0 -1], ; Itd.

Cały napisany do tego miejsca kod należy do modelu funkcyjnego gry. Kod ten jest zrozumiały — po części dlatego, że nie występują tu zmienne lokalne ani zmienny stan. W następnym punkcie przekonasz się, że zmienny stan w grze nie jest rozbudowany. Możliwe jest nawet zaimplementowanie gry Snake bez zmiennego stanu, ale nie to jest celem przykładu.

Tworzenie zmiennego modelu za pomocą pamięci STM Zmienny stan gry Snake może się zmieniać tylko na trzy sposoby.  Można przywrócić wyjściowy stan gry.  W każdej kolejce aktualizowana jest pozycja węża. Po zjedzeniu jabłka należy dodać nowy owoc.  Wąż może skręcać. Wszystkie zmiany implementujemy jako funkcje modyfikujące w ramach transakcji referencje języka Clojure. Dzięki temu zmiany pozycji węża oraz jabłka są wprowadzane synchronicznie i w skoordynowany sposób. Funkcja reset-game jest niezwykle prosta. (defn reset-game [snake apple] (dosync (ref-set apple (create-apple)) (ref-set snake (create-snake))) nil)

Funkcję reset-game można przetestować przez przekazanie referencji i sprawdzenie, czy operacja dereferencji pozwala otrzymać węża i jabłko. (def test-snake (ref nil)) (def test-apple (ref nil))

174

Programowanie w języku Clojure



(reset-game test-snake test-apple) -> nil @test-snake -> {:body ([1 1]), :dir [1 0], ; Itd. @test-apple -> {:location [52 8], ; Itd.

Funkcja update-direction jest jeszcze prostsza. Jest ona banalną nakładką na funkcyjną instrukcję turn. (defn update-direction [snake newdir] (when newdir (dosync (alter snake turn newdir))))

Spróbujmy obrócić testowego węża test-snake, tak aby kierował się w górę. (update-direction test-snake [0 -1]) -> {:body ([1 1]), :dir [0 -1], ; Itd.

Najbardziej skomplikowana funkcja związana ze zmiennym stanem to update-positions. Zjedzenie jabłka prowadzi do utworzenia nowego owocu i wydłużenia węża. Jeśli wąż nie zjadł jabłka, wystarczy go przesunąć. (defn update-positions [snake apple] (dosync (if (eats? @snake @apple) (do (ref-set apple (create-apple)) (alter snake move :grow)) (alter snake move))) nil)

Aby przetestować funkcję update-positions, przywróćmy wyjściowy stan gry. (reset-game test-snake test-apple) -> nil

Następnie umieśćmy jabłko na drodze węża, tuż pod nim. (dosync (alter test-apple assoc :location [1 1])) -> {:location [1 1], ; Itd.

Po wywołaniu funkcji dwa segmenty).

update-positions

wąż powinien być dłuższy (obejmować

(update-positions test-snake test-apple) -> nil (:body @test-snake) -> ([2 1] [1 1])

To już cały zmienny stan z gry Snake — trzy funkcje i kilkanaście wierszy kodu.

Rozdział 5. • Stan



175

Graficzny interfejs użytkownika gry Graficzny interfejs użytkownika gry Snake składa się z funkcji, które wyświetlają obiekty na ekranie, reagują na działania użytkowników i ustawiają różne komponenty frameworku Swing. Ponieważ węże i jabłka są wyświetlane na podstawie prostych punktów, funkcje wyświetlające elementy nie są skomplikowane. Funkcja fill-point wypełnia jeden punkt. (defn fill-point [g pt color] (let [[x y width height] (point-to-screen-rect pt)] (.setColor g color) (.fillRect g x y width height)))

Wielometoda paint potrafi wyświetlać węże i jabłka. 1 (defmulti paint (fn [g object & _] (:type object))) 2 3 (defmethod paint :apple [g {:keys [location color]}] 4 (fill-point g location color)) 5 6 (defmethod paint :snake [g {:keys [body color]}] 7 (doseq [point body] 8 (fill-point g point color)))

Wielometoda paint przyjmuje dwa wymagane argumenty. Argument g to egzemplarz klasy java.awt.Graphics, a object to wyświetlany obiekt. Instrukcja defmulti ma opcjonalny argument rest, dlatego inne wersje wielometody paint mogą przyjmować dodatkowe argumenty (szczegółowe omówienie instrukcji defmulti znajdziesz w podrozdziale 8.2, „Definiowanie wielometod”). W wierszu 3. metoda :apple wielometody paint wiąże lokalizację (location) i kolor (color) z jabłkiem, a następnie używa tych elementów do wyświetlenia jednego punktu na ekranie. W wierszu 6. metoda :snake określa ciało (body) i kolor (color) węża, a następnie za pomocą instrukcji doseq wyświetla każdy punkt (point) z kolekcji body. Istotą interfejsu użytkownika jest funkcja game-panel, która tworzy obiekt JPanel frameworku Swing z metodami do obsługi wyświetlania interfejsu gry, aktualizowania go przy każdym takcie zegara i reagowania na działania użytkownika. 1 (defn game-panel [frame snake apple] (proxy [JPanel ActionListener KeyListener] [] (paintComponent [g] (proxy-super paintComponent g) 5 (paint g @snake) (paint g @apple)) (actionPerformed [e] (update-positions snake apple) (when (lose? @snake) 10 (reset-game snake apple) (JOptionPane/showMessageDialog frame "Przegrałeś!"))

176



15 20 -

(when (win? @snake) (reset-game snake apple) (JOptionPane/showMessageDialog frame "Wygrałeś!")) (.repaint this)) (keyPressed [e] (update-direction snake (dirs (.getKeyCode e)))) (getPreferredSize [] (Dimension. (* (inc width) point-size) (* (inc height) point-size))) (keyReleased [e]) (keyTyped [e])))

Programowanie w języku Clojure

Funkcja game-panel jest długa, ale prosta. Instrukcja proxy służy do utworzenia panelu z zestawem wywoływanych zwrotnie metod frameworku Swing.  Swing wywołuje metodę paintComponent (wiersz 3.), aby wyświetlić panel. Metoda ta wywołuje instrukcję proxy-super w celu uruchomienia standardowych operacji klasy JPanel, a następnie wyświetla węża i jabłko.  W każdym takcie zegara Swing wywołuje metodę actionPerformed (wiersz 7.). Metoda ta aktualizuje pozycje węża i jabłka. Jeśli gra się skończyła, metoda wyświetla okno dialogowe i przywraca wyjściowy stan gry. W ostatnim kroku odświeża ekran, wywołując instrukcję (.repaint this).  W reakcji na wciśnięcie klawisza Swing wywołuje metodę keyPressed (wiersz 16.). Metoda ta wywołuje instrukcję update-direction, aby zmienić kierunek ruchu węża. Jeśli wciśnięty klawisz to nie strzałka, funkcja dirs zwraca wartość nil, a funkcja update-direction nie wykonuje żadnych operacji.  W panelu gry metody keyReleased i keyTyped są ignorowane. Funkcja game tworzy nową grę. 1 (defn game [] (let [snake (ref (create-snake)) apple (ref (create-apple)) frame (JFrame. "Snake") 5 panel (game-panel frame snake apple) timer (Timer. turn-millis panel)] (doto panel (.setFocusable true) (.addKeyListener panel)) 10 (doto frame (.add panel) (.pack) (.setVisible true)) (.start timer) 15 [snake, apple, timer]))

Rozdział 5. • Stan



177

W wierszu 2. funkcja game tworzy wszystkie potrzebne obiekty gry — snake i apple z modelu zmiennego stanu, a także komponenty interfejsu użytkownika frame, panel i timer. Wiersze od 7. do 10. to szablonowy kod do inicjowania komponentów panel i frame. W wierszu 14. funkcja uruchamia grę przez włączenie zegara. Wiersz 15. zwraca wektor obejmujący węża, jabłko i czas. Jest to udogodnienie pomocne przy testowaniu gry w środowisku REPL. Obiekty można wykorzystać do przenoszenia węża i jabłka, a także do uruchamiania i zatrzymywania gry. Teraz możesz ponownie zagrać w grę — zasłużyłeś na to! Aby uruchomić grę, wywołaj w środowisku REPL instrukcję use dla biblioteki z grą i uruchom funkcję game. Jeśli sam wprowadziłeś kod, możesz użyć wybranej przez siebie nazwy biblioteki (w instrukcjach jest to examples.reader). Możesz też uruchomić gotowy przykładowy kod z biblioteki examples.snake. (use 'examples.snake) (game) -> [# # #]

Okno z grą może znajdować się za oknem środowiska REPL. W takiej sytuacji użyj mechanizmów systemu operacyjnego do zlokalizowania okna gry. Przedstawioną tu grę można usprawnić na wiele sposobów. Kiedy wąż dociera do krawędzi ekranu, powinien zawracać, zamiast znikać z ekranu. Może to też oznaczać porażkę. Utwórz własną, ulepszoną wersję gry Snake przez dopasowanie jej do własnego stylu.

Gra Snake bez referencji Model zmiennego stanu w grze Snake zaimplementowaliśmy za pomocą referencji, co pozwala skoordynować aktualizacje pozycji węża i jabłka. Możliwe są też inne podejścia, takie jak umieszczenie stanu węża i jabłka w jednym obiekcie game. Jeśli istnieje tylko jeden obiekt, można obejść się bez koordynacji i zastosować atom. Podejście to przedstawiamy w pliku examples/atom-snake.clj. Funkcje w rodzaju update-positions są tu częścią modelu funkcyjnego i zwracają nowy obiekt game o zaktualizowanym stanie.

178



Programowanie w języku Clojure

src/examples/atom_snake.clj

(defn update-positions [{snake :snake, apple :apple, :as game}] (if (eats? snake apple) (merge game {:apple (create-apple) :snake (move snake :grow)}) (merge game {:snake (move snake)})))

Warto zauważyć, że rozkładanie struktury pozwala na łatwy dostęp do wewnętrznych danych obiektu game. Zarówno element snake, jak i apple są powiązane na liście argumentów. Aktualizacje zmiennych elementów odbywają się teraz za pomocą instrukcji swap! atomów. Operacje te są na tyle proste, że umieściliśmy je w funkcji game-panel interfejsu użytkownika, co widać w poniższym fragmencie. (actionPerformed [e] (swap! game update-positions) (when (lose? (@game :snake)) (swap! game reset-game) (JOptionPane/showMessageDialog frame "Przegrałeś!"))

Istnieją też inne możliwości. Chris Houser w swojej wersji przykładowego kodu z książki2 pokazuje, jak zastosować agenta i instrukcje sleep obiektu Thread zamiast zegara z frameworku Swing. Do aktualizowania stanu gry służy tu nowy agent generowany dla każdego ruchu.

5.7. Podsumowanie Model referencji w języku Clojure jest jednym z najbardziej innowacyjnych aspektów języka. Połączenie mechanizmów opisanych w tym rozdziale, czyli programowej pamięci transakcyjnej, agentów, atomów i wiązania dynamicznego, pozwala w abstrakcyjny sposób ująć dowolne systemy stanowe. Sprawia też, że Clojure to jeden z nielicznych języków dostosowanych do nadchodzącej generacji sprzętu wielordzeniowego. Dalej omawiamy jedną z nowszych cech języka Clojure. Niektórzy nazywają ją rozwiązaniem „problemu rozszerzalności języka”3 (ang. expression problem). My mówimy na nią „protokół”.

2 3

http://github.com/Chouser/programming-clojure http://en.wikipedia.org/wiki/Expression_problem

Rozdział 6.

Protokoły i typy danych

P

odstawą kodu wielokrotnego użytku są abstrakcje. Sam język Clojure obejmuje abstrakcje reprezentujące sekwencje i kolekcje oraz umożliwiające wywoływanie. Tradycyjnie do opisu tych abstrakcji służyły interfejsy Javy, które implementowano za pomocą klas tego języka. Początkowo w języku Clojure dostępne były instrukcje proxy i genclass, dzięki czemu nie trzeba było korzystać z Javy przy wykonywaniu wspomnianych operacji. Jednak wraz z pojawieniem się protokołów zmienił się cały model.  Protokoły są alternatywą dla interfejsów Javy, jeśli programiście zależy na wydajnym polimorficznym określaniu metod.  Typy danych są alternatywą dla klas Javy w zakresie implementowania abstrakcji zdefiniowanych za pomocą protokołów lub interfejsów. Protokoły i typy danych zapewniają wydajny, elastyczny mechanizm do tworzenia abstrakcji i konkretnego kodu. Dzięki temu nie trzeba pisać interfejsów i klas Javy w czasie programowania w języku Clojure. Za pomocą protokołów i typów danych można tworzyć nowe abstrakcje i implementujące je typy, a nawet rozszerzać nowe abstrakcje na istniejące typy. W tym rozdziale omawiamy abstrakcje oparte na protokołach i typach danych, stosowane w języku Clojure. Najpierw implementujemy własną wersję wbudowanych funkcji spit i slurp tego języka. Następnie tworzymy typ CryptoVault. Zobaczysz przy tym, jak rozszerzyć pewną standardową bibliotekę Javy.

180



Programowanie w języku Clojure

W końcowej części rozdziału łączymy wszystkie elementy. Za pomocą rekordów i protokołów definiujemy nuty i sekwencje muzyczne. Po wykonaniu ćwiczeń z pewnością docenisz możliwości abstrakcji języka Clojure.

6.1. Programowanie z wykorzystaniem abstrakcji Funkcje spit i slurp języka Clojure służą do obsługi wejścia-wyjścia i są oparte na dwóch abstrakcjach — odczycie i zapisie. Oznacza to, że można stosować je dla różnych typów wejściowych i docelowych, w tym dla plików, adresów URL i gniazd. Ponadto każdy może rozszerzyć te funkcje, aby dodać obsługę innych typów (istniejących lub zdefiniowanych od podstaw).  Funkcja slurp przyjmuje źródło danych wejściowych, wczytuje treść i zwraca ją w formie łańcucha znaków.  Funkcja spit przyjmuje docelową lokalizację danych wyjściowych i wartość, przekształca wartość na łańcuch znaków, po czym zapisuje ją w docelowej lokalizacji. Zaczynamy od napisania prostych wersji obu funkcji. Wersje te potrafią wczytywać i zapisywać dane tylko dla plików. Dalej kilkakrotnie modyfikujemy te wersje w czasie przedstawiania różnych sposobów obsługi dodatkowych typów danych. Wykonanie ćwiczeń pozwoli Ci dobrze zrozumieć wartość programowania z wykorzystaniem abstrakcji, a konkretnie — docenić możliwości protokołów i typów danych języka Clojure. Po napisaniu własnych wersji funkcji spit i slurp (nasze wersje noszą nazwy i gulp) działających dla kilku istniejących typów danych tworzymy nowy taki typ, CryptoVault, który można stosować w naszych funkcjach, a także w ich pierwotnych wersjach. expectorate

Funkcje gulp i expectorate Funkcja gulp to uproszczona wersja funkcji slurp języka Clojure, a expectorate, mimo wymyślnej nazwy, to uboższa odmiana funkcji spit tego języka. Napiszmy prostą wersję funkcji gulp, która potrafi wczytywać dane tylko z plików (typ java.io.File).

Rozdział 6. • Protokoły i typy danych



181

src/examples/gulp.clj

(ns examples.gulp (:import (java.io FileInputStream InputStreamReader BufferedReader))) (defn gulp [src] (let [sb (StringBuilder.)] (with-open [reader (-> src FileInputStream. InputStreamReader. BufferedReader.)] (loop [c (.read reader)] (if (neg? c) (str sb) (do (.append sb (char c)) (recur (.read reader))))))))

Funkcja gulp tworzy obiekt BufferedReader na podstawie obiektu File. Następnie wywołuje instrukcje loop i recur, wczytując znak po znaku i dodając każdy do obiektu StringBuilder do momentu dojścia do końca danych wejściowych. Wtedy zwraca łańcuch znaków. Prosta funkcja expectorate jest jeszcze krótsza. src/examples/expectorate.clj

(ns examples.expectorate (:import (java.io FileOutputStream OutputStreamWriter BufferedWriter))) (defn expectorate [dst content] (with-open [writer (-> dst FileOutputStream. OutputStreamWriter. BufferedWriter.)] (.write writer (str content))))

Funkcja ta tworzy plik BufferedWriter, przekształca wartość parametru na łańcuch znaków, a następnie zapisuje ją do wspomnianego pliku.

content

Co zrobić, aby zapewnić obsługę także dodatkowych typów, takich jak gniazda (Socket), adresy URL oraz proste strumienie wejścia i wyjścia? Wymaga to zmodyfikowania funkcji gulp i expectorate tak, aby potrafiły tworzyć obiekty BufferedReader i BufferedWriter na podstawie typów danych innych niż pliki. Utwórzmy więc dwie nowe funkcje, make-reader i make-writer, które będą odpowiadać za te operacje.  Funkcja make-reader tworzy obiekt BufferedReader na podstawie źródła danych wejściowych.  Funkcja make-writer tworzy obiekt BufferedWriter na podstawie docelowego miejsca na dane wyjściowe.

182



Programowanie w języku Clojure

(defn make-reader [src] (-> src FileInputStream. InputStreamReader. BufferedReader.)) (defn make-writer [dst] (-> dst FileOutputStream. OutputStreamWriter. BufferedWriter.))

Funkcje make-reader i make-writer, podobnie jak podstawowe funkcje gulp i expectorate, działają tylko dla plików, jednak wkrótce się tym zajmiemy. Teraz zmodyfikujmy funkcje gulp i expectorate tak, aby korzystały z nowych funkcji. src/examples/protocols.clj

(defn gulp [src] (let [sb (StringBuilder.)] (with-open [reader (make-reader src)] (loop [c (.read reader)] (if (neg? c) (str sb) (do (.append sb (char c)) (recur (.read reader)))))))) (defn expectorate [dst content] (with-open [writer (make-writer dst)] (.write writer (str content))))

Teraz obsługę nowych typów źródłowych i docelowych można dodać do funkcji gulp i expectorate przez zmodyfikowanie funkcji make-reader i make-writer. Jednym ze sposobów na obsługę innych typów jest zastosowanie instrukcji cond lub condp, która pozwala w odpowiedni sposób przetwarzać poszczególne typy. W poniższej wersji funkcji make-reader zamiast wywołania konstruktora FileInputStream znajduje się instrukcja condp, która tworzy obiekt InputStream dla danych wejściowych niezależnie od ich typu (może to być typ File, Socket, URL lub InputStream). (defn make-reader [src] (-> (condp = (type src) java.io.InputStream src java.lang.String (FileInputStream. src) java.io.File (FileInputStream. src) java.net.Socket (.getInputStream src) java.net.URL (if (= "file" (.getProtocol src)) (-> src .getPath FileInputStream.) (.openStream src))) InputStreamReader. BufferedReader.))

Oto wersja funkcji make-writer napisana przy użyciu tego samego podejścia. (defn make-writer [dst] (-> (condp = (type dst) java.io.OutputStream dst java.io.File (FileOutputStream. dst) java.lang.String (FileOutputStream. dst)

Rozdział 6. • Protokoły i typy danych



183

java.net.Socket (.getOutputStream dst) java.net.URL (if (= "file" (.getProtocol dst)) (-> dst .getPath FileOutputStream.) (throw (IllegalArgumentException. "Adres URL nie prowadzi do pliku")))) OutputStreamWriter. BufferedWriter.))

Problem z tym podejściem polega na tym, że nie umożliwia ono zmian. Nikt inny nie może dodać obsługi nowych typów źródłowych i docelowych bez modyfikowania funkcji make-reader i make-writer. Potrzebne jest „otwarte” rozwiązanie, w którym obsługę nowych typów może dodać każdy. Niezbędne są do tego dwie abstrakcje — jedna dla odczytu, druga dla zapisu.

6.2. Interfejsy W Javie standardowym mechanizmem do obsługi opisanego rodzaju abstrakcji są interfejsy. Interfejs umożliwia wywoływanie abstrakcyjnych funkcji (określonych w definicji interfejsu) i kierowanie tych wywołań do konkretnych implementacji na podstawie typu danych pierwszego parametru przekazanego w wywołaniu. W Javie pierwszy parametr jest określony niejawnie. Jest nim obiekt z implementacją danego interfejsu. Oto zalety interfejsów:  typy danych mogą implementować wiele interfejsów;  interfejsy określają tylko specyfikację, a nie implementację, co pozwala na implementowanie różnych interfejsów bez problemów związanych z wielodziedziczeniem. Wadą interfejsów jest to, że istniejących typów danych nie można rozszerzyć o implementację nowych interfejsów bez wprowadzania modyfikacji. W języku Clojure interfejsy Javy można tworzyć za pomocą makra definterface. Makro to przyjmuje nazwę i sygnatury jednej lub kilku metod. (definterface name & sigs)

Utwórzmy w formie interfejsu IOFactory abstrakcję dla „elementów, z których można wczytywać i w których można zapisywać dane”. (definterface IOFactory (^java.io.BufferReader make-reader [this]) (^java.io.BufferedWriter make-writer [this]))

184



Programowanie w języku Clojure

Ten kod tworzy interfejs IOFactory, obejmujący dwie abstrakcyjne funkcje — make-reader i make-writer. Każda klasa z implementacją tego interfejsu musi obejmować takie funkcje, które przyjmują jeden parametr i egzemplarz określonego typu danych, a zwracają obiekty BufferedReader i BufferedWriter. Niestety, interfejsy implementowane w klasie są określane w czasie jej tworzenia przez autora. Po zdefiniowaniu klasy Javy nie można bez jej modyfikowania dodać obsługi nowych interfejsów. Dlatego nie można rozszerzyć klas File, Socket ani URL o implementację interfejsu IOFactory. Podobnie jak wersje funkcji make-reader i make-writer oparte na instrukcji condp, tak i interfejs nie umożliwia rozszerzania przez osoby inne niż jego autor. Jest to jeden z aspektów problemu rozszerzalności języka (ang. expression problem)1. Na szczęście w języku Clojure istnieje jego rozwiązanie2.

6.3. Protokoły Jednym z elementów rozwiązania zastosowanego w języku Clojure jest protokół. Protokoły to elastyczny mechanizm tworzenia abstrakcji. Mają najlepsze cechy interfejsów, ponieważ określają tylko specyfikację, a nie implementację, co pozwala na implementowanie w typach danych wielu protokołów. Ponadto protokoły nie mają największej wady interfejsów, ponieważ umożliwiają nieinwazyjne rozszerzanie istniejących typów o obsługę nowych protokołów. Oto zalety protokołów:  w typach danych można implementować wiele protokołów;  protokoły określają tylko specyfikację, a nie implementację, co pozwala na zaimplementowanie wielu interfejsów bez problemów charakterystycznych dla wielodziedziczenia;  istniejące typy danych można rozszerzać o implementację nowych interfejsów bez konieczności modyfikowania tych typów;  nazwy metod protokołów należą do przestrzeni nazw, dlatego nie występuje zagrożenie konfliktami nazw, kiedy kilku programistów rozszerza ten sam typ.

1 2

http://lambda-the-ultimate.org/node/2232 http://www.ibm.com/developerworks/java/library/j-clojure-protocols/?ca=drs-

Rozdział 6. • Protokoły i typy danych



185

Makro defprotocol działa podobnie jak definterface, ale pozwala rozszerzyć istniejące typy danych o implementację nowej abstrakcji. (defprotocol name & opts+sigs)

Zdefiniujmy IOFactory jako protokół, a nie interfejs. (defprotocol IOFactory "Protokół dla elementów, z których można wczytywać i w których można zapisywać dane." (make-reader [this] "Tworzy obiekt BufferedReader.") (make-writer [this] "Tworzy obiekt BufferedWriter."))

Zauważ, że dokumentację można podać dla całego protokołu, jak i dla poszczególnych metod. Rozszerzmy teraz klasy java.io.InputStream i java.io.OutputStream o implementację protokołu IOFactory. Funkcja extend pozwala powiązać istniejący typ z protokołem i określić potrzebne implementacje funkcji (zwykle nazywanych w tym kontekście metodami). Parametry funkcji extend to nazwa rozszerzanego typu, nazwa implementowanego protokołu i odwzorowanie z implementacjami metod. Kluczami są tu nazwy metod przekształcone na słowa kluczowe. (extend type & proto+mmaps)

Implementacja funkcji make-reader dla typu InputStream umieszcza przekazaną do funkcji wartość w obiekcie BufferedReader. src/examples/protocols.clj

(extend InputStream IOFactory {:make-reader (fn [src] (-> src InputStreamReader. BufferedReader.)) :make-writer (fn [dst] (throw (IllegalArgumentException. "Nie można otworzyć jako InputStream.")))})

Implementacja metody make-writer dla typu OutputStream działa podobnie i zapisuje dane w obiekcie BufferedWriter. Ponieważ nie można zapisać danych do strumienia InputStream ani wczytywać ze strumienia OutputStream, opisywane implementacje metod make-writer i make-reader zgłaszają przy próbie wykonania tych operacji wyjątki IllegalArgumentException. (extend OutputStream IOFactory {:make-reader (fn [src] (throw (IllegalArgumentException. "Nie można otworzyć jako OutputStream."))) :make-writer (fn [dst] (-> dst OutputStreamWriter. BufferedWriter.))})

186



Programowanie w języku Clojure

Do rozszerzenia typu java.io.File o implementację protokołu IOFactory używamy makra extend-type, które ma trochę bardziej przejrzystą składnię niż makro extend. (extend-type type & specs)

Makro przyjmuje nazwę rozszerzanego typu i jedną lub więcej specyfikacji. Specyfikacje te obejmują nazwę protokołu i implementacje jego metod. (extend-type File IOFactory (make-reader [src] (make-reader (FileInputStream. src))) (make-writer [dst] (make-writer (FileOutputStream. dst))))

Warto zauważyć, że tworzymy tu strumień InputStream (a konkretnie — FileInputStream) na podstawie pliku, a następnie rekurencyjnie wywołujemy funkcję make-reader z implementacji zdefiniowanej wcześniej dla typu InputStream. To samo rekurencyjne rozwiązanie stosujemy dla metody make-writer, a także dla metod pozostałych typów. Wszystkie pozostałe typy można rozszerzyć w jednym miejscu, używając makra extend-protocol. (extend-protocol protocol & specs)

Makro to przyjmuje nazwę protokołu, po której następują nazwy jednego lub kilku typów i powiązane implementacje metod. (extend-protocol IOFactory Socket (make-reader [src] (make-reader (.getInputStream src))) (make-writer [dst] (make-writer (.getOutputStream dst))) URL (make-reader [src] (make-reader (if (= "file" (.getProtocol src)) (-> src .getPath FileInputStream.) (.openStream src)))) (make-writer [dst] (make-writer (if (= "file" (.getProtocol dst)) (-> dst .getPath FileInputStream.) (throw (IllegalArgumentException. "Adres URL nie prowadzi do pliku"))))))

Pora połączyć wszystkie elementy.

Rozdział 6. • Protokoły i typy danych (ns examples.io (:import (java.io File FileInputStream FileOutputStream InputStream InputStreamReader OutputStream OutputStreamWriter BufferedReader BufferedWriter) (java.net Socket URL))) (defprotocol IOFactory "Protokół dla elementów, z których można wczytywać i w których można zapisywać dane." (make-reader [this] "Tworzy obiekt BufferedReader.") (make-writer [this] "Tworzy obiekt BufferedWriter.")) (defn gulp [src] (let [sb (StringBuilder.)] (with-open [reader (make-reader src)] (loop [c (.read reader)] (if (neg? c) (str sb) (do (.append sb (char c)) (recur (.read reader)))))))) (defn expectorate [dst content] (with-open [writer (make-writer dst)] (.write writer (str content)))) (extend-protocol IOFactory InputStream (make-reader [src] (-> src InputStreamReader. BufferedReader.)) (make-writer [dst] (throw (IllegalArgumentException. "Nie można otworzyć jako InputStream."))) OutputStream (make-reader [src] (throw (IllegalArgumentException. "Nie można otworzyć jako OutputStream."))) (make-writer [dst] (-> dst OutputStreamWriter. BufferedWriter.)) File (make-reader [src] (make-reader (FileInputStream. src))) (make-writer [dst] (make-writer (FileOutputStream. dst))) Socket (make-reader [src] (make-reader (.getInputStream src)))



187

188



Programowanie w języku Clojure

(make-writer [dst] (make-writer (.getOutputStream dst))) URL (make-reader [src] (make-reader (if (= "file" (.getProtocol src)) (-> src .getPath FileInputStream.) (.openStream src)))) (make-writer [dst] (make-writer (if (= "file" (.getProtocol dst)) (-> dst .getPath FileInputStream.) (throw (IllegalArgumentException. "Adres URL nie prowadzi do pliku"))))))

6.4. Typy danych Pokazaliśmy, jak rozszerzać istniejące typy o implementację nowych abstrakcji za pomocą protokołów. Jak jednak utworzyć w Clojure nowy typ? Służą do tego typy danych. Typ danych ma następujące cechy:  unikatową klasę — nazwaną lub anonimową;  strukturę (albo określoną bezpośrednio za pomocą pól, albo pośrednio w postaci domknięcia);  pola (mogą mieć sugerowany typ, ang. type hint, lub typ prosty);  opcjonalne implementacje abstrakcyjnych metod określonych w protokołach lub interfejsach;  domyślnie obowiązującą niezmienność;  zgodność z odwzorowaniami (poprzez rekordy). Tu do zdefiniowania nowego typu, CryptoVault, używamy makra deftype. We wspomnianym typie implementujemy dwa protokoły, w tym IOFactory. Teraz, kiedy funkcje gulp i expectorate obsługują kilka istniejących klas Javy, utwórzmy nowy typ CryptoVault. Jego egzemplarz można utworzyć przez przekazanie argumentu z implementacją protokołu clojure.java.io.IOFactory (innego niż zdefiniowany w tekście), ścieżki do pliku z kluczem szyfrowania i hasła. Dane zapisywane w obiektach CryptoVault są szyfrowane i zapisywane w obiekcie IOFactory, a następnie odszyfrowywane w czasie odczytu.

Rozdział 6. • Protokoły i typy danych



189

Do utworzenia nowego typu używamy makra deftype. (deftype name [& fields] & opts+specs)

Makro to przyjmuje nazwę typu i wektor z jego polami. Nazwy typów tworzy się tu tak samo jak dla klas Javy, czyli przy użyciu NotacjiWielbłądziej. user=> (deftype CryptoVault [filename keystore password]) user.CryptoVault

Po zdefiniowaniu typu CryptoVault można utworzyć jego egzemplarz. user=> (def vault (->CryptoVault "vault-file" "keystore" "toomanysecrets")) #'user/vault

Dostęp do pól można uzyskać za pomocą tej samej składni (z przedrostkiem w postaci kropki), której używa się do dostępu do pól obiektów Javy. user=> (.filename vault) "vault-file" user=> (.keystore vault) "keystore" user=> (.password vault) "toomanysecrets"

Po zdefiniowaniu prostego typu CryptoVault dodajmy do niego metody. W typach danych można implementować tylko metody określone w protokole lub interfejsie. Utwórzmy więc najpierw protokół Vault. (defprotocol Vault (init-vault [vault]) (vault-output-stream [vault]) (vault-input-stream [vault]))

Protokół ten obejmuje trzy funkcje (init-vault, vault-output-stream i vault-input-stream ), które trzeba umieścić w każdym obiekcie z implementacją protokołu Vault. Metody nowego typu można zdefiniować wewnątrzwierszowo za pomocą makra deftype. Wystarczy przekazać (tak jak wcześniej) nazwę typu i wektor pól, a następnie podać nazwę protokołu i ciało metod. src/examples/cryptovault.clj

(ns examples.cryptovault (:use [examples.io :only [IOFactory make-reader make-writer]]) (:require [clojure.java.io :as io]) (:import (java.security KeyStore KeyStore$SecretKeyEntry KeyStore$PasswordProtection) (javax.crypto KeyGenerator Cipher CipherOutputStream CipherInputStream) (java.io FileOutputStream)))

190



Programowanie w języku Clojure

(deftype CryptoVault [filename keystore password] Vault (defn init-vault [vault] ... Ciało metody ...) (defn vault-output-stream [vault] ... Ciało metody ...) (defn vault-input-stream [vault] ... Ciało metody ...) IOFactory (make-reader [vault] (make-reader (vault-input-stream vault))) (make-writer [vault] (make-writer (vault-output-stream vault))))

Warto zauważyć, że wewnątrzwierszowo można zdefiniować metody z więcej niż jednego protokołu. Tu zdefiniowaliśmy metody protokołów Vault i IOFactory, choć ciała metod protokołu Vault pominęliśmy (opisujemy je dalej). Metoda init-vault generuje klucz AES (ang. Advanced Encryption Standard), umieszcza go w obiekcie java.security.KeyStore, zapisuje dane klucza w pliku określonym przez odpowiednie pole obiektu CryptoVault, a następnie zabezpiecza ten plik hasłem. (init-vault [vault] (let [password (.toCharArray (.password vault)) key (.generateKey (KeyGenerator/getInstance "AES")) keystore (doto (KeyStore/getInstance "JCEKS") (.load nil password) (.setEntry "vault-key" (KeyStore$SecretKeyEntry. key) (KeyStore$PasswordProtection. password)))] (with-open [fos (FileOutputStream. (.keystore vault))] (.store keystore fos password))))

Metody vault-output-stream i vault-input-stream korzystają z funkcji vault-key do wczytywania pliku z kluczem powiązanego z obiektem CryptoVault, pobierają klucz AES użyty do szyfrowania, a następnie odszyfrowują zawartość obiektu. (defn vault-key [vault] (let [password (.toCharArray (.password vault))] (with-open [fis (FileInputStream. (.keystore vault))] (-> (doto (KeyStore/getInstance "JCEKS") (.load fis password)) (.getKey "vault-key" password)))))

Metoda vault-output-stream używa metody vault-key do zainicjowania obiektu używanego przy szyfrowaniu metodą AES, tworzy strumień OutputStream na podstawie pliku Vault, a następnie używa obiektu szyfrującego i strumienia OutputStream do utworzenia egzemplarza strumienia CipherOutputStream.

Rozdział 6. • Protokoły i typy danych



191

(vault-output-stream [vault] (let [cipher (doto (Cipher/getInstance "AES") (.init Cipher/ENCRYPT_MODE (vault-key vault)))] (CipherOutputStream. (io/output-stream (.filename vault)) cipher)))

Aby utworzyć egzemplarz typu CryptoVault, wystarczy przekazać miejsce zapisu danych, pliku przeznaczonego na klucz i hasło zabezpieczające ten plik. user=> (def vault (->CryptoVault "vault-file" "keystore" "toomanysecrets")) #'user/vault

Jeśli nie określono jeszcze pliku przeznaczonego na klucz, należy wywołać metodę init-vault. user=> (init-vault vault) nil

Następnie obiektu CryptoVault można używać jak dowolnego innego obiektu źródłowego lub docelowego w funkcjach gulp i expectorate. user=> (expectorate vault "Test typu CryptoVault") nil user=> (gulp vault) "Test typu CryptoVault"

Typ CryptVault można też stosować razem z wbudowanymi funkcjami spit i slurp. Wymaga to dodania obsługi protokołu clojure.java.io/IOFactory. Ta wersja protokołu IOFactory ma cztery metody zamiast dwóch. Ponadto w odwzorowaniu default-streams-impl znajdują się domyślne implementacje tych metod. Tu przesłaniamy tylko dwie metody, make-input-stream i make-output-stream. W tym celu wywołujemy instrukcję assoc i wiążemy nowe implementacje ze wspomnianym odwzorowaniem (które przekazujemy do funkcji extend). (extend CryptoVault clojure.java.io/IOFactory (assoc clojure.java.io/default-streams-impl :make-input-stream (fn [x opts] (vault-input-stream x)) :make-output-stream (fn [x opts] (vault-output-stream x))))

To już wszystko. Teraz można wczytywać i zapisywać dane w obiektach CryptoVault za pomocą funkcji slurp i spit. user=> (spit vault "Test typu CryptoVault z wykorzystaniem funkcji spit i slurp") nil user=> (slurp vault) "Test typu CryptoVault z wykorzystaniem funkcji spit i slurp"

Umieśćmy cały kod w pliku .clj. Utwórz w katalogu projektu podkatalog src/ examples/datatypes i umieść w nim plik vault.clj.

192



Programowanie w języku Clojure

src/examples/cryptovault_complete.clj

(ns examples.cryptovault-complete (:require [clojure.java.io :as io] [examples.protocols.io :as proto]) (:import (java.security KeyStore KeyStore$SecretKeyEntry KeyStore$PasswordProtection) (javax.crypto Cipher KeyGenerator CipherOutputStream CipherInputStream) (java.io FileInputStream FileOutputStream))) (defprotocol Vault (init-vault [vault]) (vault-output-stream [vault]) (vault-input-stream [vault])) (defn vault-key [vault] (let [password (.toCharArray (.password vault))] (with-open [fis (FileInputStream. (.keystore vault))] (-> (doto (KeyStore/getInstance "JCEKS") (.load fis password)) (.getKey "vault-key" password))))) (deftype CryptoVault [filename keystore password] Vault (init-vault [vault] (let [password (.toCharArray (.password vault)) key (.generateKey (KeyGenerator/getInstance "AES")) keystore (doto (KeyStore/getInstance "JCEKS") (.load nil password) (.setEntry "vault-key" (KeyStore$SecretKeyEntry. key) (KeyStore$PasswordProtection. password)))] (with-open [fos (FileOutputStream. (.keystore vault))] (.store keystore fos password)))) (vault-output-stream [vault] (let [cipher (doto (Cipher/getInstance "AES") (.init Cipher/ENCRYPT_MODE (vault-key vault)))] (CipherOutputStream. (io/output-stream (.filename vault)) cipher))) (vault-input-stream [vault] (let [cipher (doto (Cipher/getInstance "AES") (.init Cipher/DECRYPT_MODE (vault-key vault)))] (CipherInputStream. (io/input-stream (.filename vault)) cipher))) proto/IOFactory (make-reader [vault] (proto/make-reader (vault-input-stream vault))) (make-writer [vault] (proto/make-writer (vault-output-stream vault)))) (extend CryptoVault clojure.java.io/IOFactory (assoc io/default-streams-impl :make-input-stream (fn [x opts] (vault-input-stream x)) :make-output-stream (fn [x opts] (vault-output-stream x))))

Rozdział 6. • Protokoły i typy danych



193

6.5. Rekordy Klasy w programach obiektowych zwykle należą do jednej z dwóch kategorii. Niektóre (na przykład klasy String, Socket, InputStream i OutputStream) reprezentują elementy programów, a inne (takie jak Employee lub PurchaseOrder) reprezentują informacje z dziedziny, której dotyczy aplikacja. Niestety, używanie klas do modelowania informacji z dziedziny powoduje ukrycie tych danych za charakterystycznymi dla klas procedurami ustawiającymi i pobierającymi informacje. Nie można stosować uniwersalnego podejścia do przetwarzania danych, co prowadzi do powstawania wielu niepotrzebnie wyspecjalizowanych jednostek i zmniejsza możliwość wielokrotnego wykorzystania kodu. Więcej informacji na ten temat znajdziesz w dokumentacji typów danych3 języka Clojure. Dlatego programistów języka Clojure zawsze zachęcano do umieszczania informacji w odwzorowaniach. Pojawienie się typów danych i rekordów tego nie zmieniło. Rekord jest typem danych (takim jak typy tworzone za pomocą makra deftype), a ponadto implementuje interfejs PersistentMap, dlatego rekordów można (zwykle) używać jak odwzorowań. Ponieważ rekordy są też klasami, obsługują oparty na typach polimorfizm, realizowany za pomocą protokołów. Rekordy pozwalają wykorzystać zalety z dwóch obszarów, ponieważ są odwzorowaniami, w których można implementować protokoły. Użyjmy rekordów do odtwarzania muzyki. Utwórzmy rekord przeznaczony do reprezentowania nut. Powinien obejmować pola na wysokość dźwięku, oktawę i czas trwania. Do odtwarzania sekwencji takich nut posłuży wbudowany w pakiet JDK syntezator MIDI. Ponieważ rekordy to odwzorowania, możliwa jest zmiana cech poszczególnych nut za pomocą funkcji assoc i upadate-in. Można też tworzyć i przekształcać całe sekwencje nut, używając funkcji map i reduce. Zapewnia to dostęp do całej kolekcji API języka Clojure. Utwórzmy rekord

deftype ).

Note

za pomocą makra

defrecord

(defrecord name [& fields] & opts+specs)

Rekord Note ma trzy pola: pitch, octave i duration. (defrecord Note [pitch octave duration]) -> user.Note 3

http://clojure.org/datatypes

(działa ono jak makro

194

Programowanie w języku Clojure



Wysokość dźwięku (pitch) jest reprezentowana przez słowa kluczowe w rodzaju :C, :C# i :Db (odpowiadają one nutom C, cis i des). Każdy dźwięk można zagrać w różnych oktawach. Na przykład środkowe C znajduje się w czwartej oktawie. Pole duration określa czas trwania dźwięku. Całą nutę reprezentuje liczba 1, półnutę — 1/2, ćwierćnutę — 1/4, a szesnastkę — 1/16. Półnutę D w czwartej oktawie można przedstawić za pomocą poniższego rekordu Note. (->Note :D# 4 1/2) -> #user.Note{:pitch :D#, :octave 4, :duration 1/2}

Rekordy można traktować jak inne typy danych. Dostęp do pól uzyskuje się za pomocą kropki. (.pitch (->Note :D# 4 1/2)) -> :D#

Jednak rekordy są też odwzorowaniami: (map? (->Note :D# 4 1/2)) -> true

dlatego dostęp do ich pól można uzyskać za pomocą słów kluczowych. (:pitch (->Note :D# 4 1/2)) -> :D#

Do tworzenia zmodyfikowanych rekordów służą funkcje assoc i update-in. (assoc (->Note :D# 4 1/2) :pitch :Db :duration 1/4) -> #user.Note{:pitch :Db, :octave 4, :duration 1/4} (update-in (->Note :D# 4 1/2) [:octave] inc) -> #user.Note{:pitch :D#, :octave 5, :duration 1/2}

Rekordy pozwalają na wprowadzanie zmian, dlatego można dodać nowe pola. (assoc (->Note :D# 4 1/2) :velocity 100) -> #user.Note{:pitch :D#, :octave 4, :duration 1/2, :velocity 100}

Opcjonalne pole dana nuta.

:velocity

posłuży do określania siły, z jaką grana ma być

Funkcje assoc i update-in stosowane do rekordu zwracają nowy rekord. Funkcja dissoc działa inaczej. Jeśli usuwane pole jest opcjonalne (tak jak velocity), ona też zwraca nowy rekord, jeżeli jednak zgodnie ze specyfikacją defrecord dane pole jest wymagane (tak jak pitch, octave lub duration), zwraca zwykłe odwzorowanie. Oznacza to, że po usunięciu wymaganego pola z rekordu danego typu rekord staje się odwzorowaniem. (dissoc (->Note :D# 4 1/2) :octave) -> {:pitch :D#, :duration 1/2}

Rozdział 6. • Protokoły i typy danych



195

Warto zauważyć, że funkcja dissoc zwraca odwzorowanie, a nie rekord. Jedna z różnic między rekordami a odwzorowaniami polega na tym, że rekordy nie są funkcjami od słów kluczowych. ((->Note. :D# 4 1/2) :pitch) -> user.Note cannot be cast to clojure.lang.IFn

Wyjątek ClassCastException jest zgłaszany, ponieważ rekordy — w odróżnieniu od odwzorowań — nie implementują interfejsu IFn. Jest to celowe rozwiązanie. Aby kod był bardziej czytelny, z rekordów i odwzorowań korzysta się w nieco inny sposób. Przy dostępie do kolekcji najpierw podaje się jej nazwę. Przy dostępie do odwzorowania, które działa jak rekord danych, należy najpierw umieścić słowo kluczowe — nawet jeśli rekord jest zaimplementowany jako zwykłe odwzorowanie. Po utworzeniu prostego rekordu Note dodajmy kilka metod, aby można było odtwarzać nuty za pomocą wbudowanego syntezatora MIDI z pakietu JDK. Zacznijmy od opracowania protokołu MidiNote z trzema metodami. src/examples/protocols.clj

(defprotocol MidiNote (to-msec [this tempo]) (key-number [this]) (play [this tempo midi-channel]))

Aby odtworzyć nutę w syntezatorze MIDI należy przekształcić wysokość dźwięku i oktawę na numer klawisza syntezatora MIDI, a pole określające długość dźwięku — na milisekundy. Poniżej znajdziesz opis metod to-msec, key-number i play z protokołu MidiNote.  Metoda to-msec zwraca czas trwania nuty w milisekundach.  Metoda key-number zwraca numer klawisza syntezatora MIDI odpowiadający danej nucie.  Metoda play odtwarza nutę w danym tempie dla określonego kanału. Rozszerzmy teraz rekord Note, aby zaimplementować protokół MidiNote. (import 'javax.sound.midi.MidiSystem) (extend-type Note MidiNote (to-msec [this tempo] (let [duration-to-bpm {1 240, 1/2 120, 1/4 60, 1/8 30, 1/16 15}] (* 1000 (/ (duration-to-bpm (:duration this)) tempo))))

Funkcja to-msec określa długość dźwięku w milisekundach na podstawie rodzaju nuty (cała nuta, półnuta, ćwierćnuta i tak dalej) oraz tempa reprezentowanego w uderzeniach na minutę.

196

Programowanie w języku Clojure



(key-number [this] (let [scale {:C 0, :C# 1, :Db 1, :D 2, :D# 3, :Eb 3, :E 4, :F 5, :F# 6, :Gb 6, :G 7, :G# 8, :Ab 8, :A 9, :A# 10, :Bb 10, :B 11}] (+ (* 12 (inc (:octave this))) (scale (:pitch this)))))

Funkcja key-number odwzorowuje słowa kluczowe reprezentujące wysokość na liczby z przedziału od 0 do 11, a następnie używa otrzymanej liczby i oktawy do ustalenia odpowiedniej wartości key-number dla syntezatora MIDI4. (play [this tempo midi-channel] (let [velocity (or (:velocity this) 64)] (.noteOn midi-channel (key-number this) velocity) (Thread/sleep (to-msec this tempo)))))

Metoda play przyjmuje nutę, tempo i kanał MIDI, wysyła komunikat noteOn do kanału, a następnie usypia na czas trwania dźwięku. Dźwięk jest odtwarzany także po uśpieniu wątku i zostaje przerwany po przesłaniu do kanału następnej nuty. Potrzebna jest też funkcja do konfigurowania syntezatora MIDI i odtwarzania sekwencji nut. (defn perform [notes & {:keys [tempo] :or {tempo 120}}] (with-open [synth (doto (MidiSystem/getSynthesizer) .open)] (let [channel (aget (.getChannels synth) 0)] (doseq [note notes] (play note tempo channel)))))

Funkcja perform przyjmuje sekwencję nut i opcjonalną wartość wyznaczającą tempo, uruchamia syntezator MIDI, pobiera kanał, a następnie wywołuje metodę play dla każdej nuty. Wszystkie elementy są już gotowe. Przygotujmy utwór muzyczny w postaci sekwencji rekordów Note. (def close-encounters [(->Note (->Note (->Note (->Note (->Note -> #'user/close-encounters

:D :E :C :C :G

3 3 3 2 2

1/2) 1/2) 1/2) 1/2) 1/2)])

Tu „muzyka” składa się z pięciu nut używanych do witania statków kosmitów w filmie Bliskie spotkania trzeciego stopnia. Aby odtworzyć ten fragment, wystarczy przekazać sekwencję do funkcji perform. (perform close-encounters) -> nil 4

Warto zauważyć, że z liczbami 1, 3, 6, 8 i 10 powiązane są dwie wartości pola pitch.

Rozdział 6. • Protokoły i typy danych



197

Można też dynamicznie generować sekwencje nut, wykorzystując makro for. (def jaws (for [duration [1/2 1/2 1/4 1/4 1/8 1/8 1/8 1/8] pitch [:E :F]] (Note. pitch 2 duration))) -> #'user/jaws (perform jaws) -> nil

Tym razem efekt to motyw spotkań z rekinem z filmu Szczęki. Motyw składa się z sekwencji dźwięków E i F, które stają się coraz szybsze — od półnut przez ćwierćnuty po ósemki. Ponieważ nuty zapisywane są w rekordach, a rekordy działają jak odwzorowania, nutami można manipulować za pomocą dowolnych funkcji języka Clojure działających dla odwzorowań. Można na przykład odwzorować funkcję update-in na sekwencję close-encounters, aby podnieść lub obniżyć oktawę. (perform (map #(update-in % [:octave] inc) close-encounters)) -> nil (perform (map #(update-in % [:octave] dec) close-encounters)) -> nil

Można też utworzyć sekwencję nut o coraz większej wartości opcjonalnego pola :velocity. (perform (for [velocity [64 80 90 100 110 120]] (assoc (Note. :D 3 1/2) :velocity velocity))) -> nil

W ten sposób powstaje sekwencja coraz mocniejszych dźwięków D. Manipulowanie sekwencjami to wyjątkowo mocna strona języka Clojure, dlatego istnieją nieskończone możliwości programowego tworzenia sekwencji rekordów Note i manipulowania nimi. Umieśćmy protokół MidiNote, rekord Note i funkcję perform w pliku źródłowym języka Clojure, src/examples/midi.clj. Pozwoli to wykorzystać ten kod w przyszłości. src/examples/midi.clj

(ns examples.datatypes.midi (:import [javax.sound.midi MidiSystem])) (defprotocol MidiNote (to-msec [this tempo]) (key-number [this]) (play [this tempo midi-channel])) (defn perform [notes & {:keys [tempo] :or {tempo 88}}] (with-open [synth (doto (MidiSystem/getSynthesizer).open)] (let [channel (aget (.getChannels synth) 0)]

198



Programowanie w języku Clojure

(doseq [note notes] (play note tempo channel))))) (defrecord Note [pitch octave duration] MidiNote (to-msec [this tempo] (let [duration-to-bpm {1 240, 1/2 120, 1/4 60, 1/8 30, 1/16 15}] (* 1000 (/ (duration-to-bpm (:duration this)) tempo)))) (key-number [this] (let [scale {:C 0, :C# 1, :Db 1, :D 2, :D# 3, :Eb 3, :E 4, :F 5, :F# 6, :Gb 6, :G 7, :G# 8, :Ab 8, :A 9, :A# 10, :Bb 10, :B 11}] (+ (* 12 (inc (:octave this))) (scale (:pitch this))))) (play [this tempo midi-channel] (let [velocity (or (:velocity this) 64)] (.noteOn midi-channel (key-number this) velocity) (Thread/sleep (to-msec this tempo)))))

6.6. Makro reify Makro reify pozwala tworzyć anonimowe egzemplarze typu danych z implementacją protokołu lub interfejsu. Warto zauważyć, że dostęp do egzemplarza uzyskuje się poprzez domknięcie, a nie w formie deklaracji. Wynika to z tego, że egzemplarz nie ma zadeklarowanych składowych. (reify & opts+specs)

Makro reify, podobnie jak deftype i defrecord, przyjmuje nazwy protokołów lub interfejsów oraz zestaw ciał metod. W odróżnieniu od makr deftype i defrecord nie przyjmuje nazwy ani wektora pól. Egzemplarze typu danych utworzone za pomocą makra reify nie mają jawnie określonych pól. Zamiast tego stosuje się domknięcia. Skomponujmy utwór aleatoryczny5 w stylu Johna Cage’a6 — a może nawet zbudujmy generator takich utworów. Za pomocą makra reify można utworzyć egzemplarz typu MidiNote odtwarzający losową nutę przy każdym wywołaniu metody play.

5 6

http://pl.wikipedia.org/wiki/Aleatoryzm http://pl.wikipedia.org/wiki/John_Cage

Rozdział 6. • Protokoły i typy danych



199

src/examples/generator.clj

(import '[examples.datatypes.midi MidiNote]) (let [min-duration 250 min-velocity 64 rand-note (reify MidiNote (to-msec [this tempo] (+ (rand-int 1000) min-duration)) (key-number [this] (rand-int 100)) (play [this tempo midi-channel] (let [velocity (+ (rand-int 100) min-velocity)] (.noteOn midi-channel (key-number this) velocity) (Thread/sleep (to-msec this tempo)))))] (perform (repeat 15 rand-note)))

Najpierw należy za pomocą instrukcji import (nie use ani require) dołączyć protokół MidiNote z przestrzeni nazw examples.midi. Następnie kod wiąże dwie wartości, min-duration i min-velocity, używane w implementacjach metod protokołu MidiNote. Następnie za pomocą metody reify należy utworzyć egzemplarz typu anonimowego z implementacją protokołu MidiNote. Typ ten przy każdym wywołaniu metody play losowo określa nutę, czas trwania dźwięku i jego siłę. Ostatni wiersz tworzy za pomocą funkcji repeat sekwencję 15 nut (egzemplarzy typu rand-note) i odtwarza je. Gotowe — zostałeś wirtualnym Johnem Cage’em!

6.7. Podsumowanie W tym rozdziale omówiliśmy wiele zagadnień — od ogólnego stosowania abstrakcji w programowaniu po wybrane (choć nie wszystkie) związane z abstrakcją mechanizmy dostępne w języku Clojure. Opisaliśmy tworzenie konkretnych abstrakcji za pomocą protokołów i przy okazji trochę się pobawiliśmy. To jednak nie wszystko. Makra w języku Clojure są czytelne i łatwe w użyciu przy wykonywaniu standardowych operacji, a przy tym dają na tyle duże możliwości, że można je stosować w pracy nad trudniejszymi zadaniami. W następnym rozdziale zobaczysz, jak w języku Clojure korzystać z makr w codziennym programowaniu.

200



Programowanie w języku Clojure

Rozdział 7.

Makra

M

akra zapewniają użytkownikom języka Clojure wiele korzyści. Większość technik programowania polega na tworzeniu mechanizmów w ramach języka, natomiast pisanie makr to raczej dodawanie cech do języka. Daje to duże możliwości, ale czasem jest niebezpieczne, dlatego należy przestrzegać zasad opisanych w podrozdziale 7.1, „Kiedy należy stosować makra?” — przynajmniej do czasu zdobycia doświadczenia, które pozwoli samodzielnie stwierdzić, kiedy warto nagiąć reguły. W podrozdziale 7.2, „Makro do sterowania przebiegiem programu”, zaczniesz gromadzić potrzebne doświadczenie, dodając nowy mechanizm do języka Clojure. Makra, choć potężne, nie zawsze są proste. Twórcy języka Clojure starali się je jednak maksymalnie uprościć przez dodanie udogodnień, które pozwalają rozwiązać wiele standardowych problemów powstających w czasie pisania makr. W podrozdziale 7.3, „Upraszczanie makr”, wyjaśniamy te problemy i pokazujemy, w jaki sposób złagodzono ich skutki w języku Clojure.

Makra tak bardzo różnią się od innych idiomów programowania, że możesz nie wiedzieć, kiedy korzystać z tego mechanizmu. Nic nie pomoże w ustaleniu tego lepiej niż grupowe doświadczenie społeczności. W podrozdziale 7.4, „Taksonomia makr”, poznasz taksonomię makr języka Clojure opartą na makrach języka Clojure i makrach z dodanych bibliotek.

202



Programowanie w języku Clojure

7.1. Kiedy należy stosować makra? Klub użytkowników makr ma dwie reguły i jeden wyjątek. Pierwsza reguła klubu to: „nie pisać makr”. Makra są skomplikowane i wymagają starannego przemyślenia zależności między czasem rozwijania makra a czasem kompilacji. Jeśli możesz napisać kod w formie funkcji, dobrze się zastanów, zanim zastosujesz makro. Druga reguła brzmi: „pisz makra, jeśli jest to jedyny sposób na hermetyzację wzorca”. Wszystkie języki programowania udostępniają sposoby na hermetyzację wzorców, jednak bez makr mechanizmy te są niekompletne. W większości języków poczucie niekompletności pojawia się w momencie stwierdzenia, że praca byłaby prostsza, gdyby tylko język miał cechę X. W języku Clojure dowolną cechę X można zaimplementować za pomocą makra. Wyjątkiem od tych reguł jest to, że można napisać makro, jeśli stosowanie funkcji byłoby trudniejsze dla użytkowników kodu. Jednak aby zrozumieć, kiedy wyjątek ten znajduje zastosowanie, trzeba mieć doświadczenie w pisaniu makr i umieć porównać je z funkcjami. Zacznijmy więc od przykładu.

7.2. Makro do sterowania przebiegiem programu Język Clojure udostępnia specjalną konstrukcję if. (if (= 1 1) (println "Tak, jeden nadal równa się jeden")) | Tak, jeden nadal równa się jeden

Niektóre języki obejmują instrukcję unless, która jest (niemal) przeciwieństwem if. Instrukcja unless sprawdza wartość wyrażenia, a następnie wykonuje kod, jeśli ta wartość to false. Język Clojure nie udostępnia instrukcji unless, obejmuje jednak analogiczne makro when-not. Aby móc zacząć od prostego przykładu, przyjmijmy, że makro when-not nie istnieje, i utwórzmy implementację instrukcji unless. W celu dostosowania się do reguł klubu użytkowników makr najpierw spróbujmy napisać unless jako funkcję. src/examples/macros.clj

; Błędna próba. (defn unless [expr form] (if expr nil form))

Rozdział 7. • Makra

Sprawdźmy, czy funkcja wartość false.

 unless

działa poprawnie, jeśli wyrażenie

expr

203

ma

(unless false (println "To powinno się pojawić")) | To powinno się pojawić

Na razie funkcja działa prawidłowo. Bądźmy jednak staranni i sprawdźmy także wartość true. (unless true (println "To nie powinno się pojawić")) | To nie powinno się pojawić

Najwyraźniej coś jest nie tak. Problem polega na tym, że Clojure wartościuje wszystkie argumenty przed przekazaniem ich do funkcji, dlatego instrukcja println jest wywoływana, zanim trafi do funkcji unless. Oba przedstawione wywołania tej funkcji uruchamiają instrukcję println zbyt wcześnie, przed wejściem do funkcji unless. Aby się o tym przekonać, umieśćmy w funkcji unless nową instrukcję println. (defn unless [expr form] (println "Przed sprawdzaniem wartości...") (if expr nil form))

Teraz można naocznie się przekonać, że argumenty zawsze są wartościowane przed przekazaniem ich do funkcji unless. (unless false (println "To powinno się pojawić")) | To powinno się pojawić | Przed sprawdzaniem wartości... (unless true (println "To nie powinno się pojawić")) | To nie powinno się pojawić | Przed sprawdzaniem wartości...

Makra rozwiązują ten problem, ponieważ nie wartościują argumentów od razu. Programista może określić, kiedy (i czy w ogóle!) argumenty makra mają być wartościowane. Kiedy Clojure natrafia na makro, przetwarza je w dwóch krokach. Najpierw rozwija (wykonuje) makro i podstawia wynik tej operacji do programu. Jest to czas rozwijania makra. Później następuje standardowy czas kompilacji. Aby utworzyć makro unless, trzeba napisać w języku Clojure kod wykonujący w czasie rozwijania makra poniższe przekształcenie. (unless expr form) -> (if expr nil form)

Następnie trzeba poinformować Clojure, że kod to makro. Służy do tego makro defmacro (jest ono bardzo podobne do makra defn). (defmacro name doc-string? attr-map? [params*] body)

204



Programowanie w języku Clojure

Ponieważ kod w języku Clojure to dane, mamy wszystkie narzędzia potrzebne do napisania makra unless. Napiszmy to makro, używając typu list do utworzenia wyrażenia if. (defmacro unless [expr form] (list 'if expr nil form))

Ciało makra unless jest wykonywane w czasie rozwijania makra, co powoduje utworzenie konstrukcji if używanej w czasie kompilacji. Wprowadzenie w środowisku REPL wyrażenia: (unless false (println "To powinno się pojawić"))

prowadzi do (niezauważalnego dla programisty) rozwinięcia przez Clojure struktury unless do następującej postaci: (if false nil (println "To powinno się pojawić"))

Następnie Clojure kompiluje i wykonuje rozwiniętą postać instrukcji if. Sprawdźmy, czy makro unless działa poprawnie dla wartości true i false. (unless false (println "To powinno się pojawić")) | To powinno się pojawić -> nil (unless true (println "To nie powinno się pojawić")) -> nil

Gratulacje, właśnie napisałeś swoje pierwsze makro. Makro unless może wydawać się dość proste, jednak pamiętaj, że zrobiliśmy tu coś, co w większości języków programowania jest niemożliwe. W językach pozbawionych makr uniemożliwiają to specjalne konstrukcje.

Specjalne konstrukcje, wzorce projektowe i makra Język Clojure nie ma specjalnej składni. Kod składa się ze struktur danych. Dotyczy to zarówno zwykłych funkcji, jak i specjalnych konstrukcji oraz makr. Przyjrzyjmy się językowi z większym zróżnicowaniem składniowym, na przykład Javie1. W Javie największą swobodę w pisaniu kodu dają metody egzemplarza. Wyobraź sobie, że piszesz program w Javie. Po odkryciu powtarzającego się wzorca w metodach egzemplarza możesz wykorzystać cały język, aby zhermetyzować dany wzorzec.

1

Nie zamierzamy wytykać tu wad konkretnie tego języka. Łatwiej jednak opisać zagadnienie na przykładzie konkretnego języka, a Java jest powszechnie znana.

Rozdział 7. • Makra



205

Póki co wygląda to atrakcyjnie. Jednak Java obejmuje też wiele specjalnych konstrukcji (choć zwykle nie nazywa się ich w ten sposób). W odróżnieniu od specjalnych konstrukcji z Clojure, które są zwykłymi danymi tego języka, każda specjalna konstrukcja w Javie ma określoną składnię. Jedną z takich konstrukcji jest if. Po odkryciu powtarzającego się wzorca obejmującego if nie ma możliwości zhermetyzowania go. Nie można utworzyć makra unless, dlatego trzeba zasymulować jego działania za pomocą standardowego wykorzystania if. if (!something) ...

Na pozór jest to stosunkowo mało istotny problem. Programiści Javy mogą nauczyć się przekształcać „w głowie” konstrukcję if (!foo) na unless (foo). Jednak problem dotyczy nie tylko if. Każda konstrukcja składniowa języka utrudnia hermetyzację powtarzających się wzorców obejmujących tę konstrukcję. Oto następny przykład. Java zawiera też specjalną konstrukcję new. Nie można zastosować dla niej polimorfizmu, dlatego trzeba go zasymulować, na przykład za pomocą standardowego zastosowania metody klasy. Widget w = WidgetFactory.makeWidget(...)

Idiom ten jest dość rozbudowany i wymaga zastosowania nowej klasy, WidgetFactory. Klasa ta nie ma znaczenia w kontekście dziedziny problemu i używamy jej tylko po to, aby poradzić sobie ze specjalną konstrukcją w postaci konstruktora. Idiom polimorficznego tworzenia egzemplarzy, w odróżnieniu od idiomu unless, jest na tyle skomplikowany, że można go zaimplementować na kilka sposobów. Dlatego trafniejsze jest nazwanie go wzorcem projektowym. Według Wikipedii wzorzec projektowy2 to „uniwersalne, sprawdzone w praktyce rozwiązanie często pojawiających się, powtarzalnych problemów projektowych”. W haśle z angielskiej wersji Wikipedii pojawia się stwierdzenie, że „wzorzec projektowy nie jest gotowym projektem, który można bezpośrednio (wyróżnienie własne) przekształcić na kod”. W takiej sytuacji przydają się makra. Zapewniają one warstwę pośrednią, która pozwala automatycznie wykonywać standardowe fragmenty powtarzającego się wzorca. Makra i kod w postaci danych wspólnie umożliwiają szybkie modyfikowanie języka w celu hermetyzacji wzorców. Oczywiście nie każdy tak to postrzega. Zdaniem wielu osób dostępność licznych specjalnych konstrukcji składniowych ułatwia naukę języka programowania i czytanie kodu. Nie zgadzamy się z tym, a nawet gdybyśmy przyznali rację 2

http://pl.wikipedia.org/wiki/Wzorzec_projektowy_(informatyka)

206



Programowanie w języku Clojure

takim osobom, chętnie wymienilibyśmy zróżnicowanie składniowe na potężny system makr. Kiedy już przyzwyczaisz się do używania kodu w postaci danych, możliwość automatyzacji wzorców projektowych daje olbrzymie korzyści.

Rozwijanie makr W makro unless symbol if poprzedzony jest apostrofem. (defmacro unless [expr form] (list 'if expr nil form))

Jednak pozostałe symbole podane są w normalnej postaci. Aby zrozumieć, z czego to wynika, należy zastanowić się nad tym, co się dzieje w czasie rozwijania makr.  Poprzedzenie instrukcji apostrofem ( 'if ) sprawia, że Clojure nie wartościuje jej w trakcie rozwijania makra. Wartościowanie polega tu na usunięciu apostrofu, przez co kompilowany jest symbol if.  Nazw expr i form nie należy poprzedzać apostrofem, ponieważ są to argumenty makra. W czasie rozwijania makra język Clojure zastępuje je bez wartościowania.  Nie trzeba też poprzedzać apostrofem słowa nil, ponieważ jego wartość to właśnie nil. Ustalenie tego, co należy poprzedzać apostrofem, bywa skomplikowane. Na szczęście nie trzeba robić tego „w głowie”. Clojure udostępnia funkcje diagnostyczne, które pozwalają sprawdzić rozwijanie makra w środowisku REPL. Funkcja macroexpand-1 pokazuje, co się dzieje w czasie rozwijania makra. (macroexpand-1 form)

Za pomocą funkcji macroexpand-1 można udowodnić, że funkcja unless jest rozwijana do poprawnego wyrażenia if. (macroexpand-1 '(unless false (println "To powinno się pojawić"))) -> (if false nil (println "To powinno się pojawić"))

Makra są skomplikowane, dlatego sprawdzanie ich za pomocą funkcji macroexpand-1 jest bardzo ważne. Wypróbujmy teraz nieprawidłową wersję makra unless. W tej wersji niepotrzebnie poprzedzono apostrofem słowo expr. (defmacro bad-unless [expr form] (list 'if 'expr nil form))

Rozdział 7. • Makra



Po rozwinięciu makra bad-unless okazuje się, że generuje ono symbol zamiast sprawdzanego wyrażenia.

207 expr

(macroexpand-1 '(bad-unless false (println "To powinno się pojawić"))) -> (if expr nil (println "To powinno się pojawić"))

W reakcji na próbę zastosowania makra może określić wartości symbolu expr.

bad-unless

Clojure informuje, że nie

(bad-unless false (println "To powinno się pojawić")) -> java.lang.Exception: Unable to resolve symbol: expr in this context

Czasem makra rozwijane są do innych makr. Wtedy Clojure rozwija wszystkie makra do czasu, kiedy pozostaje sam kod. Na przykład makro .. jest rozwijane rekurencyjnie. Generuje wywołanie z operatorem kropki umieszczone w następnym makro .., służącym do obsługi pozostałych argumentów. Można się o tym przekonać przez rozwinięcie makra. (macroexpand-1 '(.. arm getHand getFinger)) -> (clojure.core/.. (. arm getHand) getFinger)

Aby rozwinąć makro .. do końca, należy użyć instrukcji macroexpand. (macroexpand form)

Zastosowanie instrukcji macroexpand do makra .. prowadzi do rekurencyjnego rozwijania makra do momentu, w którym pozostają same operatory kropki. (macroexpand '(.. arm getHand getFinger)) -> (. (. arm getHand) getFinger)

To, że funkcje arm, getHand i getFinger nie istnieją, nie stanowi problemu. Tutaj jedynie je rozwijamy — nie próbujemy ich skompilować ani uruchomić. Inne rekurencyjne makro to and. Wywołanie makra and dla więcej niż dwóch argumentów powoduje rozwinięcie go do następnego wywołania and z jednym mniej argumentem. (macroexpand '(and 1 2 3)) -> (let* [and__3585__auto__ 1] (if and__3585__auto__ (clojure.core/and 2 3) and__3585__auto__))

Tym razem funkcja macroexpand nie rozwija makra do końca. Działa tylko dla najwyższego poziomu przekazanej konstrukcji. Ponieważ rozwinięcie makra and prowadzi do uzyskania nowego and zagnieżdżonego w konstrukcji, macroexpand nie kontynuuje rozwijania.

208

Programowanie w języku Clojure



Makra when i when-not Makro unless można nieco ulepszyć, aby zamiast zgłaszać poniższy błąd, obsługiwało kilka konstrukcji. (unless false (println "To") (println "i jeszcze to")) -> java.lang.IllegalArgumentException: \ Wrong number of args passed to: macros$unless

Zastanów się nad usprawnioną wersją makra unless. Trzeba uwzględnić listę o zmiennej liczbie argumentów i dodać przed nią słowo do, aby wykonane zostały wszystkie konstrukcje. W języku Clojure uzyskanie tego efektu umożliwiają makra when i when-not. (when test & body) (when-not test & body)

Makro when-not to ulepszona wersja makra unless, na której nam zależy. (when-not false (println "To") (println "i jeszcze to")) | To | i jeszcze to -> nil

Ponieważ napisałeś już makro kod źródłowy makra when-not.

unless,

powinieneś bez problemów zrozumieć

; Z rdzenia języka Clojure. (defmacro when-not [test & body] (list 'if test nil (cons 'do body)))

Oczywiście można użyć funkcji when-not.

macroexpand-1,

aby zobaczyć, jak działa makro

(macroexpand-1 '(when-not false (print "1") (print "2"))) -> (if false nil (do (print "1") (print "2")))

Makro when jest przeciwieństwem makra when-not i wykonuje podane konstrukcje tylko wtedy, jeśli wyrażenie test ma wartość true. Warto zwrócić uwagę na dwie różnice między konstrukcjami when i if.  Konstrukcja if pozwala zastosować klauzulę else, natomiast when tego nie umożliwia. Odpowiada to znaczeniu tych słów w języku angielskim (nikt nie powie „when…else”, czyli „kiedy…w przeciwnym razie”).  Ponieważ w makro when drugi argument nie jest klauzulą else, można podać listę o zmiennej liczbie argumentów i wykonać wszystkie argumenty w instrukcji do. Makro unless nie jest potrzebne — wystarczy używać makra when-not języka Clojure. Zawsze sprawdzaj, czy ktoś nie napisał już potrzebnego Ci makra.

Rozdział 7. • Makra



209

7.3. Upraszczanie makr Makro unless jest znakomite jako prosty przykład, jednak większość makr jest bardziej skomplikowana. W tym podrozdziale tworzymy zestaw coraz bardziej złożonych makr. Przedstawiamy przy tym różne cechy języka Clojure. Cechy te opisujemy w poniższej tabeli. Konstrukcja

Opis

foo#

Automatyczne generowanie symboli. Tworzy niepowtarzalną nazwę z przedrostkiem foo w sekcji z dosłownie podawaną składnią.

(gensym prefix?)

Tworzy niepowtarzalną nazwę (z opcjonalnym przedrostkiem).

(macroexpand form)

Rozwija makro form za pomocą funkcji macroexpand-1 do czasu, kiedy zwrócona konstrukcja nie jest makrem.

(macroexpand-1 form)

Pokazuje, w jaki sposób Clojure rozwija makro form.

(list-frag? ~@form list-frag?)

Operacja splicing unquote. Pozwala w dosłownie podanej składni wykorzystać listę jako szablon.

`form

Dosłowne podawanie składni. Powoduje dosłowne podanie konstrukcji, ale umożliwia zastosowanie operacji unquote do wewnętrznych elementów, dlatego form może działać jak szablon. Symbole z konstrukcji form są określane, co pomaga zapobiec przypadkowemu przechwyceniu symbolu.

~form

Operacja unquote. Z tej konstrukcji można korzystać przy dosłownym podawaniu składni, aby użyć wartości symbolu, dla którego zastosowano operację unquote.

Utwórzmy najpierw odpowiednik makra .. z języka Clojure. Nazwijmy nowe makro chain (czyli łańcuch), ponieważ powoduje wywołanie łańcucha metod. Oto przykładowe rozwinięcia makra chain. Wywołanie makra

Rozwinięcie

(chain arm getHand)

(. arm getHand)

(chain arm getHand getFinger)

(. (. arm getHand) getFinger)

Zacznijmy od zaimplementowania prostej sytuacji, w której makro chain wywołuje tylko jedną metodę. Makro musi tu tylko utworzyć prostą listę. src/examples/macros/chain_1.clj

; Makro chain to odpowiednik makra .. języka Clojure. (defmacro chain [x form] (list '. x form))

210

Programowanie w języku Clojure



Makro chain musi obsługiwać dowolną liczbę argumentów, dlatego w dalszej części implementacji trzeba zastosować rekurencję. Manipulowanie listą staje się na tym etapie trudniejsze, ponieważ trzeba utworzyć dwie listy i połączyć je za pomocą instrukcji concat. src/examples/macros/chain_2.clj

(defmacro chain ([x form] (list '. x form)) ([x form & more] (concat (list 'chain (list '. x form)) more)))

Sprawdźmy makro chain za pomocą funkcji prawidłowo rozwija wywołania.

macroexpand,

aby się upewnić, że

(macroexpand '(chain arm getHand)) -> (. arm getHand) (macroexpand '(chain arm getHand getFinger)) -> (. (. arm getHand) getFinger)

Makro chain w obecnej postaci działa poprawnie, ale trudno jest odczytać wyrażenie obsługujące więcej niż jeden argument. (concat (list 'chain (list '. x form)) more)))

W definicji makra chain występuje i kod makra, i ciało generowanego kodu. Łączenie obu tych elementów zmniejsza czytelność rozwiązania. A przedstawiona tu konstrukcja jest bardzo prosta — ma tylko jeden wiersz. W bardziej skomplikowanych makrach funkcje przeznaczone do łączenia elementów, na przykład list i concat, mogą utrudniać zrozumienie makra. Jednym z rozwiązań tego problemu jest język szablonów. Gdyby makra były tworzone na podstawie szablonów, można by zastosować technikę „uzupełnij puste miejsca”. Definicja makra chain mogłaby wtedy wyglądać tak: ; Hipotetyczny język szablonów. (defmacro chain ([x form] (. ${x} ${form})) ([x form & more] (chain (. ${x} ${form}) ${more})))

W tym hipotetycznym języku szablonów człon podstawiane przy rozwijaniu makra.

${}

pozwala podać argumenty

Warto zauważyć, że ta definicja jest znacznie czytelniejsza i jednoznacznie określa, jak wygląda rozwinięcie makra.

Rozdział 7. • Makra



211

Dosłowne podawanie składni oraz operacje unquote i splicing unquote Makra języka Clojure umożliwiają obsługę szablonów bez stosowania odrębnego języka. Symbol operacji dosłownego podawania składni (`) działa niemal tak jak standardowy apostrof. Jednak na liście poprzedzonej symbolem ` znak operacji unquote (~) powoduje ponowne uruchomienie przetwarzania symboli. W efekcie powstają szablony podobne do poniższego. src/examples/macros/chain_3.clj

(defmacro chain [x form] `(. ~x ~form))

Sprawdźmy, czy nowa wersja makra wywołanie jednej metody.

chain

potrafi poprawnie wygenerować

(macroexpand '(chain arm getHand)) -> (. arm getHand)

Niestety, dla wieloargumentowej wersji makra chain podejście oparte na dosłownym podawaniu składni i operacji unquote się nie sprawdza. src/examples/macros/chain_4.clj

; Nie działa poprawnie. (defmacro chain ([x form] `(. ~x ~form)) ([x form & more] `(chain (. ~x ~form) ~more)))

Po rozwinięciu makra chain okazuje się, że występuje problem z nawiasami. (macroexpand '(chain arm getHand getFinger)) -> (. (. arm getHand) (getFinger))

Ostatni argument makra chain to lista argumentów more. Po umieszczeniu listy more w szablonie generowane są dla niej nawiasy (jak dla każdej listy). Są one jednak niepożądane. Chcemy, aby zawartość listy more została podzielona (ang. splice). Sytuacja ta powtarza się na tyle często, że istnieje odpowiednie makro odczytu — ~@ (operacja splicing unquote). Zmodyfikujmy makro chain za pomocą operacji splicing unquote, aby podzielić listę more. src/examples/macros/chain_5.clj

(defmacro chain ([x form] `(. ~x ~form)) ([x form & more] `(chain (. ~x ~form) ~@more)))

Teraz rozwijanie powinno przebiegać prawidłowo. (macroexpand '(chain arm getHand getFinger)) -> (. (. arm getHand) getFinger)

212



Programowanie w języku Clojure

Wiele makr jest zgodnych ze wzorcem zastosowanym dla makra chain (odpowiednika makra .. z języka Clojure). 1. Ciało makra rozpoczyna się od symbolu operacji dosłownego podawania składni (`), co sprawia, że cały kod traktowany jest jak szablon. 2. Dalej poszczególne elementy są poprzedzone symbolem operacji unquote ( ~). 3. Argumenty z listy more są dzielone za pomocą operacji splicing unquote ( ~@ ). Makra tworzone do tej pory były na tyle proste, że nie musieliśmy stosować wiązań (instrukcji let lub binding). Utwórzmy teraz bardziej złożone makro.

Tworzenie nazw w makrach Język Clojure udostępnia makro time, które mierzy czas wykonania wyrażenia i wyświetla ten czas w konsoli. (time (str "a" "b")) | "Elapsed time: 0.06 msecs" -> "ab"

Utwórzmy odmianę makra time. Nazwijmy ją bench. Wersja ta ma rejestrować dane dla wielu uruchomień kodu. Zamiast wyświetlać je w konsoli, ma zwracać odwzorowanie ze zwróconą wartością wyrażenia i czasem jego obliczania. Najlepszy sposób na rozpoczęcie tworzenia makra to ręczne napisanie rozwiniętej postaci. Makro bench po rozwinięciu powinno wyglądać tak: ; Instrukcja (bench (str "a" "b")) ; powinna być rozwijana do: (let [start (System/nanoTime) result (str "a" "b")] {:result result :elapsed (- (System/nanoTime) start)}) -> {:elapsed 61000, :result "ab"}

Instrukcja let wiąże nazwę start z początkowym czasem, a następnie wykonuje mierzone wyrażenie i wiąże jego wartość z nazwą result. W ostatnim kroku konstrukcja zwraca odwzorowanie z wartością result i czasem, który upłynął od momentu start. Po utworzeniu rozwinięcia można się cofnąć i napisać makro do jego generowania. Za pomocą techniki z poprzedniego podrozdziału spróbujmy napisać makro bench z wykorzystaniem dosłownego podawania składni i operacji unquote.

Rozdział 7. • Makra



213

src/examples/macros/bench_1.clj

; Ta wersja nie działa. (defmacro bench [expr] `(let [start (System/nanoTime) result ~expr] {:result result :elapsed (- (System/nanoTime) start)}))

Próba wywołania tej wersji makra Clojure.

bench

spowoduje zgłoszenie wyjątku przez

(bench (str "a" "b")) -> java.lang.Exception: Can't let qualified name: examples.macros/start

Clojure informuje tu, że w instrukcji let nie można stosować pełnych nazw. Problem można potwierdzić przez wywołanie funkcji macroexpand-1. (macroexpand-1 '(bench (str "a" "b"))) -> (clojure.core/let [examples.macros/start (System/nanoTime) examples.macros/result (str "a" "b")] {:elapsed (clojure.core/- (System/nanoTime) examples.macros/start) :result examples.macros/result})

Kiedy w konstrukcji dosłownego podawania składni pojawia się symbol, jest on określany w pełnej postaci. Tu wydaje się to irytujące, ponieważ chcemy utworzyć nazwy lokalne, a konkretnie start i result. Jednak podejście stosowane w języku Clojure chroni programistów przed poważnym błędem w makrach, przechwytywaniem symboli (ang. symbol capture). Załóżmy, że przy rozwijaniu makr można stosować niekwalifikowane symbole wtedy stanie, jeśli programista wywoła makro bench w miejscu, w którym nazwy te są już powiązane z innymi wartościami? Makro przechwyci nazwy i powiąże je z nowymi wartościami, co może prowadzić do nieoczekiwanych wyników. Jeśli makro bench przechwyci symbole, kod będzie na pozór wyglądał poprawnie. Dodanie jeden do dwa da trzy.

start i result. Co się

(let [a 1 b 2] (bench (+ a b))) -> {:result 3, :elapsed 39000}

Przyjmijmy teraz, że pewnego pechowego dnia programista zastosował nazwę lokalną w rodzaju start, która powoduje konflikt z nazwą z makra bench. (let [start 1 end 2] (bench (+ start end))) -> {:result 1228277342451783002, :elapsed 39000}

Makro bench przechwytuje wtedy symbol start i wiąże go z Nagle jeden plus dwa równa się 1228277342451783002.

(System/nanoTime).

214



Programowanie w języku Clojure

Wymuszanie w języku Clojure określania nazw w makrach chroni przed przechwytywaniem symboli, jednak makro bench nadal nie działa. Potrzebny jest sposób na wprowadzenie nazw lokalnych, najlepiej niepowtarzalnych, które nie powodują konfliktów z żadnymi nazwami używanymi przez jednostkę wywołującą. Język Clojure udostępnia konstrukcję odczytu przeznaczoną do tworzenia niepowtarzalnych nazw lokalnych. W konstrukcji dosłownego podawania składni można dodać znak kratki (#) do niekwalifikowanej nazwy, a Clojure automatycznie wygeneruje wtedy symbol, używając nazwy, podkreślenia i niepowtarzalnego identyfikatora. Wypróbujmy tę technikę w środowisku REPL. `foo# foo__1004

Z wykorzystaniem automatycznie generowanych symboli można łatwo napisać prawidłową implementację makra bench. (defmacro bench [expr] `(let [start# (System/nanoTime) result# ~expr] {:result result# :elapsed (- (System/nanoTime) start#)}))

Wypróbujmy to makro w środowisku REPL. (bench (str "a" "b")) -> {:elapsed 63000, :result "ab"}

Clojure umożliwia łatwe generowanie niepowtarzalnych nazw, jednak w razie potrzeby można wymusić przechwytywanie symboli. W przykładowym kodzie z tej książki znajduje się makro evil-bench, w którym zastosowano dosłowne podawanie składni, apostrofy i operację unquote w sposób prowadzący do przechwytywania symboli. Nie należy stosować tej techniki bez dobrego opanowania makr.

7.4. Taksonomia makr Po przedstawieniu kilku makr możemy wrócić do reguł klubu użytkowników makr i podać dodatkowe szczegóły. Pierwsza reguła klubu to „nie pisz makr”. Makra są skomplikowane. Jeśli żadne z makr języka Clojure nie wydaje Ci się złożone, w naszej firmie czeka na Ciebie posada3. 3

http://thinkrelevance.com

Rozdział 7. • Makra



215

Druga zasada klubu dotyczy tego, żeby pisać makra, jeśli jest to jedyny sposób na hermetyzację wzorca. Zobaczyłeś już, że trudne w hermetyzacji wzorce często związane są ze specjalnymi konstrukcjami, które są wyjątkami w języku. Dlatego drugą zasadę można też nazwać „regułą konstrukcji specjalnych”. Konstrukcje specjalne mają wyjątkowe możliwości, których nie posiada programista. Oto one:  Konstrukcje specjalne zapewniają podstawowe struktury sterowania przebiegiem kodu, na przykład instrukcje if i recur. Wszystkie makra do sterowania przebiegiem muszą ostatecznie wywoływać konstrukcje specjalne.  Konstrukcje specjalne zapewniają bezpośredni dostęp do Javy. Każde wywołanie kodu Javy w języku Clojure wymaga zastosowania przynajmniej jednej konstrukcji specjalnej, na przykład kropki lub instrukcji new.  Nazwy są tworzone i wiązane za pomocą konstrukcji specjalnych. Dotyczy to zarówno definiowania zmiennych za pomocą instrukcji def, tworzenia wiązań leksykalnych przy użyciu instrukcji let, jak i tworzenia wiązań dynamicznych z wykorzystaniem instrukcji binding. Choć konstrukcje specjalne mają duże możliwości, nie są funkcjami. Nie mogą wykonywać pewnych operacji dostępnych w funkcjach. Nie można na przykład zastosować instrukcji apply do konstrukcji specjalnej, zapisać takiej konstrukcji w zmiennej lub użyć konstrukcji specjalnej jako filtra w funkcjach biblioteki sekwencji. W języku Clojure konstrukcje specjalne nie są elementami pierwszej kategorii. Wyjątkowy charakter konstrukcji specjalnych może być poważnym problemem i prowadzić do powstawania powtarzalnych, trudnych w pielęgnacji wzorców w kodzie. Jednak makra są eleganckim rozwiązaniem tego problemu, ponieważ pozwalają generować konstrukcje specjalne. W praktyce oznacza to, że wszystkie mechanizmy języka w czasie rozwijania makr są elementami pierwszej kategorii. Makra do generowania konstrukcji specjalnych często są najtrudniejsze do napisania, ale też dają największe korzyści. W niemal magiczny sposób dodają nowe mechanizmy do języka. Wyjątek w klubie użytkowników makr dotyczy wygody użytkownika. Można napisać makro, jeśli stosowanie funkcji byłoby trudniejsze dla użytkowników kodu. Ponieważ argumenty makr nie są wartościowane, jednostka wywołująca może przekazać do makra nieprzetworzony kod, zamiast umieszczać go w funkcji anonimowej. Jednostka wywołująca może też przekazywać nazwy w standardowej postaci, zamiast symboli w apostrofach lub łańcuchów znaków.

216

Programowanie w języku Clojure



Przejrzeliśmy makra z języka Clojure i jego bibliotek. Prawie wszystkie z nich są zgodne z regułami klubu. Ponadto makra te można umieścić w kategoriach z poniższej tabeli, w której przedstawiamy taksonomię makr języka Clojure. Uzasadnienie

Kategoria

Przykłady

Konstrukcja specjalna

Wartościowanie warunkowe

when, when-not, and, or, comment

Konstrukcja specjalna

Definiowanie zmiennych

defn, defmacro, defmulti, defstruct, declare

Konstrukcja specjalna

Współdziałanie z Javą

.., doto, import-static

Wygoda użytkownika

Odraczanie wartościowania

lazy-cat, lazy-seq, delay

Wygoda użytkownika

Nakładka na wartościowanie

with-open, dosync, with-out-str, time, assert

Wygoda użytkownika

Unikanie lambdy

Te same, co dla nakładki na wartościowanie

Przyjrzyjmy się po kolei każdej z tych kategorii.

Wartościowanie warunkowe Ponieważ makra nie wartościują argumentów natychmiast, można je wykorzystać do tworzenia niestandardowych struktur sterujących. Pokazaliśmy to już na przykładzie makra unless z podrozdziału 7.2, „Makro do sterowania przebiegiem programu”. Makra służące do wartościowania warunkowego są zwykle dość proste do zrozumienia i napisania. Mają określoną strukturę — określ wartość argumentu (warunku), następnie na podstawie wyniku wybierz inne argumenty do wartościowania (jeśli istnieją). Dobrym przykładem jest makro and języka Clojure. 1 (defmacro and 2 ([] true) 3 ([x] x) 4 ([x & rest] 5 `(let [and# ~x] 6 (if and# (and ~@rest) and#))))

Makro and jest zdefiniowane rekurencyjnie. Ciała pozbawione argumentów i jednoargumentowe to przypadki podstawowe.  W wersji bezargumentowej zwracana jest wartość true.  W wersji jednoargumentowej zwracany jest argument.

Rozdział 7. • Makra



217

Jeśli argumentów jest więcej niż jeden, makro and wykorzystuje pierwszy argument jako warunek i sprawdza jego wartość w wierszu 5. Następnie, jeśli wartość argumentu to true, makro and wartościuje pozostałe argumenty przez rekurencyjne wywoływanie samego siebie dla kolekcji rest (wiersz 6.). Instrukcja and musi być makrem, aby mogła przeprowadzić skrócone wartościowanie po napotkaniu pierwszej wartości różnej od true. Nie jest zaskoczeniem, że istnieje podobne do makra and makro or. Sygnatury obu tych instrukcji są takie same. (and & exprs) (or & exprs)

Różnica polega na tym, że and zatrzymuje się na pierwszej logicznej wartości false, natomiast or — na pierwszej logicznej wartości true. (and 1 0 nil false) -> nil (or 1 0 nil false) -> 1

Rekordy w skróconym wartościowaniu bije makro comment. (comment & exprs)

Makro comment nigdy nie wartościuje żadnych argumentów. Czasem używa się go w końcowej części pliku z kodem źródłowym, aby przedstawić sposoby korzystania z danego interfejsu API. Na przykład biblioteka inspector języka Clojure kończy się przedstawionym poniżej makrem comment, w którym opisano korzystanie z tej biblioteki. (comment (load-file "src/inspector.clj") (refer 'inspector) (inspect-tree {:a 1 :b 2 :c [1 2 3 {:d 4 :e 5 :f [6 7 8]}]}) (inspect-table [[1 2 3][4 5 6][7 8 9][10 11 12]]) )

Zwróć uwagę na brak wcięć. W kodzie języka Clojure jest to niestandardowe podejście, jednak w makro comment przydaje się to, ponieważ pozwala zwrócić uwagę na treść makra.

218



Programowanie w języku Clojure

Tworzenie zmiennych Do tworzenia zmiennych języka Clojure służy konstrukcja specjalna def. W każdym innym kodzie służącym do tworzenia zmiennych trzeba ostatecznie wywołać def. Oznacza to, że defn, defmacro i defmulti to makra. Aby przedstawić pisanie makr do tworzenia zmiennych, omawiamy dwa makra języka Clojure — defstruct i declare. Clojure udostępnia niskopoziomową funkcję create-struct do tworzenia struktur. (create-struct & key-symbols)

Użyjmy funkcji create-struct do utworzenia struktury person. (def person (create-struct :first-name :last-name)) -> #'user/person

Funkcja create-struct działa, ale jest nieelegancka. Ponieważ programiści często chcą natychmiast wywołać instrukcję def dla nowej struktury, zwykle wywołują makro defstruct, które łączy instrukcje def i create-struct w jednej operacji. (defstruct name & key-symbols)

Instrukcja defstruct to proste makro języka Clojure. (defmacro defstruct [name & keys] `(def ~name (create-struct ~@keys)))

Makro to wygląda tak prosto, że kusząca jest myśl o napisaniu go jako funkcji. Nie jest to możliwe, ponieważ def to konstrukcja specjalna. Instrukcję def trzeba wygenerować w czasie rozwijania makra. Nie można „dynamicznie” wywoływać instrukcji def w czasie wykonywania programu. Makro defstruct poprawia czytelność jednego wiersza kodu, jednak niektóre makra pozwalają skrócić wiele wierszy do postaci jednowierszowej. Pomyśl o deklaracjach wyprzedzających. W programie potrzebne są referencje wyprzedzające do zmiennych a, b, c i d. Można wywołać instrukcję def bez argumentów, aby zdefiniować nazwy tych zmiennych bez ich wiązania. (def (def (def (def

a) b) c) d)

Jednak jest to podejście żmudne i wymagające wielu wierszy. Makro declare przyjmuje dowolnie długą listę nazw i wywołuje dla każdej z nich instrukcję def. (declare & names)

Teraz wszystkie nazwy można zadeklarować w jednej zwięzłej instrukcji.

Rozdział 7. • Makra



219

(declare a b c d) -> #'user/d

W języku Clojure instrukcja sposób:

declare

jest zaimplementowana w następujący

(defmacro declare [& names] `(do ~@(map #(list 'def %) names)))

Przeanalizujmy instrukcję declare, zaczynając od najbardziej zagnieżdżonego fragmentu. Funkcja anonimowa #(list 'def %) odpowiada za generowanie jednej instrukcji def. Sprawdźmy działanie tej funkcji w środowisku REPL. (#(list 'def %) 'a) -> (def a)

Instrukcja map wywołuje funkcję wewnętrzną jeden raz dla każdego przekazanego symbolu. Także tę instrukcję można przetestować w środowisku REPL. (map #(list 'def %) '[a b c d]) -> ((def a) (def b) (def c) (def d))

Początkowa instrukcja do powoduje rozwinięcie całego kodu do jednej poprawnej konstrukcji języka Clojure. `(do ~@(map #(list 'def %) '[a b c d])) -> (do (def a) (def b) (def c) (def d))

Podstawienie kolekcji '[a b c d] to ręczny odpowiednik przetestowania całego makra za pomocą funkcji macroexpand-1. (macroexpand-1 '(declare a b c d)) -> (do (def a) (def b) (def c) (def d))

Wiele spośród najciekawszych fragmentów języka Clojure to makra rozwijane do konstrukcji specjalnych obejmujących instrukcję def. Tu opisaliśmy kilka z nich. Możesz zajrzeć do kodu źródłowego każdego takiego makra. Większość z nich znajduje się w pliku src/clj/clojure/core.clj w dystrybucji z kodem źródłowym języka Clojure.

Współdziałanie z Javą W programach języka Clojure można wywoływać kod Javy za pomocą pewnych konstrukcji specjalnych — kropki (.), new i set!. Jednak w standardowym kodzie języka Clojure często stosuje się makra w rodzaju .. (dostęp do składowej w wątku) i doto, aby uprościć konstrukcje wywołujące kod Javy. Programista może wzbogacić sposób wywoływania kodu Javy w języku Clojure przez napisania makra. Rozważmy następujący scenariusz. Programista pisze kod z wykorzystaniem kilku stałych z klasy java.lang.Math.

220



Programowanie w języku Clojure

Math/PI -> 3.141592653589793 (Math/pow 10 3) -> 1000.0

Jeśli kod jest długi, przedrostek Math/ staje się uciążliwy. Wygodniejsze byłoby stosowanie samych nazw PI i pow. Język Clojure nie umożliwia osiągnięcia tego efektu bezpośrednio, można jednak ręcznie zdefiniować kilka zmiennych. (def PI Math/PI) -> #'user/PI (defn pow [b e] (Math/pow b e)) -> #'user/pow

Stuart Sierra napisał makro import-static, które automatycznie generuje ten szablonowy kod. (examples.import-static/import-static class & members)

Makro import-static importuje statyczne składowe members z klasy class Javy jako nazwy do lokalnej przestrzeni nazw. Za pomocą makra import-static można zaimportować dowolne składowe z klasy Math. (use '[examples.import-static :only (import-static)]) (import-static java.lang.Math PI pow) -> nil PI -> 3.141592653589793 (pow 10 3) -> 1000.0

Odraczanie wartościowania Większość sekwencji w języku Clojure to sekwencje „leniwe”. W czasie tworzenia „leniwych” sekwencji programiści łączą kilka konstrukcji, których wartościowanie jest odraczane do momentu realizacji sekwencji. Ponieważ wartościowanie nie odbywa się natychmiast, trzeba zastosować makro. Zetknąłeś się już z takim makrem w podrozdziale 3.3, „Sekwencje nieskończone i »leniwe«”. Jest to makro lazy-seq. Inne makro z tej kategorii to delay. (delay & exprs)

Makro delay przechowuje wyrażenia exprs i nic z nimi nie robi, dopóki nie jest to konieczne. Utwórzmy makro delay, które przez uśpienie symuluje długie obliczenia. (def slow-calc (delay (Thread/sleep 5000) "Gotowe!")) -> #'user/slow-calc

Rozdział 7. • Makra



221

Aby wykonać wyrażenia z makra delay, trzeba wywołać instrukcję force. (force x)

Wywołajmy kilkakrotnie force dla funkcji slow-calc. (force slow-calc) -> "Gotowe!" (force slow-calc) -> "Gotowe!"

Pierwsze wywołanie instrukcji force powoduje wykonanie wyrażeń i zapisanie wyniku w pamięci podręcznej. Późniejsze wywołania force prowadzą do zwrócenia wartości z pamięci. Wszystkie makra z implementacją „leniwego” i odroczonego wartościowania wywołują kod Javy z archiwum clojure.jar. We własnym kodzie nie należy bezpośrednio korzystać z tego rodzaju interfejsów API Javy. Makra do „leniwego” i odroczonego wartościowania należy traktować jak publiczny interfejs API, a klasy Javy — jak implementację, która może się zmienić.

Nakładki na wartościowanie Wiele makr to nakładki na wartościowanie kilku konstrukcji. Makra te wykonują specjalne operacje przed wartościowaniem konstrukcji i (lub) po wykonaniu tego zadania. Zetknąłeś się już z kilkoma makrami tego rodzaju.  Makro time uruchamia zegar, wartościuje konstrukcje, a następnie informuje, jak długo trwało wykonywanie kodu.  Makra let i binding tworzą wiązania, wartościują konstrukcje, a następnie usuwają wiązania.  Makro with-open przyjmuje otwarty plik (lub inny zasób), wykonuje pewne konstrukcje, a następnie gwarantuje zamknięcie zasobu w bloku finally.  Makro dosync wykonuje konstrukcje w transakcji. Innym przykładem makra pełniącego funkcję nakładki jest with-out-str. (with-out-str & exprs)

Makro with-out-str tymczasowo wiąże *out* z nowym obiektem StringWriter, wartościuje wyrażenie exprs, a następnie zwraca łańcuch wyświetlony na wyjściu (*out*). Makro with-out-str pozwala na łatwe używanie instrukcji print i println do tworzenia łańcuchów znaków „w locie”.

222



Programowanie w języku Clojure

(with-out-str (print "witaj, ") (print "świecie")) -> "witaj, świecie"

Implementacja makra with-out-str ma prostą strukturę, która może posłużyć za szablon do pisania podobnych makr. 1 (defmacro with-out-str 2 [& body] 3 `(let [s# (new java.io.StringWriter)] 4 (binding [*out* s#] 5 ~@body 6 (str s#))))

Makra będące nakładkami zwykle przyjmują dowolną liczbę argumentów (wiersz 2.), którymi są wartościowane konstrukcje. Następnie makro wykonuje trzy kroki. Oto one: 1. Przygotowania. Należy przygotować specjalny kontekst wartościowania, w razie konieczności tworząc wiązania za pomocą instrukcji let (wiersz 3.) i binding (wiersz 4.). 2. Wartościowanie. Makro wartościuje konstrukcję (wiersz 5.). Ponieważ liczba konstrukcji jest przeważnie dowolna, należy zastosować operację splicing unquote (~@). 3. Porządkowanie. Makro przywraca normalny kontekst wykonania i zwraca odpowiednią wartość (wiersz 6.). W czasie pisania makr będących nakładkami zawsze należy ustalić, czy do prawidłowego zaimplementowania porządkowania potrzebny jest blok finally. W makro with-out-str blok ten nie jest potrzebny, ponieważ instrukcje let i binding same wykonują operacje porządkujące. Jeśli jednak makro ustawia stan globalny lub lokalny dla wątku za pomocą interfejsu API Javy, trzeba zastosować blok finally do przywrócenia wyjściowego stanu. Ze zmiennym stanem związane jest inne spostrzeżenie. Kod, którego działanie zmienia się w czasie wykonywania w makro będącym nakładką, z pewnością nie jest czystą funkcją. Funkcje print i println działają inaczej w zależności od wartości *out*, dlatego nie są czystymi funkcjami. Makra tworzące wiązania za pomocą instrukcji binding (na przykład makro with-out-str) robią to w celu zmiany działania funkcji, która nie jest czysta. Nie wszystkie nakładki modyfikują działanie funkcji. Zetknąłeś się już takim makrem, time, które mierzy czas wykonywania funkcji. Innym makrem tego typu jest assert. (assert expr)

Rozdział 7. • Makra



223

Makro assert sprawdza wyrażenie i zgłasza wyjątek, jeśli wartość logiczna wyrażenia jest różna od true. (assert (= 1 1)) -> nil (assert (= 1 2)) -> java.lang.Exception: Assert failed: (= 1 2)

Autorzy makr w rodzaju assert i time naruszają pierwszą regułę klubu użytkowników makr, aby uniknąć stosowania niepotrzebnych lambd.

Unikanie lambd Z przyczyn historycznych funkcje anonimowe nieraz nazywa się lambdami. Czasem zamiast makra można wywołać funkcję z argumentami umieszczonymi w lambdzie. Na przykład makro bench z punktu „Dosłowne podawanie składni oraz operacje unquote i splicing unquote” wcale nie musi być makrem. Można zapisać je jako funkcję. (defn bench-fn [f] (let [start (System/nanoTime) result (f)] {:result result :elapsed (- (System/nanoTime) start)}))

Jednak aby wywołać funkcję bench-fn, trzeba przekazać do niej funkcję opakowującą konstrukcję, którą należy wykonać. Różnicę pokazano w poniższym kodzie. ; Makro. (bench (+ 1 2)) -> {:elapsed 44000, :result 3} ; Funkcja. (bench-fn (fn [] (+ 1 2))) -> {:elapsed 53000, :result 3}

W przypadku instrukcji bench makra i funkcje anonimowe są niemal swoimi odpowiednikami. Obie techniki pozwalają odroczyć wykonanie konstrukcji. Jednak podejście oparte na funkcjach anonimowych wymaga więcej wysiłku po stronie użytkownika, dlatego można złamać pierwszą regułę i napisać makro zamiast funkcji. Jest też inny powód, dla którego instrukcję bench lepiej jest napisać w formie makra — funkcja bench-fn nie jest idealnym zastępnikiem. Wynika to z tego, że w czasie wykonywania programu trzeba ponieść koszty związane z wywołaniem funkcji anonimowej. Ponieważ bench służy do pomiaru czasu, należy unikać zbędnych kosztów.

224



Programowanie w języku Clojure

7.5. Podsumowanie Makra języka Clojure pozwalają automatyzować wzorce występujące w kodzie. Ponieważ w czasie rozwijania makr przekształcany jest kod źródłowy, makra pozwalają w abstrakcyjny sposób ująć dowolne wzorce. Programiści nie są ograniczeni do języka Clojure; za pomocą makr mogą rozszerzyć ten język na dziedzinę problemu.

Rozdział 8.

Wielometody

W

ielometody w języku Clojure umożliwiają swobodne wiązanie funkcji ze zbiorem danych wejściowych. Mechanizm ten przypomina polimorfizm z Javy, ale jest bardziej uniwersalny. Przy wywołaniu metody Javy konkretna implementacja jest wybierana na podstawie typu jednego obiektu. Wywołanie wielometody języka Clojure powoduje wybór konkretnej implementacji na podstawie wyniku dowolnej wybranej funkcji, wywołanej dla wszystkich argumentów. W tym rozdziale docenisz wartość wielometod. Najpierw zobaczysz, jak wygląda programowanie bez nich. Następnie zapoznasz się z serią coraz bardziej skomplikowanych implementacji wielometod. Pierwsze metody posłużą do symulowania polimorfizmu, a dalsze — do zaimplementowania rozmaitych doraźnych taksonomii. Wielometody w języku Clojure stosuje się znacznie rzadziej niż polimorfizm w językach obiektowych. Jednak kiedy już się z nich korzysta, odgrywają bardzo istotną rolę. W podrozdziale 8.5, „Kiedy należy korzystać z wielometod?”, wyjaśniamy, w jaki sposób wielometody stosuje się w kilku otwartych projektach języka Clojure. Przedstawiamy też wskazówki pomagające używać wielometod we własnych programach.

Jeśli czytasz tę książkę rozdział po rozdziale, po przeczytaniu tego będziesz znał wszystkie najważniejsze mechanizmy języka Clojure.

226



Programowanie w języku Clojure

8.1. Życie bez wielometod Najlepszym sposobem na docenienie wielometod jest tymczasowa rezygnacja z nich. Zróbmy to więc. Clojure umożliwia wyświetlanie dowolnych informacji za pomocą instrukcji print i println, jednak przez chwilę załóżmy, że funkcje te nie istnieją, dlatego trzeba utworzyć uniwersalny mechanizm wyświetlania tekstu. Zacznijmy od utworzenia funkcji my-print, która wyświetla łańcuch znaków do standardowego strumienia wyjścia *out*. src/examples/life_without_multi.clj

(defn my-print [ob] (.write *out* ob))

Następnie można utworzyć funkcję my-println, która wywołuje instrukcję my-print, a następnie dodaje znak nowego wiersza. src/examples/life_without_multi.clj

(defn my-println [ob] (my-print ob) (.write *out* "\n"))

Znak nowego wiersza sprawia, że dane wyjściowe wyświetlane przez funkcję my-println są bardziej czytelne przy testowaniu kodu w środowisku REPL. W dalszej części podrozdziału wprowadzamy zmiany w funkcji my-print i testujemy je przez wywoływanie funkcji my-println. Sprawdźmy, czy funkcja my-println działa dla łańcuchów znaków. (my-println "witaj") | witaj -> nil

Dla łańcuchów funkcja my-println działa dobrze, jednak gorzej radzi sobie z innymi danymi, na przykład wartością nil. (my-println nil) -> java.lang.NullPointerException

Nie stanowi to jednak większego problemu. Wystarczy użyć instrukcji cond i dodać specjalny przypadek dla wartości nil. src/examples/life_without_multi.clj

(defn my-print [ob] (cond (nil? ob) (.write *out* "nil") (string? ob) (.write *out* ob)))

Rozdział 8. • Wielometody



227

Po zastosowaniu instrukcji warunkowej można prawidłowo wyświetlić wartość nil. (my-println nil) | nil -> nil

Oczywiście występują też inne typy, z którymi instrukcja my-println sobie nie radzi. Na przykład wektora nie można dopasować do żadnej klauzuli instrukcji cond, dlatego program nie wyświetla wektorów. (my-println [1 2 3]) -> nil

Wiesz już jednak, co trzeba zrobić. Wystarczy dodać następną klauzulę cond dla wektorów. Tu implementacja jest bardziej skomplikowana, dlatego wyświetlanie można umieścić w funkcji pomocniczej, na przykład my-print-vector. src/examples/life_without_multi.clj

(require '[clojure.string :as str]) (defn my-print-vector [ob] (.write *out*"[") (.write *out* (str/join " " ob)) (.write *out* "]")) (defn my-print [ob] (cond (vector? ob) (my-print-vector ob) (nil? ob) (.write *out* "nil") (string? ob) (.write *out* ob)))

Upewnij się, że teraz można wyświetlić wektor. (my-println [1 2 3]) | [1 2 3] -> nil

Funkcja my-println obsługuje teraz trzy typy — łańcuchy znaków, wektory i wartość nil. Wiesz też, jak dodać obsługę nowych typów. Wystarczy dodać nowe klauzule do instrukcji cond w funkcji my-println. Jest to jednak kiepskie rozwiązanie, ponieważ nie oddziela procesu wyboru implementacji od konkretnego kodu implementacji. Kod można nieco ulepszyć przez wyodrębnienie funkcji pomocniczych w rodzaju my-print-vector. Jednak wymaga to wprowadzania dwóch odrębnych zmian przy dodawaniu każdego nowego mechanizmu do funkcji my-println. Trzeba wtedy:  utworzyć nową funkcję pomocniczą dla konkretnego typu;  zmodyfikować istniejącą funkcję my-println przez dodanie w instrukcji cond nowej klauzuli z odpowiednią funkcją pomocniczą.

228



Programowanie w języku Clojure

Potrzebny jest sposób dodawania do systemu nowych cech przez umieszczanie nowego kodu w jednym miejscu, bez konieczności modyfikowania istniejącego kodu. Język Clojure umożliwia uzyskanie tego efektu poprzez protokoły (opisane w podrozdziale 6.3, „Protokoły”) i wielometody.

8.2. Definiowanie wielometod Do definiowania wielometod służy instrukcja defmulti. (defmulti name dispatch-fn)

Argument name to nazwa nowej wielometody, a Clojure wywołuje funkcję dispatch-fn dla argumentów metody w celu wybrania konkretnej implementacji wielometody. Przyjrzyj się funkcji my-print z poprzedniego podrozdziału. Przyjmuje ona jeden argument, wyświetlany element. Chcemy, aby na podstawie tego argumenty dobierana była odpowiednia implementacja. Dlatego dispatch-fn musi być jednoargumentową funkcją, która zwraca typ argumentu. Clojure ma wbudowaną funkcję, która pasuje do tego opisu. Jest to funkcja class. Przy jej użyciu można utworzyć wielometodę my-print. src/examples/multimethods.clj

(defmulti my-print class)

Podaliśmy już opis wybierania konkretnej metody, ale na razie nie utworzyliśmy żadnych implementacji. Nie jest więc zaskoczeniem, że próba wywołania wielometody my-print kończy się niepowodzeniem. (my-println "foo") -> java.lang.IllegalArgumentException: \ No method for dispatch value

Aby dodać do wielometody my-println implementację konkretnej metody, należy użyć instrukcji defmethod. (defmethod name dispatch-val & fn-tail)

Argument name to nazwa wielometody, do której należy implementacja. Clojure dopasowuje do argumentu dispatch-val wynik wywołania funkcji wybierającej z definicji defmulti, a fn-tail obejmuje argumenty i ciało (tak jak zwykła funkcja). Utwórzmy implementację wielometody my-print pasującą do łańcuchów znaków.

Rozdział 8. • Wielometody



229

src/examples/multimethods.clj

(defmethod my-print String [s] (.write *out* s))

Teraz można wywołać funkcję znaków.

my-println

i przekazać jako argument łańcuch

(my-println "stu") | stu -> nil

Teraz utwórzmy implementację wielometody wartości nil.

my-print

przeznaczoną dla

src/examples/multimethods.clj

(defmethod my-print nil [s] (.write *out* "nil"))

Warto zauważyć, że rozwiązaliśmy w ten sposób problem opisany w poprzednim podrozdziale. Implementacji wielometody my-print nie trzeba łączyć w dużym warunku cond — można zapisać je osobno. Metody wielometody mogą znajdować się w dowolnym miejscu kodu źródłowego. Ponadto w dowolnym momencie można dodać nowe implementacje bez konieczności modyfikowania pierwotnego kodu.

Wybieranie metod odbywa się z uwzględnieniem dziedziczenia Wybieranie implementacji wielometody odbywa się z uwzględnieniem dziedziczenia z Javy. Aby się o tym przekonać, warto utworzyć implementację my-print obsługującą typ Number. Implementacja ta wyświetla wartość po przekształceniu jej za pomocą funkcji toString. src/examples/multimethods.clj

(defmethod my-print Number [n] (.write *out* (.toString n)))

Sprawdźmy, czy implementacja dla typu Number działa dla liczb całkowitych. (my-println 42) | 42 -> nil

Wartość 42 jest typu Integer, a nie Number. Mechanizm wybierania implementacji wielometody potrafi wykryć, że liczba całkowita jest liczbą, i uruchomić właściwy kod. Wewnętrznie mechanizm wybierania implementacji stosuje funkcję isa?. (isa? child parent)

230



Programowanie w języku Clojure

Funkcja isa? poprawnie interpretuje dziedziczenie z Javy, dlatego potrafi wykryć, że obiekt typu Integer jest też obiektem typu Number. (isa? Integer Number) -> true

Funkcja isa? sprawdza nie tylko dziedziczenie. Jej działanie można dynamicznie wzbogacać w czasie wykonywania programu, o czym przekonasz się w podrozdziale 8.4, „Tworzenie doraźnych taksonomii”.

Domyślne działanie wielometod Byłoby wygodnie, gdyby wielometoda my-print miała domyślną metodę używaną dla każdego typu, dla którego nie zdefiniowano konkretnej metody. Taką domyślną metodę można określić za pomocą słowa :default. Przy jego użyciu utwórzmy metodę my-print wyświetlającą dla obiektów wartość wywołania funkcji toString Javy. Wartość ta jest umieszczana między znakami #< i >. src/examples/multimethods.clj

(defmethod my-print :default [s] (.write *out* "#"))

Teraz sprawdźmy, czy funkcja my-println za pomocą domyślnej metody potrafi wyświetlać dowolne dane. (my-println (java.sql.Date. 0)) -> # (my-println (java.util.Random.)) -> #

W rzadkich sytuacjach, kiedy nazwa :default ma określone znaczenie w danej dziedzinie, można utworzyć wielometodę za pomocą przedstawionej poniżej alternatywnej sygnatury. (defmulti name dispatch-fn :default default-value)

Argument default-value pozwala podać własną nazwę domyślnej metody. Możemy ją nazwać na przykład :everything-else. src/examples/multimethods/default.clj

(defmulti my-print class :default :everything-else) (defmethod my-print String [s] (.write *out* s)) (defmethod my-print :everything-else [_] (.write *out* "Brak implementacji."))

Rozdział 8. • Wielometody



231

Jeśli teraz przy wybieraniu metody nie uda się dopasować typu, wywołana zostanie metoda dla wartości :everything-else. Wybieranie metod wielometody na podstawie typu pierwszego argumentu (tak działa wielometoda my-print) to zdecydowanie najczęściej stosowana technika. W wielu językach obiektowych jest to jedyny mechanizm dynamicznego wybierania metod, nazywany tam polimorfizmem. Wybieranie metod w języku Clojure to dużo bardziej uniwersalny mechanizm. Dodajmy kilka komplikacji do wielometody my-print i wyjdźmy poza to, co jest możliwe w standardowym polimorfizmie.

8.3. Więcej niż proste wybieranie metod Funkcja print języka Clojure wyświetla różne sekwencyjne dane jako listy. Jeśli wielometoda my-print ma działać podobnie, można dodać metodę wybieraną dla typu kolekcji z wysokiego poziomu hierarchii dziedziczenia Javy, na przykład dla typu Collection. src/examples/multimethods.clj

(require '[clojure.string :as str]) (defmethod my-print java.util.Collection [c] (.write *out* "(") (.write *out* (str/join " " c)) (.write *out* ")"))

Teraz wypróbujmy metodę dla różnych sekwencji, aby sprawdzić, czy są wyświetlane w odpowiedni sposób. (my-println (take 6 (cycle [1 2 3]))) | (1 2 3 1 2 3) -> nil (my-println [1 2 3]) | (1 2 3) -> nil

Dla perfekcjonistów, jakimi bywają programiści, nieakceptowalne jest wyświetlanie wektorów za pomocą nawiasów zwykłych, a nie kwadratowych. Dlatego dodajmy nową metodę wielometody my-print, przeznaczoną do obsługi wektorów. Wszystkie wektory obejmują implementację interfejsu IPersistentVector, dlatego poniższy kod powinien działać poprawnie.

232



Programowanie w języku Clojure

src/examples/multimethods.clj

(defmethod my-print clojure.lang.IPersistentVector [c] (.write *out* "[") (.write *out* (str/join " " c)) (.write *out* "]"))

Powinien, ale nie działa. Przy próbie wyświetlenia wektorów pojawia się wyjątek. (my-println [1 2 3]) -> java.lang.IllegalArgumentException: Multiple methods match dispatch value: class clojure.lang.LazilyPersistentVector -> interface clojure.lang.IPersistentVector and interface java.util.Collection, and neither is preferred

Problem polega na tym, że teraz do wektorów pasują metody wybierane dla dwóch typów — Collection i IPersistentVector. W wielu językach przy wybieraniu metod obowiązują ograniczenia (nie można na przykład stosować wielodziedziczenia), co pozwala uniknąć tego typu konfliktów. W języku Clojure zastosowano inne podejście. Można powodować konflikty, a następnie rozwiązywać je przy użyciu instrukcji prefer-method. (prefer-method multi-name loved-dispatch dissed-dispatch)

W wywołaniu instrukcji prefer-method dla wielometody można nakazać, aby przy wystąpieniu konfliktu preferowana była wartość loved-dispatch kosztem wartości dissed-dispatch. Ponieważ chcemy, aby w wielometodzie my-print używana była metoda dla wektorów, a nie dla kolekcji, należy o tym poinformować. src/examples/multimethods.clj

(prefer-method my-print clojure.lang.IPersistentVector java.util.Collection)

Teraz można obsługiwać wektory i inne sekwencje za pomocą odpowiednich implementacji. (my-println (take 6 (cycle [1 2 3]))) | (1 2 3 1 2 3) -> nil (my-println [1 2 3]) | [1 2 3] -> nil

W wielu językach obowiązują skomplikowane reguły lub arbitralne ograniczenia, potrzebne do radzenia sobie z wieloznacznością działania systemów wybierania funkcji. Clojure umożliwia zastosowanie znacznie prostszego podejścia — nie trzeba się o nic martwić! Jeśli występuje wieloznaczność, można rozwiązać ją za pomocą instrukcji prefer-method.

Rozdział 8. • Wielometody



233

8.4. Tworzenie doraźnych taksonomii Wielometody pozwalają tworzyć doraźne taksonomie, przydatne w sytuacji wykrycia niezadeklarowanych relacji między typami. Zastanówmy się na przykład nad aplikacją finansową z rachunkami rozliczeniowymi i oszczędnościowymi. Konto można zapisać w strukturze języka Clojure i użyć pola :tag do rozróżniania rodzajów rachunków. Umieśćmy kod w przestrzeni nazw examples.multimethods.account. Aby utworzyć strukturę, najpierw trzeba utworzyć plik examples/multimethods/account.clj w ścieżce klasy1, a następnie wprowadzić przedstawiony poniżej kod. src/examples/multimethods/account.clj

(ns examples.multimethods.account) (defstruct account :id :tag :balance)

Teraz należy utworzyć dwa rodzaje rachunków, oznaczane jako ::Checking i ::Savings. Wielkie litery w konwencjach stosowanych w języku Clojure oznaczają słowa kluczowe, które pełnią funkcję typów. Dwa dwukropki (::) powodują, że słowa kluczowe są określane w ramach bieżącej przestrzeni nazw. Aby zobaczyć, jak działa ten mechanizm, sprawdźmy, jaki efekt ma wpisanie w środowisku REPL słów kluczowych :Checking i ::Checking. :Checking -> :Checking ::Checking -> :user/Checking

Umieszczanie słów kluczowych w przestrzeni nazw pomaga zapobiegać konfliktom z nazwami z kodu innych programistów. Aby zastosować słowa kluczowe ::Savings lub ::Checking z innej przestrzeni nazw, należy podać pełne nazwy. (struct account 1 ::examples.multimethods.account/Savings 100M) -> {:id 1, :tag :examples.multimethods.account/Savings, :balance 100M}

Używanie pełnych nazw szybko staje się niewygodne, dlatego można zastosować instrukcję alias i podać krótszy alias dla długich przestrzeni nazw. (alias short-name-symbol namespace-symbol)

1

Warto zauważyć, że w przykładowym kodzie z książki znajduje się gotowa wersja tej aplikacji, umieszczona w ścieżce klasy. Aby samodzielnie utworzyć przykładowy program, usuń gotową aplikację lub zmień jej nazwę.

234

Programowanie w języku Clojure



Zastosujmy instrukcję alias do utworzenia krótkiej nazwy acc. (alias 'acc 'examples.multimethods.account) -> nil

Po przygotowaniu aliasu acc utwórzmy na najwyższym poziomie dwa obiekty testowe — rachunek oszczędnościowy i rachunek rozliczeniowy. (def test-savings (struct account 1 ::acc/Savings 100M)) -> #'user/test-savings (def test-checking (struct account 2 ::acc/Checking 250M)) -> #'user/test-checking

Warto zauważyć, że końcowa litera typu BigDecimal.

M

nie oznacza milionów, a tworzy literał

Oprocentowanie dla rachunków rozliczeniowych wynosi 0%, a dla rachunków oszczędnościowych — 5%. Utwórzmy wielometodę interest-rate, która wybiera metody na podstawie wartości pola :tag. src/examples/multimethods/account.clj

(defmulti interest-rate :tag) (defmethod interest-rate ::acc/Checking [_] 0M) (defmethod interest-rate ::acc/Savings [_] 0.05M)

Sprawdźmy obiekty test-savings i test-checking, aby się upewnić, że wielometoda interest-rate działa w oczekiwany sposób. (interest-rate test-savings) -> 0.05M (interest-rate test-checking) -> 0M

Obowiązują też roczne opłaty za prowadzenie rachunków. Oto reguły naliczania tych opłat:  Obsługa zwykłych rachunków rozliczeniowych kosztuje 25 złotych rocznie.  Obsługa rachunków oszczędnościowych kosztuje 10 złotych rocznie.  Rachunki premium są prowadzone bezpłatnie.  Rozliczeniowe rachunki premium to te ze stanem powyżej 5000 złotych.  Oszczędnościowe rachunki premium to te ze stanem powyżej 1000 złotych.

Rozdział 8. • Wielometody



235

W praktyce reguły byłyby bardziej skomplikowane. Status konta premium przyznawany byłby na podstawie średniego stanu z określonego okresu. Prawdopodobnie możliwe byłoby uzyskanie tego statusu także w inny sposób. Jednak przedstawione tu reguły są wystarczająco złożone na potrzeby przykładu. Funkcję service-charge (służącą do naliczania opłat) można zaimplementować za pomocą kilku instrukcji warunkowych, jednak premium można potraktować jak typ, choć w strukturze account nie ma tagu premium. Utwórzmy wielometodę account-level, zwracającą wartości ::Premium lub ::Basic. src/examples/multimethods/account.clj

(defmulti account-level :tag) (defmethod account-level ::acc/Checking [acct] (if (>= (:balance acct) 5000) ::acc/Premium ::acc/Basic)) (defmethod account-level ::acc/Savings [acct] (if (>= (:balance acct) 1000) ::acc/Premium ::acc/Basic))

Przetestujmy wielometodę account-level, aby się upewnić, że rachunki rozliczeniowe i oszczędnościowe muszą mieć inny stan, żeby uzyskać status konta premium. (account-level (struct account 1 ::acc/Savings 2000M)) -> :examples.multimethods.account/Premium (account-level (struct account 1 ::acc/Checking 2000M)) -> :examples.multimethods.account/Basic

Kusząca jest myśl o tym, aby zaimplementować wielometodę service-charge z wykorzystaniem wielometody account-level do wybierania konkretnych metod. src/examples/multimethods/service_charge_1.clj

; Złe podejście. (defmulti service-charge account-level) (defmethod service-charge ::Basic [acct] (if (= (:tag acct) ::Checking) 25 10)) (defmethod service-charge ::Premium [_] 0)

Warunek dla kont ::Basic w wielometodzie service-charge jest oparty na typie, a wielometody mają służyć unikaniu właśnie tego typu rozwiązań. Problem polega na tym, że metody są już wybierane za pomocą wielometody account-level, a teraz trzeba je wybierać także na podstawie wartości pola :tag. Nic nie szkodzi — można wybierać metody na podstawie obu tych elementów. Napiszmy wielometodę service-charge, która wybiera metody na podstawie wektora z wynikiem działania wielometody account-level i polem :tag.

236

Programowanie w języku Clojure



src/examples/multimethods/service_charge_2.clj

(defmulti service-charge (fn [acct] [(account-level acct) (:tag acct)])) (defmethod service-charge [::acc/Basic ::acc/Checking] [_] 25) (defmethod service-charge [::acc/Basic ::acc/Savings] [_] 10) (defmethod service-charge [::acc/Premium ::acc/Checking] [_] 0) (defmethod service-charge [::acc/Premium ::acc/Savings] [_] 0)

Ta wersja wielometody service-charge wybiera metody na podstawie dwóch różnych taksonomii — pola :tag rachunku i zewnętrznej wielometody account-level. Sprawdźmy kilka rachunków, aby ustalić, czy wielometoda service-charge działa w oczekiwany sposób. (service-charge {:tag ::acc/Checking :balance 1000}) -> 25 (service-charge {:tag ::acc/Savings :balance 1000}) -> 0

Warto zauważyć, że we wcześniejszych testach nawet nie utworzyliśmy „prawdziwego” rachunku. Struktury takie jak account to odwzorowania zoptymalizowane pod kątem zapisywania konkretnych pól, jednak nic nie stoi na przeszkodzie, aby używać takich struktur jak zwykłych odwzorowań, kiedy jest to wygodniejsze.

Dziedziczenie po doraźnie tworzonych typach Wielometodę service-charge można usprawnić w jeszcze jeden sposób. Ponieważ koszt prowadzenia wszystkich rachunków premium jest taki sam, nie trzeba definiować dwóch odrębnych metod service-charge dla rachunków premium typu ::Savings i ::Checking. Wygodny byłby jeden nadrzędny typ ::Account, pozwalający zdefiniować metodę używaną dla typu ::Premium i dowolnego rodzaju typu ::Account. W języku Clojure za pomocą instrukcji derive można definiować dowolne relacje rodzic-dziecko. (derive child parent)

Za pomocą instrukcji miany typu ::Account.

derive

można określić, że

::Savings

i

::Checking

to od-

src/examples/multimethods/service_charge_3.clj

(derive ::acc/Savings ::acc/Account) (derive ::acc/Checking ::acc/Account)

Przy korzystaniu z instrukcji derive przydatna jest funkcja isa?. Poprawnie interpretuje ona nie tylko dziedziczenie z Javy, ale też relacje utworzone za pomocą instrukcji derive. (isa? ::acc/Savings ::acc/Account) -> true

Rozdział 8. • Wielometody



237

Teraz, kiedy Clojure potrafi wykryć, że Savings i Checking są odmianami typu Account, można zdefiniować wielometodę service-charge z jedną metodą do obsługi rachunków typu ::Premium. src/examples/multimethods/service_charge_3.clj

(defmulti service-charge (fn [acct] [(account-level acct) (:tag acct)])) (defmethod service-charge [::acc/Basic ::acc/Checking] [_] 25) (defmethod service-charge [::acc/Basic ::acc/Savings] [_] 10) (defmethod service-charge [::acc/Premium ::acc/Account] [_] 0)

Na pozór może się wydawać, że derive i isa? powielają mechanizmy dostępne w języku Clojure poprzez dziedziczenie z Javy. To nieprawda. Dziedziczenie w Javie prowadzi do powstania relacji ustalonych w momencie definiowania klasy. Za pomocą instrukcji derive można tworzyć relacje, kiedy są one potrzebne. Można je tworzyć dla istniejących obiektów bez modyfikowania tych ostatnich. Dlatego po wykryciu przydatnej relacji między istniejącymi obiektami można wywołać instrukcję derive i utworzyć tę relację. Nie trzeba przy tym modyfikować kodu źródłowego pierwotnych obiektów ani tworzyć klas pełniących funkcję nakładek. Nie przejmuj się, jeśli liczba możliwości dostępnych przy tworzeniu wielometod wywołuje u Ciebie zawrót głowy. W praktyce w języku Clojure wielometody zwykle stosuje się rzadko. Przyjrzyjmy się otwartemu kodowi języka Clojure, aby lepiej zrozumieć sposoby korzystania z wielometod.

8.5. Kiedy należy korzystać z wielometod? Wielometody dają bardzo duże możliwości, z czym wiąże się konieczność podejmowania decyzji. Jak ustalić, czy warto zastosować wielometody zamiast innej techniki? Rozważamy dwa aspekty tego pytania. Zastanawiamy się tu:  gdzie wielometody stosuje się w projektach języka Clojure i  gdzie w projektach języka Clojure unika się wielometod. Najbardziej uderzające jest to, że wielometody występują rzadko — mniej więcej raz na 1000 wierszy kodu. Nie przejmuj się więc, że nie stosujesz ważnej techniki, ponieważ w aplikacji używasz niewielu wielometod lub w ogóle z nich nie korzystasz. Programy w języku Clojure pozbawione wielometod występują dużo częściej niż programy obiektowe bez polimorfizmu.

238



Programowanie w języku Clojure

W licznych wielometodach wybór metod odbywa się na podstawie klasy. Jest to najłatwiejszy do zrozumienia i zaimplementowania sposób wybierania metod. Omówiliśmy go już szczegółowo na przykładzie wielometody my-print, dlatego tu nie wracamy do tego podejścia. W języku Clojure wielometody, w których metody wybiera się nie na podstawie klasy, są dość rzadkie. Przykłady można jednak znaleźć w samym języku. W bibliotekach clojure.inspector i clojure.test stosuje się niestandardowe sposoby wybierania metod.

Biblioteka inspector Biblioteka inspector języka Clojure korzysta z frameworku Swing do tworzenia prostych widoków danych. Przy użyciu tej biblioteki można wyświetlić drzewo właściwości systemowych. (use '[clojure.inspector :only (inspect inspect-tree)]) (inspect-tree (System/getProperties)) -> #

Instrukcja inspect-tree zwraca (i wyświetla) obiekt JFrame z drzewem obejmującym obiekty, które można wyświetlić w tej postaci. Do instrukcji inspect-tree można na przykład przekazać zagnieżdżone odwzorowanie. (inspect-tree {:clojure {:creator "Rich" :runs-on-jvm true}}) -> #

Elementy wyświetlane w drzewach składają się z węzłów, dla których można określić dwie właściwości:  jakie są elementy potomne węzła i  czy węzeł jest liściem. Pojęcia „drzewo”, „węzeł” i „liść” są dobrymi kandydatami na klasy lub interfejsy w projekcie obiektowym. Jednak biblioteka inspector nie działa w tym modelu. Zamiast tego dodaje doraźnie do istniejących typów system typów zdatnych do wyświetlania w drzewie. Umożliwia to funkcja collection-tag, służąca do wybierania metod. ; Z pliku clojure/inspector.clj języka Clojure. (defn collection-tag [x] (cond (instance? java.util.Map$Entry x) :entry (instance? clojure.lang.IPersistentMap x) :map (instance? java.util.Map x) :map (instance? clojure.lang.Sequential x) :seq :else :atom))

Rozdział 8. • Wielometody



239

Funkcja collection-tag zwraca jedno ze słów kluczowych z zestawu :entry, :map, :seq i :atom. Działają one jak system typów wyświetlanych w formie drzewa. Funkcja collection-tag służy do wybierania w trzech różnych wielometodach konkretnych implementacji na podstawie systemu typów wyświetlanych w formie drzewa. (defmulti is-leaf collection-tag) (defmulti get-child (fn [parent index] (collection-tag parent))) (defmulti get-child-count collection-tag) ; W celu zachowania zwięzłości implementacje metod pomijamy.

System typów wyświetlanych w formie drzewa jest oparty na istniejącym systemie typów Javy. Nie trzeba nic robić w kodzie istniejących obiektów, aby wyświetlać je w formie drzewa. Za wszystko odpowiada biblioteka inspector. Opisany mechanizm to przykład bardzo wartościowego sposobu wielokrotnego wykorzystania kodu. Można wykryć nowe relacje w istniejącym kodzie i łatwo je wykorzystać bez konieczności modyfikowania istniejącego kodu.

Biblioteka clojure.test Biblioteka clojure.test języka Clojure pozwala pisać różnego rodzaju asercje z wykorzystaniem makra is. Można między innymi sprawdzać, czy dowolne funkcje mają wartość true. Na przykład 10 to nie łańcuch znaków. (use :reload '[clojure.test :only (is)]) (is (string? 10)) FAIL in clojure.lang.PersistentList$EmptyList@1 (NO_SOURCE_FILE:2) expected: (string? 10) actual: (not (string? 10)) -> false

Choć można stosować dowolne funkcje, is obsługuje pewne konkretne funkcje i wyświetla szczegółowe komunikaty o błędach. Można na przykład za pomocą instrukcji instance? stwierdzić, że łańcuch znaków nie jest egzemplarzem typu Collection. (is (instance? java.util.Collection "foo")) FAIL in clojure.lang.PersistentList$EmptyList@1 (NO_SOURCE_FILE:3) expected: (instance? java.util.Collection "foo") actual: java.lang.String -> false

240



Programowanie w języku Clojure

Funkcja is współdziała też z operatorem =. Pozwala to stwierdzić, że władza to nie mądrość. (is (= "władza" "mądrość")) FAIL in clojure.lang.PersistentList$EmptyList@1 (NO_SOURCE_FILE:4) expected: (= "władza" "mądrość") actual: (not (= "władza" "mądrość")) -> false

Wewnętrznie funkcja is wywołuje wielometodę assert-expr, która wybiera metody nie na podstawie typu, ale według tożsamości pierwszego argumentu. (defmulti assert-expr (fn [form message] (first form)))

Ponieważ pierwszy argument to symbol reprezentujący to, jakiej funkcji wartość należy sprawdzić, powstaje w ten sposób następny doraźny system typów. Obejmuje on trzy typy — =, instance? i wszystkie pozostałe funkcje. Poszczególne metody wielometody assert-expr wyświetlają konkretne komunikaty o błędach, powiązane z różnymi funkcjami, które można wywołać w funkcji is. Ponieważ wielometody można rozszerzać, do assert-expr możemy dodać własne metody z dokładniejszymi komunikatami o błędach, powiązane z funkcjami często używanymi w is.

Kontrprzykłady W podrozdziale 8.4, „Tworzenie doraźnych taksonomii”, pokazaliśmy, że wielometody często można stosować do obsługi gałęzi dla typów, które nie są w standardowy sposób przetwarzane przez funkcje. Aby znaleźć kontrprzykłady sytuacji, w których nie należy stosować wielometod, wyszukaliśmy w rdzeniu języka Clojure gałęzie dla typów nieobsługiwanych za pomocą wielometod. Prostym przykładem jest funkcja class języka Clojure. To prosta, bezpieczna ze względu na wartości null nakładka na instrukcję getClass Javy. Oto kod funkcji class (komentarze i metadane pomijamy). (defn class [x] (if (nil? x) x (. x (getClass))))

Można napisać własną wersję instrukcji class jako wielometodę i wybierać metody na podstawie tożsamości (argument identity). src/examples/multimethods.clj

(defmulti my-class identity) (defmethod my-class nil [_] nil) (defmethod my-class :default [x] (.getClass x))

Rozdział 8. • Wielometody



241

W ten sposób można zapisać każdy test wartości nil. Jednak naszym zdaniem pierwotna wersja funkcji class jest bardziej czytelna niż pokazana wielometoda. Jest to wyjątek, który potwierdza regułę. Choć funkcja class tworzy gałęzie na podstawie typów, wersja z gałęziami jest tu bardziej czytelna. Poniżej przedstawiamy ogólne reguły określające, czy warto utworzyć funkcję, czy wielometodę.  Jeśli funkcja tworzy gałęzie na podstawie typu (lub wielu typów), rozważ zastosowanie wielometody.  Typem może być cokolwiek. Nie muszą to być klasy Javy lub pola danych.  Interpretacja wartości używanej do wybierania metody zdefiniowanej za pomocą instrukcji defmethod powinna być możliwa bez zaglądania do definicji defmulti.  Nie należy stosować wielometod w celu obsługi opcjonalnych argumentów lub rekurencji. Jeśli masz wątpliwości, spróbuj napisać funkcję w obu stylach i wybierz ten bardziej czytelny.

8.6. Podsumowanie Wielometody pozwalają na wybieranie metod według dowolnych kryteriów. Zwykle używa się do tego relacji między typami. Czasem są to typy formalne, na przykład klasy Javy. W innych sytuacjach typy tworzy się doraźnie na podstawie cech obiektów systemu.

242



Programowanie w języku Clojure

Rozdział 9.

Sztuczki z Javą

O

bsługa Javy w języku Clojure z jednej strony daje duże możliwości, a z drugiej — jest prosta. Możliwości wynikają z bogactwa konstrukcji, jakie można wyrazić za pomocą składni Lispa, a także z „lukru składniowego” związanego z Javą. Obsługa Javy jest prosta, ponieważ można pracować na podstawowym poziomie. Kod języka Clojure jest kompilowany do kodu bajtowego, dlatego nie jest potrzebna specjalna warstwa pośrednia przy współdziałaniu z Javą. Clojure w naturalny sposób współdziała z Javą i jej bibliotekami. W standardowym kodzie języka Clojure biblioteki Javy wywołuje się bezpośrednio, bez opakowywania wszystkich możliwych konstrukcji tak, aby wyglądały jak w Lispie. Zaskakuje to wielu początkujących programistów języka Clojure, ale jest bardzo praktyczne. Skoro Java nie jest zepsuta, twórcy języka Clojure nie próbują jej naprawiać. W tym rozdziale pokazujemy, że dostęp do Javy w języku Clojure jest wygodny, elegancki i szybki.  Wywoływanie kodu Javy jest proste i bezpośrednie. Clojure udostępnia rozszerzenia składniowe umożliwiające dostęp do wszystkich elementów kodu Javy — klas, egzemplarzy, konstruktorów, metod i pól. Choć kod Javy zwykle wywołuje się bezpośrednio, można też utworzyć nakładki dla interfejsów API Javy i pisać kod w bardziej funkcyjnym stylu.

244

 



Programowanie w języku Clojure

Clojure — w odróżnieniu od wielu innych dynamicznych języków działających w maszynie JVM — jest szybki. Można korzystać z niestandardowej obsługi typów prostych i tablic, a także z sugestii typów, aby kompilator języka Clojure generował taki sam kod, jaki wygenerowałby kompilator Javy. Obsługa wyjątków w języku Clojure jest łatwa w użyciu. Co więcej, rzadko konieczne jest bezpośrednie obsługiwanie wyjątków. Podstawowe mechanizmy obsługi wyjątków są w języku Clojure takie same jak w Javie. Clojure nie wymaga jednak zarządzania wyjątkami kontrolowanymi (ang. checked exception) i pozwala na łatwe porządkowanie zasobów za pomocą idiomu with-open.

9.1. Obsługa wyjątków W kodzie Javy obsługę wyjątków stosuje się w trzech sytuacjach:  W celu opakowania wyjątków kontrolowanych (jeśli nie znasz tego mechanizmu, zapoznaj się z ramką „Wyjątki kontrolowane”).  Przy stosowaniu bloku finally do porządkowania zasobów nieprzechowywanych w pamięci (na przykład uchwytów do plików i połączeń sieciowych).  W reakcji na problemy. Można na przykład zignorować wyjątek, ponowić operację, przekształcić wyjątek na dozwolony wynik i tak dalej. W języku Clojure mechanizmy wyglądają podobnie, ale są prostsze. Konstrukcje specjalne try i throw zapewniają wszystkie możliwości instrukcji try, catch, finally i throw Javy. Nie należy jednak nadużywać wspomnianych konstrukcji. Wynika to z następujących powodów:  W języku Clojure nie trzeba zarządzać kontrolowanymi wyjątkami.  Do hermetyzowania procesu porządkowania zasobów można stosować makra w rodzaju with-open. Zobaczmy, jak obsługa wyjątków wygląda w praktyce.

Prosta obsługa wyjątków W programach w Javie kontrolowane wyjątki często są obsługiwane na granicach między poszczególnymi abstrakcjami. Dobry przykład pochodzi z narzędzia Apache Ant, gdzie niskopoziomowe wyjątki (na przykład wejścia-wyjścia) są umieszczane w wyjątkach procesu budowania z poziomu Anta.

Rozdział 9. • Sztuczki z Javą



245

// Kod w stylu Anta (uproszczony ze względu na przejrzystość). try { newManifest = new Manifest(r); } catch (IOException e) { throw new BuildException(...); }

W języku Clojure nie trzeba zarządzać kontrolowanymi wyjątkami. Nie trzeba ich przechwytywać i deklarować ich zgłaszania. Dlatego poprzedni fragment można zapisać w następujący sposób: (Manifest. r)

Brak nakładek na wyjątki sprawia, że standardowy kod w języku Clojure jest łatwiejszy do zrozumienia, napisania i pielęgnowania niż standardowy kod w Javie. Nic jednak nie stoi na przeszkodzie, aby także w języku Clojure bezpośrednio przechwytywać wyjątki, tworzyć nakładki na nie i ponownie zgłaszać wyjątki. Po prostu nie jest to konieczne. Należy jednak przechwytywać wyjątki, jeśli program ma reagować na nie w określony sposób. Kontrolowane wyjątki Kontrolowane wyjątki w Javie trzeba bezpośrednio przechwytywać lub ponownie zgłaszać w każdej metodzie, w której występują. Na pozór wydaje się to dobrym pomysłem. Kontrolowane wyjątki pozwalają wykorzystać system typów i precyzyjnie udokumentować obsługę błędów, a także wymuszać kontrolę kodu przez kompilator. Jednak większość programistów Javy uważa obecnie kontrolowane wyjątki za nieudany eksperyment, ponieważ prowadzą one do nadmiernego zwiększania się ilości kodu i problemów z pielęgnacją, co przeważa nad zaletami. Więcej o historii wyjątków kontrolowanych dowiesz się z artykułu Roda Waldhoffa1 i zamieszczonych w nim odnośników.

Porządkowanie zasobów Mechanizm przywracania pamięci porządkuje znajdujące się w niej zasoby. Jednak zasoby używane poza pamięcią, dla której działa ten mechanizm (na przykład uchwyty do plików), muszą zostać usunięte ręcznie — nawet po wystąpieniu wyjątku. W Javie standardowo odpowiada za to blok finally. Jeśli zwalniane zasoby mają metodę close (jej dodawanie należy do dobrych praktyk), można użyć makra with-open języka Clojure. 1

http://tinyurl.com/checked-exceptions-mistake

246



Programowanie w języku Clojure

(with-open [name init-form] & body)

Wewnętrznie makro with-open tworzy blok try, ustawia name na wynik wywołania init-form, a następnie uruchamia konstrukcje podane w body. Co najważniejsze, makro with-open zawsze zamyka w bloku finally obiekt powiązany z nazwą name. Dobrym przykładem zastosowania makra with-open jest funkcja spit z biblioteki clojure.core. (clojure.core/spit file content)

Funkcja spit zapisuje łańcuch znaków do pliku. Wypróbujmy następującą instrukcję: (spit "hello.out" "Witaj, świecie") -> nil

Powinien pojawić się plik hello.out z tekstem Witaj,

świecie.

Kod funkcji spit jest prosty. ; Z biblioteki clojure.core. (defn spit "Opposite of slurp. Opens f with writer, writes content, then closes f. Options passed to clojure.java.io/writer." {:added "1.2"} [f content & options] (with-open [^java.io.Writer w (apply jio/writer f options)] (.write w (str content))))

Funkcja spit tworzy obiekt PrintWriter dla f. Jako f można podać cokolwiek, co umożliwia zapis — plik, adres URL, identyfikator URI lub dowolny z różnych programów sterowania wyjściem lub strumieni wyjścia Javy. Następnie funkcja wyświetla content za pomocą obiektu writer. Makro with-open gwarantuje zamknięcie obiektu writer w końcowej części funkcji spit. Aby w bloku finally wykonać operacje inne niż close, można użyć konstrukcji try, która w języku Clojure wygląda tak: (try expr* catch-clause* finally-clause?) ; catch-clause -> (catch classname name expr*) ; finally-clause -> (finally expr*)

Poniżej pokazujemy, jak stosować tę konstrukcję. (try (throw (Exception. "Wystąpił problem")) (finally (println "Trzeba uporządkować zasoby"))) | Trzeba uporządkować zasoby -> java.lang.Exception: something failed

W tym fragmencie pokazaliśmy też, jak używać konstrukcji throw z języka Clojure. Konstrukcja ta zgłasza dowolny przekazany do niej wyjątek.

Rozdział 9. • Sztuczki z Javą



247

Reagowanie na wyjątki Najciekawsza jest sytuacja, kiedy metoda obsługi wyjątku próbuje zareagować na problem w bloku catch. W ramach prostego przykładu napiszmy funkcję, która sprawdza, czy w czasie wykonywania programu dostępna jest pewna klasa. src/examples/interop.clj

; Niewygodne dla jednostki wywołującej funkcję. (defn class-available? [class-name] (Class/forName class-name))

Technika ta jest niewygodna dla jednostki wywołującej funkcję. Jednostka ta oczekuje odpowiedzi typu tak/nie, jednak zamiast tego otrzymuje wyjątek. (class-available? "borg.util.Assimilate") -> java.lang.ClassNotFoundException: borg.util.Assimilate

Lepsze podejście polega na zwróceniu wartości false w bloku catch. src/examples/interop.clj

(defn class-available? [class-name] (try (Class/forName class-name) true (catch ClassNotFoundException _ false)))

Teraz jednostka wywołująca otrzymuje dużo wygodniejsze dane. (class-available? "borg.util.Assimilate") -> false (class-available? "java.lang.String") -> true

Clojure udostępnia wszystkie mechanizmy potrzebne do zgłaszania i przechwytywania wyjątków oraz zwalniania zasobów. Jednocześnie w języku Clojure wyjątki mają ściśle określoną rolę. Są ważne, ale nie na tyle, aby zdominować podstawowy kod. Clojure zaprojektowano tak, aby można było wykonać potrzebne operacje, a przy tym dobrze się bawić. Jednak ważnym aspektem realizowania zadań jest możliwość wykorzystania pełnego potencjału platformy. W pozostałych rozdziałach książki omawiamy wykonywanie operacji w sposób charakterystyczny dla języka Clojure. Tu jednak stosujemy styl Javy. Aby umożliwić wykorzystanie wszystkich możliwości Javy, w języku Clojure zastosowano kilka mechanizmów.

248

Programowanie w języku Clojure

 









Stosowane w odpowiednich miejscach sugerowane typy i wnioskowanie typów pozwalają uzyskać wydajność, którą część osób (niesłusznie) uważa za typową dla języków ze statyczną kontrolą typów. Kompilacja AOT (ang. ahead of time) umożliwia programom języka Clojure pełnoprawne uczestnictwo w opartym na binariach ekosystemie Javy. Choć warto stosować instrukcje reify, defrecord i deftype z uwagi na swobodę, jaką zapewniają, Clojure udostępnia też konstrukcje do współdziałania z Javą, dające dostęp do mniej eleganckich mechanizmów Javy. W języku Clojure (i projekcie Clojure Contrib) szybko pojawiają się nowe gotowe biblioteki do wykonywania standardowych zadań. Jednak, jak zawsze, można też wywoływać biblioteki Javy, jeśli biblioteki języka Clojure nie obejmują potrzebnych rozwiązań. Jeśli techniki przedstawione w tym rozdziale wydają Ci się nieeleganckie lub zbędne, to świetnie! Nie musisz ich stosować. Jeśli jednak potrzebujesz maksymalnej wydajności lub współdziałania z dawnymi, nieeleganckimi bibliotekami, w tym rozdziale znajdziesz coś dla siebie.

9.2. Zmagania z liczbami całkowitymi Clojure udostępnia trzy różne zbiory operacji na typach całkowitoliczbowych. Do ich wykonywania służą następujące operatory:  operatory bez sprawdzania przepełnienia (ang. unchecked operator),  operatory domyślne,  operatory automatycznie zmieniające typ (ang. promoting operator). W poniższej tabeli przedstawiamy przykładowe operatory każdego z tych rodzajów. Bez sprawdzania przepełnienia

Domyślny

Z automatyczną zmianą typu

unchecked-add

+

+'

unchecked-subtract

-

-'

unchecked-multiply

*

*'

unchecked-inc

inc

inc'

unchecked-dec

dec

dec'

Rozdział 9. • Sztuczki z Javą



249

Operatory bez sprawdzania przepełnienia odpowiadają prostym operacjom matematycznym Javy. Są szybkie, ale bardzo niebezpieczne, ponieważ mogą prowadzić do niezauważalnego przepełnienia i zwrócić nieprawidłowy wynik. W języku Clojure operatory bez sprawdzania przepełnienia należy stosować tylko w rzadkich sytuacjach, kiedy przepełnienie jest dopuszczalne lub kiedy wydajność ma bardzo duże znaczenie, a przepełnienie jest niemożliwe lub nieistotne. (unchecked-add 9223372036854775807 1) -> -9223372036854775808

Operatory domyślne wszędzie tam, gdzie to możliwe, używają prostych operacji Javy (ze względu na wydajność), jednak zawsze sprawdzają, czy nie wystąpiło przepełnienie, i zgłaszają wyjątek. (+ 9223372036854775807 1) -> ArithmeticException integer overflow

Operatory zmieniające typ po wykryciu przepełnienia automatycznie przekształcają wartość z typu prostego na typ dla dużych liczb. Umożliwia to obsługę dowolnych wartości, ale kosztem istotnego spadku wydajności. Ponieważ typy proste i duże liczby nie mają wspólnego typu bazowego, operatory zmieniające typ najpierw zwracają wartość typu prostego. (+' 9223372036854775807 1) -> 9223372036854775808N

W języku Clojure liczby dziesiętne o dowolnej precyzji zapisywane są za pomocą klasy BigDecimal Javy. Szczegółowe omówienie tego typu znajdziesz w dokumentacji dostępnej w internecie2. Klasa BigDecimal zapewnia dowolną precyzję, ale pewnym kosztem — działa znacznie wolniej niż proste typy zmiennoprzecinkowe Javy. W Clojure do obsługi konwersji z typu BigInteger służy klasa BigInt. Korzystanie z niej zamiast bezpośrednio z klasy BigInteger Javy pozwala przyspieszyć pracę kodu. Klasa BigInt obsługuje też pewne skomplikowane aspekty klasy BigInteger, na przykład obejmuje poprawną implementację funkcji hashCode. Pozwala ona szybko sprawdzać równość wartości i jest używana w prawie każdej abstrakcji w języku. Na zapleczu w języku Clojure używa się klasy BigInteger Javy. Różnice w wydajności wynikają z traktowania wartości w typie BigInt. Typ ten obejmuje część Long i część BigInteger. Jeśli wartość przekazana do obiektu BigInt jest na tyle mała, aby traktować ją jako liczbę typu Long, przypisywany jest jej ten właśnie typ. 2

http://docs.oracle.com/javase/6/docs/api/java/math/BigDecimal.html

250



Programowanie w języku Clojure

Podobnie dzieje się, kiedy wynik operacji na obiektach BigInt jest na tyle mały, że można go zapisać jako wartość typu Long. Dzięki temu użytkownik może dodać wskazówkę zapobiegającą przepełnieniu (N), a jednocześnie nie musi ponosić kosztów korzystania z typu BigInteger, jeśli nie jest to konieczne.

9.3. Optymalizowanie wydajności W języku Clojure typowe jest wywoływanie Javy za pomocą technik opisanych w podrozdziale 2.5, „Wywoływanie kodu Javy”. W 90% sytuacji uzyskany kod jest wystarczająco szybki. Jednak jeśli to konieczne, można wprowadzić zmiany, aby zwiększyć wydajność. Modyfikacje te nie zmieniają sposobu wywoływania kodu przez zewnętrzne jednostki, dlatego można najpierw napisać działającą wersję programu, a potem przyspieszyć jego pracę.

Używanie typów prostych w celu poprawy wydajności W kodzie z poprzednich podrozdziałów nie określaliśmy typów parametrów funkcji. Clojure po prostu wybiera właściwy typ. Zdaniem niektórych programistów jest to zaleta, natomiast według innych — wada. Zaletą jest to, że kod jest przejrzysty i prosty. Ponadto można wykorzystać mechanizm duck typing (czyli określanie typu na podstawie udostępnianych przez niego metod). Wadą jest to, że osoba czytająca kod nie może mieć pewności co do typów danych, a wybieranie właściwych typów wiąże się ze spadkiem wydajności. Przyjrzyjmy się funkcji obliczającej sumę liczb z przedziału od 1 do n. ; To tylko ilustracja wydajności; nie należy pisać kodu w ten sposób. (defn sum-to [n] (loop [i 1 sum 0] (if ( 55

Zobaczmy, jaka jest wydajność funkcji sum-to. Aby zmierzyć czas jej działania, można wykorzystać funkcję time. Zwykle warto przeprowadzić kilka pomiarów, aby wyeliminować koszty początkowe i skrajne przypadki. Dlatego funkcję time warto wywołać w makrze dotimes. (dotimes bindings & body)

Rozdział 9. • Sztuczki z Javą



251

Makro dotimes wykonuje wielokrotnie kod argumentu body, a nazwy przebiegów wiązane są z liczbami całkowitymi z przedziału od 0 do n-1. Za pomocą makra dotimes można pięciokrotnie zmierzyć czas wykonania funkcji sum-to. (dotimes [_ | "Elapsed | "Elapsed | "Elapsed | "Elapsed -> "Elapsed

5] (time (sum-to 10000))) time: 0.149 msecs" time: 0.126 msecs" time: 0.194 msecs" time: 0.279 msecs" time: 0.212 msecs"

Aby przyspieszyć pracę funkcji, można podać long jako sugerowany typ argumentu i zwracanej wartości. Mechanizm wnioskowania typów języka Clojure wykorzystuje taką wskazówkę we wszystkich wewnętrznych operacjach i wywołaniach w ramach danej funkcji. (defn ^long integer-sum-to [^long n] (loop [i 1 sum 0] (if ( "Elapsed

5] (time (integer-sum-to 10000))) time: 0.044 msecs" time: 0.023 msecs" time: 0.025 msecs" time: 0.023 msecs" time: 0.02 msecs"

Proste operacje matematyczne w Clojure nadal działają prawidłowo, ponieważ sprawdzają, czy nie wystąpiło przepełnienie, i zgłaszają wyjątek. Czy można jeszcze bardziej przyspieszyć pracę kodu? Programiści Javy mają dostęp do niezwykle szybkich operacji arytmetycznych, które zapewniają maksymalną wydajność, ale mogą niezauważalnie prowadzić do przepełnienia i zwracać błędne dane. W języku Clojure dostęp do składni operacji arytmetycznych Javy można uzyskać za pomocą grupy funkcji bez sprawdzania przepełnienia. Może uda nam się uzyskać jeszcze wyższą wydajność, używając funkcji unchecked-add, czyli wersji operatora +, która nie sprawdza przepełnienia? (defn ^long unchecked-sum-to [^long n] (loop [i 1 sum 0] (if ( "Elapsed

5] (time (unchecked-sum-to 10000))) time: 0.039 msecs" time: 0.018 msecs" time: 0.014 msecs" time: 0.015 msecs" time: 0.015 msecs"

Liczą się rzędy wielkości. Proste sugestie typu pozwalają znacznie przyspieszyć niektóre operacje. Jednak pochodzące z Javy pomijanie sprawdzania przepełnienia zwykle nie jest przydatne. Pozwala uzyskać niewielką poprawę wydajności, jednak może prowadzić do otrzymania błędnych danych. Dlaczego więc w języku Clojure w ogóle dostępne są takie operacje? Wynika to z dwóch powodów:  Czasem potrzebna jest składnia Javy. Operacje bez sprawdzania przepełnienia są potrzebne przede wszystkim do współdziałania z innymi bibliotekami, które oczekują tego podejścia.  Bez wypróbowania tych operacji nikt nie wiedziałby (ani nie uwierzył), że nie są dużo szybsze od innych podejść. Na pierwszym miejscu należy stawiać poprawność, a dopiero potem optymalizować kod pod kątem wydajności (jeśli jest to konieczne). Funkcja integer-sum-to zgłasza wyjątek przepełnienia. To źle, jednak pozwala łatwo wykryć problem. (integer-sum-to 10000000000) -> java.lang.ArithmeticException: integer overflow

Funkcja unchecked-sum-to niezauważalnie akceptuje przepełnienie. W programie może prowadzić to do niewykrytego, ale bardzo szkodliwego w skutkach uszkodzenia danych. (unchecked-sum-to 10000000000) -> -5340232216128654848 ; ŹLE!

Jeśli masz do wyboru poprawność i wydajność, powinieneś preferować prosty kod bez dodatków, taki jak w pierwotnej funkcji sum-to. Jeżeli w trakcie profilowania wykryjesz wąskie gardło, możesz wymusić stosowanie w odpowiednich miejscach typów prostych. Przykład z funkcją sum-to jest celowo prosty. Pozwala zademonstrować różne sposoby wykonywania operacji matematycznych w języku Clojure. W prawdziwym programie języka Clojure lepszym podejściem byłoby zaimplementowanie funkcji sum-to z wykorzystaniem instrukcji reduce. Sumowanie sekwencji polega na zsumowaniu dwóch pierwszych elementów, dodaniu wyniku do następnego elementu itd. Właśnie tak działa pętla tworzona przez wyrażenie (reduce + ...). Za pomocą instrukcji reduce funkcję sum-to można zapisać w jednym wierszu.

Rozdział 9. • Sztuczki z Javą



253

(defn better-sum-to [n] (reduce + (range 1 (inc n))))

Przykład ten ilustruje też bardziej ogólną kwestię — warto od razu wybrać odpowiedni algorytm. Sumę wartości z przedziału od 1 do n można bezpośrednio obliczyć w następujący sposób: (defn best-sum-to [n] (/ (* n (inc n)) 2))

Wersja ta, nawet bez sugestii typów, jest szybsza niż implementacje oparte na powtarzaniu dodawania. (dotimes [_ | "Elapsed | "Elapsed | "Elapsed | "Elapsed -> "Elapsed

5] (time (best-sum-to 10000))) time: 0.029 msecs" time: 0.0040 msecs" time: 0.0040 msecs" time: 0.0040 msecs" time: 0.0030 msecs"

Wydajność to skomplikowany temat. Nie warto pisać nieeleganckiego kodu w dążeniu do wyższej wydajności. Lepiej jest wybrać odpowiednie algorytmy i tworzyć poprawny kod. Jeśli występują problemy z wydajnością, należy przeprowadzić profilowanie i zidentyfikować je. Następnie można wprowadzić tylko tyle skomplikowanych mechanizmów, ile potrzeba do rozwiązania wykrytych problemów.

Dodawanie sugestii typów Clojure umożliwia dodawanie sugestii typów do parametrów funkcji, wiązań w instrukcji let, nazw zmiennych i wyrażeń. Sugestie te odgrywają trzy role:  pozwalają zoptymalizować fragmenty kodu najważniejsze ze względu na wydajność,  służą do dokumentowania wymaganych typów,  umożliwiają wymuszanie stosowania wymaganych typów w czasie wykonywania programu. Rozważmy na przykład poniższą funkcję, która zwraca informacje o klasie Javy. (defn describe-class [c] {:name (.getName c) :final (java.lang.reflect.Modifier/isFinal (.getModifiers c))})

Można określić sposób wnioskowania informacji o typie przez Clojure. W tym celu należy ustawić specjalną zmienną *warn-on-reflection* na true. (set! *warn-on-reflection* true) -> true

254



Programowanie w języku Clojure

Wykrzyknik w instrukcji set! to zwyczajowe określenie, że set! modyfikuje zmienny stan. Instrukcję set! opisujemy szczegółowo w punkcie „Korzystanie z interfejsów API Javy opartych na wywołaniach zwrotnych”. Po ustawieniu *warn-on-reflection* na true w procesie kompilowania funkcji describe-class pojawiają się ostrzeżenia. Reflection warning, line: 87 - reference to field getName can't be resolved. Reflection warning, line: 88 - reference to field getModifiers can't be resolved.

Ostrzeżenia te określają, że Clojure nie może ustalić typu argumentu c. Można zasugerować typ, aby rozwiązać ten problem. Umożliwia to składnia pobierania metadanych — ^Class. (defn describe-class [^Class c] {:name (.getName c) :final (java.lang.reflect.Modifier/isFinal (.getModifiers c))})

Po dodaniu sugestii typu ostrzeżenie związane z mechanizmem refleksji znika. Skompilowany kod języka Clojure jest identyczny ze skompilowanym kodem Javy. Ponadto próby wywołania funkcji describe-class dla obiektu innego niż Class spowodują zgłoszenie wyjątku ClassCastException. (describe-class StringBuffer) {:name "java.lang.StringBuffer", :final true} (describe-class "foo") -> java.lang.ClassCastException: \ java.lang.String cannot be cast to java.lang.Class

Jeśli na Twoim komputerze wyjątek ClassCastException powoduje wyświetlenie mniej przydatnego komunikatu o błędzie, wynika to z tego, że używasz wersji Javy starszej niż 6. Ulepszone komunikaty o błędach to jeden z wielu dobrych powodów na uruchamianie kodu języka Clojure za pomocą Javy 6 lub nowszej. Jeśli programista stosuje sugestię typu, Clojure dodaje odpowiednie rzutowanie na klasę, aby uniknąć wolnych wywołań metod Javy, wymagających używania mechanizmu refleksji. Jeżeli jednak funkcja nie wywołuje żadnych metod Javy dla obiektu, dla którego zasugerowano typ, Clojure nie dodaje rzutowania. Przyjrzyjmy się funkcji wants-a-string. (defn wants-a-string [^String s] (println s)) -> #'user/wants-a-string

Rozdział 9. • Sztuczki z Javą



255

Można oczekiwać, że funkcja wants-a-string zgłosi problem, jeśli argument nie będzie łańcuchem znaków. Dzieje się jednak inaczej. (wants-a-string "foo") -> foo (wants-a-string 0) -> 0

Clojure potrafi wykryć, że funkcja wants-a-string nie używa argumentu jako łańcucha znaków (instrukcja println próbuje wyświetlić argument dowolnego typu). Ponieważ nie trzeba wywoływać żadnej funkcji przeznaczonej dla łańcuchów znaków, Clojure nie próbuje zrzutować argumentu s na taki łańcuch. Kiedy potrzebna jest wydajność, sugestie typów pozwalają skompilować kod język Clojure do takiego samego kodu, jaki jest generowany dla Javy. Jednak sugestie typów nie są potrzebne zbyt często. Najpierw należy napisać poprawny kod, a potem martwić się o jego wydajność.

9.4. Tworzenie klas Javy w języku Clojure Wszystkie obiekty języka Clojure obejmują implementację przydatnych interfejsów Javy.  W strukturach danych języka Clojure zaimplementowane są interfejsy z interfejsu API Collections Javy.  W funkcjach języka Clojure zaimplementowane są interfejsy Runnable i Callable. Oprócz tych uniwersalnych interfejsów czasem potrzebne są interfejsy charakterystyczne dla dziedziny. Często mają one postać metod obsługi wywołań zwrotnych dla zdarzeń zgłaszanych przez interfejsy API (na przykład framework Swing lub niektóre parsery XML-owych danych). Clojure w razie potrzeby może łatwo generować na dysku „jednorazowych” pośredników lub klasy. Efekt ten można uzyskać za pomocą znacznie mniejszej liczby wierszy niż w Javie.

Tworzenie pośredników dla kodu Javy Współdziałanie z kodem Javy często wymaga implementowania interfejsów tego języka. Dobrym przykładem jest przetwarzanie XML-owych danych za pomocą parserów SAX (ang. Simple API for XML). Aby przygotować system na ten przykład, należy zaimportować wymienione dalej klasy. Będą one potrzebne w dalszym kodzie.

256



Programowanie w języku Clojure

(import '(org.xml.sax InputSource) '(org.xml.sax.helpers DefaultHandler) '(java.io StringReader) '(javax.xml.parsers SAXParserFactory))

Aby zastosować parser SAX, należy zaimplementować mechanizm wywołań zwrotnych. Najłatwiej zrobić to przez rozszerzenie klasy DefaultHandler. W języku Clojure do rozszerzania klas służy funkcja proxy. (proxy class-and-interfaces super-cons-args & fns)

W ramach prostego przykładu użyjmy instrukcji proxy do utworzenia obiektu DefaultHandler, który wyświetla informacje o wszystkich wywołaniach metody startElement. (def print-element-handler (proxy [DefaultHandler] [] (startElement [uri local qname atts] (println (format "Wykryto element: %s" qname)))))

Instrukcja proxy generuje egzemplarz klasy pośredniczącej. Pierwszym argumentem funkcji proxy jest [DefaultHandler] (jest to wektor z klasą bazową i interfejsami bazowymi). Drugi argument, [], to wektor argumentów dla konstruktora klasy bazowej. Tu takie argumenty nie są potrzebne. Po kodzie konfigurującym pośrednika znajduje się implementacja jego metod. Omawiany tu pośrednik obejmuje jedną metodę. Jej nazwa to startElement. Metoda ta przyjmuje cztery argumenty i wyświetla nazwę (argument qname). Teraz wystarczy przekazać do obiektu obsługi parser. Wymaga to zastosowania szeregu metod fabrycznych i konstruktorów Javy. Na potrzeby krótkich testów w środowisku REPL można utworzyć funkcję, która przetwarza XML-owe dane podane w łańcuchu znaków. (defn demo-sax-parse [source handler] (.. SAXParserFactory newInstance newSAXParser (parse (InputSource. (StringReader. source)) handler)))

Teraz przetwarzanie jest proste. (demo-sax-parse " Body of bar " print-element-handler) | Wykryto element: foo | Wykryto element: bar

W przykładzie tym pokazaliśmy, jak w języku Clojure utworzyć pośrednika do współdziałania z XML-owymi interfejsami Javy. Podobne podejście można zastosować do zaimplementowania własnych interfejsów Javy. Jeśli jednak program ma tylko przetwarzać dane w formacie XML, wystarczy użyć biblioteki

Rozdział 9. • Sztuczki z Javą



257

clojure.data.xml, która zapewnia znakomitą obsługę XML-a i współdziała z dowolnym parserem SAX Javy. Mechanizm oparty na pośrednikach jest w pełni ogólny i można go wykorzystać do generowania „w locie” dowolnego rodzaju obiektów Javy. Czasem obiekty te są tak proste, że można je utworzyć w jednym wierszu. Poniższy kod tworzy nowy wątek, a następnie generuje nową dynamiczną podklasę klasy Runnable i używa jej do uruchomienia nowego wątku. (.start (Thread. (proxy [Runnable] [] (run [] (println "Działam!")))))

W Javie trzeba napisać każdą metodę każdego implementowanego interfejsu. W języku Clojure takie metody można pominąć. (proxy [Callable] []) ; Pośrednik bez metod.

Jeśli pominiesz implementację metody, Clojure udostępni implementację domyślną, która zgłasza wyjątek UnsupportedOperationException. (.call (proxy [Callable] [])) -> java.lang.UnsupportedOperationException: call

Stosowanie implementacji domyślnej w interfejsach o tylko jednej metodzie, takich jak Runnable i Callable, nie ma sensu, jednak technika ta może okazać się przydatna przy implementowaniu większych interfejsów, w których niektóre metody są niepotrzebne. W podrozdziale tym pokazaliśmy już, jak używać instrukcji proxy do implementowania interfejsów Javy. Technika ta daje bardzo duże możliwości, jednak często Clojure udostępnia gotowe implementacje. Na przykład w funkcjach automatycznie implementowane są interfejsy Runnable i Callable. ; Normalne stosowanie – wywołanie funkcji anonimowej. (#(println "foo")) foo ; Wywołanie za pomocą metody run interfejsu Runnable. (.run #(println "foo")) foo ; Wywołanie za pomocą metody call interfejsu Callable. (.call #(println "foo")) foo

Pozwala to na bardzo łatwe przekazywanie funkcji języka Clojure do innych wątków. (dotimes [i 5] (.start (Thread. (fn [] (Thread/sleep (rand 500)) (println (format "Wywołanie %d w %s" i (Thread/currentThread)))))))

258



Programowanie w języku Clojure

W kontekście wykonywania jednorazowych zadań, takich jak przetwarzanie XML-owych danych lub obsługa wywołań zwrotnych od wątków, pośrednicy języka Clojure to szybkie i łatwe w użyciu rozwiązanie. Czasem jednak potrzebne są dłużej istniejące klasy. W języku Clojure można je także wygenerować.

Korzystanie z kolekcji Javy W większości sytuacji zamiast kolekcji Javy stosuje się kolekcje języka Clojure. Te ostatnie są wydajne i bezpieczne ze względu na współbieżność, a także obejmują implementację odpowiednich interfejsów kolekcji Javy. Dlatego przy korzystaniu z języka Clojure zwykle należy stosować kolekcje z tego języka (tam, gdzie to wygodne, można je nawet przekazywać do kodu Javy). Jeśli jednak chcesz stosować kolekcje Javy, Clojure to umożliwia. W języku Clojure kolekcje Javy są zwykłymi klasami, dlatego można stosować wszystkie techniki współdziałania z kodem Javy. Jednak kolekcje Javy są zaprojektowane z myślą o współbieżności opartej na blokadach. Nie zapewniają gwarancji z obszaru współbieżności typowych dla kolekcji języka Clojure i nie współdziałają dobrze z pamięcią STM z tego języka. Jedną z sytuacji, w których należy używać kolekcji Javy, jest korzystanie z tablic Javy. W Javie dla tablic używa się specyficznej składni i kompiluje się je do specjalnych instrukcji w kodzie bajtowym. W tablicach Javy nie jest zaimplementowany żaden interfejs tego języka. Dlatego nie da się podstawić kolekcji języka Clojure (ani nawet języka Java!) zamiast tablic. W Javie tablice traktowane są w specjalny sposób, dlatego tak samo jest w języku Clojure. Clojure udostępnia funkcję make-array, przeznaczoną do tworzenia tablic Javy. (make-array class length) (make-array class dim & more-dims)

Funkcja make-array przyjmuje klasę i dowolną liczbę wymiarów tablicy. Jednowymiarową tablicę łańcuchów znaków można zdefiniować tak: (make-array String 5) -> #

Dziwne dane wyjściowe wynikają z używanej w Javie implementacji funkcji toString() dla tablic. Człon [Ljava.lang.String; w maszynie JVM oznacza jednowymiarową tablicę łańcuchów znaków. W środowisku REPL nie jest to przydatne, dlatego warto za pomocą instrukcji seq języka Clojure opakować tablicę Javy w sekwencję języka Clojure. Wtedy środowisko REPL wyświetli poszczególne elementy tablicy.

Rozdział 9. • Sztuczki z Javą



259

(seq (make-array String 5)) -> (nil nil nil nil nil)

Język Clojure obejmuje też rodzinę funkcji o nazwach w rodzaju int-array. Funkcje te służą do tworzenia prostych typów Javy. W środowisku REPL można wpisać poniższe polecenie, aby wyświetlić dokumentację dotyczącą tej i innych funkcji związanych z tablicami. (find-doc "-array")

Clojure udostępnia zestaw niskopoziomowych operacji na tablicach Javy. Operacje te to między innymi aset, aget i alength. (aset java-array index value) (aset java-array index-dim1 index-dim2 ... value) (aget java-array index) (aget java-array index-dim1 index-dim2 ...) (alength java-array)

Można utworzyć tablicę za pomocą instrukcji bować funkcje aset, aget i alength.

make-array,

a następnie wypró-

(defn painstakingly-create-array [] (let [arr (make-array String 5)] (aset arr 0 "Kłopotliwy") (aset arr 1 "sposób") (aset arr 2 "na") (aset arr 3" zapełnienie") (aset arr 4 "tablicy") arr)) (aget (paintakingly-create-array) 0) -> "Kłopotliwy" (alength (painstakingly-create-array)) -> 5

Zwykle łatwiej jest używać wysokopoziomowych funkcji, takich jak które tworzą tablice na podstawie dowolnej kolekcji.

to-array,

(to-array sequence)

Funkcja to-array zawsze tworzy tablicę obiektów typu Object. (to-array ["Easier" "array" "creation"]) -> #

Funkcja to-array jest też przydatna do wywoływania metod Javy przyjmujących dowolnie długą listę argumentów. Dotyczy to na przykład metody String/format. ; Przykład. Lepszy sposób: clojure.core/format (String/format "Tydzień treningów: %s. Kilometry: %d." (String/format "Tydzień treningów: %s. Kilometry: %d." (to-array [2 26])) -> "Tydzień treningów: 2. Mileage: 26."

260

Programowanie w języku Clojure



Podobna do to-array funkcja o typie innym niż Object.

into-array

pozwala tworzyć tablice obiektów

(into-array type? seq)

W pierwszym, opcjonalnym argumencie funkcji into-array można określić typ. (into-array String ["Łatwiejsze", "tworzenie", "tablic"]) -> #

Jeśli pominiesz argument określający typ, funkcja stawie pierwszego elementu sekwencji.

into-array

ustali typ na pod-

(into-array ["Łatwiejsze" "tworzenie" "tablic"]) -> #

Jak widać, tablica obejmuje obiekty typu String, a nie Object. Aby przekształcić każdy element tablicy Javy bez konwersji na sekwencję języka Clojure, można zastosować makro amap. (amap a idx ret expr)

Makro amap powiela tablicę a i wiąże ją z nazwą ret. Następnie wykonuje wyrażenie expr dla każdego elementu tablicy a, przy czym nazwa idx wiązana jest z indeksem przetwarzanego elementu. W ostatnim kroku makro zwraca powieloną tablicę. Za pomocą makra amap można zmienić litery w każdym łańcuchu z tablicy na wielkie. (def strings (into-array ["kilka" "łańcuchów" "znaków"])) -> #'user/strings (seq (amap strings idx _ (.toUpperCase (aget strings idx)))) -> ("KILKA" "ŁAŃCUCHÓW" "ZNAKÓW")

Jako parametr ret podaliśmy wartość _, aby określić, że nie jest potrzebna w wyrażeniu odwzorowującym. Nakładka w postaci sekwencji pozwala wygodnie wyświetlić efekt przekształcenia w środowisku REPL. Podobnie do makra amap działa makro areduce. (areduce a idx ret init expr)

Makro amap tworzy nową tablicę, natomiast makro areduce generuje dowolną strukturę. Początkowa wartość ret to init. Później parametr ret ustawiany jest na wartość zwróconą przez kolejne wywołania wyrażenia expr. Makro areduce zwykle służy do pisania funkcji, które w pewien sposób „podsumowują” kolekcję. Na przykład poniższe wywołanie określa długość najdłuższego łańcucha znaków z tablicy strings. (areduce strings idx ret 0 (max ret (.length (aget strings idx)))) -> 7

Makra amap i areduce to specjalne makra umożliwiające korzystanie z tablic Javy.

Rozdział 9. • Sztuczki z Javą



261

9.5. Praktyczny przykład Choć omówienie różnych sposobów korzystania z kodu Javy i zwiększania wydajności za pomocą prostych konstrukcji z tego języka jest ciekawe, przyda Ci się także praktyczna wiedza. W tym miejscu zaczynamy rozwijanie aplikacji sprawdzającej dostępność witryn. Celem jest ustalenie, czy witryna zwraca odpowiedź HTTP 200 OK. Jeśli witryna zwraca inny kod, należy uznać ją za niedostępną. Także tu używamy narzędzia Leiningen. Jeśli jeszcze nie zainstalowałeś Leiningena, wróć do podrozdziału 1.2, „Szybkie wprowadzenie do programowania w Clojure”. Zacznijmy od utworzenia nowego projektu. lein new pinger

Otwórz plik project.clj i zmodyfikuj jego zawartość pod kątem dalszych operacji. Zaktualizuj Clojure do najnowszej wersji. (defproject pinger "0.0.1-SNAPSHOT" :description "Program do sprawdzania dostępności witryn" :dependencies [[org.clojure/clojure 1.3.0]])

Pobierz zależności przez wywołanie instrukcji lein

deps.

lein deps

Najpierw trzeba napisać kod, który nawiązuje połączenie z witryną o danym adresie URL i pobiera kod odpowiedzi. Posłużymy się do tego klasą URL Javy. (ns pinger.core (:import (java.net URL HttpURLConnection))) (defn response-code [address] (let [conn ^HttpURLConnection (.openConnection (URL. address)) code (.getResponseCode conn)] (when (< code 400) (-> conn .getInputStream .close)) code))

Wypróbujmy ten kod w środowisku REPL. (response-code "http://google.com") -> 200

Teraz utwórzmy funkcję, która używa funkcji response-code i określa, czy witryna o podanym adresie URL jest dostępna. Predykat available? zwraca true, jeśli kod odpowiedzi to HTTP 200. (defn available? [address] (= 200 (response-code address))) (available? "http://google.com")

262



Programowanie w języku Clojure

-> true (available? "http://google.com/badurl") -> false

Potrzebny jest też sposób na uruchomienie programu, okresowe sprawdzanie listy adresów URL i informowanie o dostępności witryn. Operacje te można umieścić w funkcji –main. (defn -main [] (let [addresses '("http://google.com" "http://amazon.com" "http://google.com/badurl")] (while true (doseq [address addresses] (println (available? address))) (Thread/sleep (* 1000 60)))))

W tym przykładzie tworzymy listę adresów, dwóch poprawnych i jednego błędnego, oraz używamy prostej pętli while, która nigdy nie kończy pracy. Pętla ta do czasu zamknięcia programu co minutę sprawdza adresy URL. Ponieważ eksportujemy funkcję –main, trzeba dodać słowo kluczowe :gen-class do deklaracji przestrzeni nazw. (ns pinger.core (:import (java.net URL)) (:gen-class))

Po przygotowaniu podstawowych elementów można poinformować Leiningena, gdzie znajduje się główna funkcja. Należy otworzyć plik project.clj i dodać deklarację :main. (defproject pinger "0.0.1-SNAPSHOT" :description "Program do sprawdzania dostępności witryn" :dependencies [[org.clojure/clojure "1.3.0"]] :main pinger.core)

Pora skompilować program do pliku JAR i uruchomić go. W tym celu należy wprowadzić następujące instrukcje: lein uberjar java -jar pinger-0.0.1-SNAPSHOT-standalone.jar true false true

Program uruchomi się i będzie kontynuował pracę do czasu wciśnięcia kombinacji Ctrl+C.

Rozdział 9. • Sztuczki z Javą



263

Dodawanie prawdziwej pętli wiecznej Pętla while, której warunek zawsze ma wartość true, działa do czasu zamknięcia programu, jednak nie jest najlepszym rozwiązaniem, ponieważ nie umożliwia eleganckiego zakończenia pracy kodu. W zamian można użyć puli wątków uruchamianych według harmonogramu, która pozwala wywoływać i wykonywać pożądane polecenia w podobny sposób jak pętla while, ale zapewnia znacznie większą kontrolę. Utwórzmy w katalogu src plik scheduler.clj i umieśćmy w nim następujący kod: (ns pinger.scheduler (:import (java.util.concurrent ThreadPoolExecutor ScheduledThreadPoolExecutor TimeUnit))) (defn scheduled-executor "Tworzenie wykonawcy." ^ScheduledThreadPoolExecutor [threads] (ScheduledThreadPoolExecutor. threads)) (defn periodically "Tworzy harmonogram uruchamiania funkcji f w wykonawcy e co 'delay' milisekund po upływie czasu 'initial-delay'. Zwraca obiekt ScheduledFuture." ^ScheduledFuture [e f & {:keys [initial-delay delay]}] (.scheduleWithFixedDelay e f initial-delay delay TimeUnit/MILLISECONDS)) (defn shutdown-executor "Zamykanie wykonawcy." [^ThreadPoolExecutor e] (.shutdown e))

W przedstawionej przestrzeni nazw znajdują się funkcje, które tworzą i zamykają obiekt ScheduledExecutor Javy. Zdefiniowana jest tu także funkcja periodically, która przyjmuje wykonawcę, funkcję, początkowe opóźnienie (initial-delay) i powtarzalne opóźnienie (delay). Za pierwszym razem funkcja jest wykonywana po upływie początkowego opóźnienia, a następnie — po upływie powtarzalnego opóźnienia. Funkcja jest uruchamiana wielokrotnie, do czasu zamknięcia puli wątków. Zmodyfikujmy przestrzeń nazw pinger.core, aby wykorzystać kod tworzący harmonogram wykonywania funkcji. Funkcja –main może odpowiadać tylko za wywoływanie funkcji, która uruchamia pętlę. Zastąpmy dawną funkcję –main funkcjami przedstawionymi poniżej. (defn check [] (let [addresses '("http://google.com" "http://google.com/404" "http://amazon.com")]

264



Programowanie w języku Clojure

(doseq [address addresses] (println (available? address))))) (def immediately 0) (def every-minute (* 60 1000)) (defn start [e] "Funkcja pomocnicza dla REPL. Uruchamia program w wykonawcy e." (scheduler/periodically e check :initial-delay immediately :delay every-minute)) (defn stop [e] "Funkcja pomocnicza dla REPL. Zatrzymuje wykonawcę e." (scheduler/shutdown-executor e)) (defn -main [] (start (scheduler/scheduled-executor 1)))

Należy też zmodyfikować deklarację przestrzeni nazw i dołączyć kod do obsługi harmonogramu wywołań. (ns pinger.core (:import (java.net URL)) (:require [pinger.scheduler :as scheduler]) (:gen-class))

Nie wszystkie elementy przykładu są niezbędne, jednak kod jest czytelny. Funkcje uruchamiające i zatrzymujące program ułatwiają interaktywną pracę w środowisku REPL, co jest bardzo istotną zaletą w momencie rozszerzania przykładu. Sprawdźmy wszystko jeszcze raz. W tym celu należy wywołać instrukcję lein uberjar i uruchomić plik JAR. Program powinien działać dokładnie tak jak wcześniej.

Rejestrowanie informacji Utworzyliśmy program, który potrafi okresowo sprawdzać dostępność witryn z listy. Program nie potrafi jednak śledzić wykonanych informacji i powiadamiać o braku dostępu do witryny. Oba problemy można rozwiązać za pomocą mechanizmu rejestrowania. W aplikacjach Javy można utworzyć go na wiele sposobów. Tu używamy biblioteki log4j. Daje ona dostęp do prawdziwego mechanizmu rejestrowania i pozwala przesyłać powiadomienia na e-mail. Jest to bardzo przydatne, ponieważ umożliwia wysyłanie alertów, kiedy witryna jest niedostępna. Najpierw należy dodać do aplikacji biblioteki log4j i mail. Aby ułatwić sobie korzystanie z biblioteki log4j, należy też dołączyć bibliotekę clojure.tools.logging. Otwórzmy plik project.clj i dodajmy biblioteki clojure. tools.logging, log4j i mail.

Rozdział 9. • Sztuczki z Javą



265

(defproject pinger "0.0.1-SNAPSHOT" :description "Program do sprawdzania dostępności witryn" :dependencies [[org.clojure/clojure "1.3.0"] [org.clojure/tools.logging "0.2.3"] [log4j "1.2.16"] [javax.mail/mail "1.4.1"]] :main pinger.core)

Należy też pobrać zależności za pomocą Leiningena. lein deps

Bardzo wygodną cechą biblioteki rejestrowania informacji dostępnej w języku Clojure jest to, że korzysta ona z dowolnej podanej w ścieżce klas biblioteki Javy służącej do rejestrowania informacji, dlatego nie trzeba dodatkowo wiązać biblioteki log4j z aplikacją. Utwórzmy w katalogu głównym projektu katalog resources. Leiningen automatycznie dodaje zawartość tego katalogu do ścieżki klas. W katalogu tym należy umieścić plik właściwości biblioteki log4j, log4j.properties, i zapisać w nim podany poniżej kod. log4j.rootLogger=info, R, email log4j.appender.R=org.apache.log4j.RollingFileAppender log4j.appender.R.File=logs/pinger.log log4j.appender.R.MaxFileSize=1000KB log4j.appender.R.MaxBackupIndex=1 log4j.appender.R.layout=org.apache.log4j.PatternLayout log4j.appender.R.layout.ConversionPattern=%d{ISO8601} %-5p [%c] - %m%n log4j.appender.email=org.apache.log4j.net.SMTPAppender log4j.appender.email.SMTPHost=localhost [email protected] [email protected] log4j.appender.email.Subject=[Pinger Notification] - Website Down log4j.appender.email.threshold=error log4j.appender.email.layout=org.apache.log4j.PatternLayout log4j.appender.email.layout.conversionPattern=%d{ISO8601} %-5p [%c] - %m%n

Kod ten powoduje rejestrowanie informacji w pliku pinger.log i wysyłanie powiadomień na e-mail przy wykryciu informacji z poziomu error. Tu tego rodzaju informacje są rejestrowane, kiedy witryna wysyła kod odpowiedzi inny niż HTTP 200 lub w trakcie sprawdzania witryny zgłoszony zostaje wyjątek. Dostosuj też do własnych danych kod związany z wysyłaniem wiadomości e-mail. Zmodyfikujmy kod i dodajmy mechanizm rejestrowania. Należy zastąpić wszystkie instrukcje println komunikatami zapisywanymi w dzienniku. W tym celu trzeba otworzyć plik core.clj, dodać funkcje info i error z biblioteki clojure.tools.logging do deklaracji przestrzeni nazw oraz utworzyć funkcję do rejestrowania informacji. (ns pinger.core (:import (java.net URL)) (:require [pinger.scheduler :as scheduler] [clojure.tools.logging :as logger])

266



Programowanie w języku Clojure

(:gen-class)) ... (defn record-availability [address] (if (available? address) (logger/info (str address " działa normalnie")) (logger/error (str address " jest niedostępna"))))

Należy też zmodyfikować funkcję check pod kątem wprowadzonych zmian. (defn check [] (let [addresses '("http://google.com" "http://google.com/404" "http://amazon.com")] (doseq [address addresses] (record-availability address))))

Teraz można ponownie zbudować program i go wypróbować. Powinien pojawić się nowy katalog logs, w którym można sprawdzić efekty działania programu. Ponadto powinieneś otrzymać e-mail z komunikatem o błędzie. Błąd „connection refused” w porcie 25 oznacza, że należy skonfigurować agenta pocztowego na komputerze, aby umożliwić wysyłanie e-maili. Masz już sposób na powiadamianie o awarii witryny!

Konfiguracja W kodzie na stałe zapisana jest lista witryn do sprawdzania. Nie jest to dobre rozwiązanie. Potrzebny jest sposób na pobieranie listy witryn z zewnętrznego źródła. Można użyć do tego pliku właściwości, bazy danych lub usługi sieciowej. Aby uprościć omówienie, korzystamy tu z pliku właściwości. Należy utworzyć plik pinger.properties w katalogu głównym aplikacji i umieścić w pliku przedstawiony poniżej kod. urls=http://google.com,http://amazon.com,http://google.com/badurl

Potrzebny jest sposób na wczytanie tego pliku i utworzenie kolekcji witryn przekazywanej do funkcji check. Utwórzmy w katalogu src plik config.clj. (ns pinger.config (:use [clojure.java.io :only (reader resource)]) (:require [clojure.string :as str]) (:import (java.util Properties))) (defn load-properties [src] (with-open [rdr (reader src)] (doto (Properties.) (.load rdr)))) (defn config [] (load-properties (resource "pinger.properties")))

Rozdział 9. • Sztuczki z Javą



267

Jeśli plik pinger.properties znajduje się w ścieżce klas, funkcja config wczyta jego zawartość do obiektu właściwości Javy. Następnie wystarczy pobrać atrybut urls i zapisać jego wartość na liście. Dodajmy do przestrzeni nazw config przedstawioną poniżej funkcję. (defn urls [conf] (str/split (.get conf "urls") #","))

W ostatnim kroku należy zmodyfikować funkcję stosować nową funkcję konfiguracyjną.

check

z pliku core.clj, aby za-

(ns pinger.core (:import (java.net URL)) (:require [pinger.scheduler :as scheduler] [clojure.tools.logging :as logger] [pinger.config :as config]) (:gen-class)) ... (defn check [] (doseq [address (config/urls (config/config))] (record-availability address)))

Teraz można ponownie zbudować aplikację za pomocą Leiningena i wypróbować ją. Należy pamiętać o umieszczeniu katalogu głównego w ścieżce klas, aby aplikacja potrafiła znaleźć plik pinger.properties. java -cp .:pinger-0.0.1-standalone.jar pinger.core

Wiesz już wszystko, co potrzebne do osiągnięcia sukcesu. W tym przykładzie omówiliśmy następujące zagadnienia:  Używanie klasy URL Javy do sprawdzania dostępności witryny.  Używanie klasy ScheduledThreadPoolExecutor Javy do okresowego uruchamiania zadań.  Używanie biblioteki log4j wraz z biblioteką clojure.tools.logging do wysyłania powiadomień o błędach.  Używanie systemu właściwości Javy do konfigurowania programu.  Używanie Leiningena do tworzenia niezależnych wykonywalnych plików JAR. Opisany program można rozszerzyć, na przykład przez inne zdefiniowanie dostępności witryny; można dodać wymóg, że na stronie muszą znajdować się konkretne elementy HTML lub odpowiedź musi nadejść w określonym czasie, aby spełnione były warunki świadczenia usługi. Spróbuj wzbogacić przykładowy program. Zobacz, co uda Ci się wymyślić.

268



Programowanie w języku Clojure

9.6. Podsumowanie Opisaliśmy dużą część mechanizmów współdziałania języka Clojure i Javy. Połączyliśmy nawet kod obu języków w ciekawy sposób. Ponieważ zaczęliśmy eksperymentować z rozwiązaniami spoza języka Clojure, prawdopodobnie jest to dobry moment na omówienie różnych bibliotek używanych do tworzenia praktycznych aplikacji w języku Clojure. Takie biblioteki są tematem następnego rozdziału.

Rozdział 10.

Tworzenie aplikacji

Z

nasz już podstawy języka Clojure. Pora zacząć korzystać z niego we własnych projektach. Jednak kiedy zaczniesz pracę nad nową fantastyczną aplikacją w języku Clojure, szybko odkryjesz, że znajomość samego języka to za mało, aby skutecznie pracować w związanym z nim środowisku. Pojawiają się następujące pytania:  Za pomocą jakich narzędzi można zarządzać projektami i zależnościami?  Jak wygląda poprawny proces pisania kodu?  Jak można się upewnić, że kod jest prawidłowy?  Jak umożliwić sobie modyfikowanie i pielęgnowanie kodu?  Jakie biblioteki są potrzebne?  Jak stosować Clojure w sieci?

Nie istnieją uniwersalne odpowiedzi na te pytania. Clojure działa w maszynach JVM. Jest to bardzo rozbudowane środowisko, w którym stosuje się setki różnych podejść. Trzeba jednak od czegoś zacząć. W tym rozdziale przedstawiamy tymczasowe odpowiedzi na podane pytania. Opisane tu techniki sprawdziły się dla nas i przez pewien czas posłużą także Tobie. Kiedy lepiej poznasz swoje preferencje, będziesz mógł dostosować rozwiązania przedstawione w tym rozdziale lub zrezygnować z nich na rzecz bardziej odpowiadających Ci podejść.

270



Programowanie w języku Clojure

Przykładowa aplikacja z tego rozdziału to internetowa wersja gry Clojurebreaker. W grze tej autor kodu (program) tworzy niejawny ciąg N kolorowych elementów do odgadnięcia. Osoba łamiąca kod (gracz) próbuje odgadnąć ten ciąg. Program ocenia próbę w następujący sposób:  daje jeden czarny punkt za każdy odgadnięty element we właściwym kolorze i na prawidłowej pozycji;  daje jeden biały punkt za każdy odgadnięty element we właściwym kolorze, ale na nieodpowiedniej pozycji. Gra kończy się po poprawnym odgadnięciu kodu lub po przekroczeniu ustalonego limitu prób. Choć pokazujemy tu cały kod gry, to nie on jest w tym rozdziale najważniejszy. Istotne jest podejście do problemu i tworzenie rozwiązań w środowisku języka Clojure. Zaczynajmy.

10.1. Wynik w grze Clojurebreaker Programiści języka Clojure często zastanawiają się, w którym miejscu trzeba zapisywać stan, aby można było rozwiązać problem. Jeszcze lepsze jest pytanie o to, jak dużą część problemu można rozwiązać bez przechowywania stanu. W Clojurebreaker (i w wielu innych grach) logika gry zapisana jest w czystej funkcji. Funkcja ta przyjmuje niejawny ciąg i próbę, a następnie zwraca wynik. Zauważenie tego faktu ma dwie poważne zalety. Można ustalić, że:  funkcja zwracająca wynik będzie prosta do napisania i przetestowania niezależnie od innego kodu;  można spokojnie zaimplementować funkcję score bez zastanawiania się nad działaniem reszty systemu. Generowanie wyniku to proces dwuetapowy. Należy ustalić liczbę dokładnie dopasowanych elementów i liczbę elementów o odpowiednim kolorze, ale podanych w niewłaściwej kolejności. Każdy z tych etapów można zapisać w odrębnej funkcji. Zacznijmy od dokładnie dopasowanych elementów. Aby program był czytelny, elementy zapisujemy w sposób ułatwiający testowanie kodu w środowisku REPL. Oznaczenia elementów pochodzą od kolorów — :r (od red, czyli czerwony), :g (od green, czyli zielony), :b (od blue, czyli niebieski) i :y (od yellow, czyli żółty). Funkcja ma zwracać liczbę dokładnie dopasowanych elementów, której w późniejszym kroku odpowiadają czarne punkty. Poniżej przedstawiamy szkielet potrzebnej funkcji.

Rozdział 10. • Tworzenie aplikacji



271

clojurebreaker/snippets.clj

(defn exact-matches "Na podstawie dwóch kolekcji zwraca liczbę pozycji, na których kolekcje obejmują identyczne elementy." [c1 c2])

Chwileczkę — łańcuch znaków z dokumentacją nie obejmuje żadnych informacji o grze, kolorach ani słowach kluczowych. O co tu chodzi? Choć dla niektórych jednostek wywołujących, na przykład samej gry, reprezentacja stanu gry ma znaczenie, w funkcji exact-matches nie jest to istotne. Dlatego warto zachować uniwersalny charakter kodu. Ważnym aspektem odpowiedzialnego projektowania programów w języku Clojure jest myślenie w kategoriach danych. Nie należy w każdej możliwej sytuacji tworzyć konkretnych obiektów. Czytając opis funkcji exact-matches jako uniwersalnej metody działającej na danych, możemy podejrzewać, że taka funkcja już istnieje. Po przeszukaniu odpowiednich przestrzeni nazw (clojure.code i clojure.data) odkrywamy, że najbardziej zbliżona do exact-matches jest funkcja diff z przestrzeni clojure.data. Funkcja diff rekurencyjnie porównuje dwie struktury danych i zwraca elementy z kolekcji A, elementy z kolekcji B i elementy z obu kolekcji. Ta ostatnia struktura to właśnie szukane dokładne dopasowania. Wypróbujmy funkcję diff w środowisku REPL. (require '[clojure.data :as data]) (data/diff [:r :g :g :b] [:r :y :y :b]) -> [[nil :g :g] [nil :y :y] [:r nil nil :b]]

Różne od nil wartości kolekcji [:r nil nil :b] to dokładne dopasowania uzyskane przez porównanie kolekcji r/g/g/b i r/y/y/b. Za pomocą funkcji diff można bardzo łatwo zaimplementować funkcję exact-matches. clojurebreaker/src/clojurebreaker/game.clj

(defn exact-matches "Na podstawie dwóch kolekcji zwraca liczbę pozycji, na których kolekcje obejmują identyczne elementy." [c1 c2] (let [[_ _ matches] (data/diff c1 c2)] (count (remove nil? matches))))

Ponownie przetestujmy kod w środowisku REPL dla przykładowych danych. (exact-matches [:r :g :g :b] [:r :y :y :b]) 2

Teraz możemy skoncentrować się na pasujących elementach z różnych pozycji. Aby ustalić liczbę takich elementów, trzeba określić, ile elementów każdego koloru znajduje się w niejawnym ciągu, a ile w zestawie podanym przez gracza. Wygląda na to, że to zadanie dla funkcji frequencies.

272



Programowanie w języku Clojure

(def example-secret [:r :g :g :b]) (frequencies example-secret) -> {:r 1, :g 2, :b 1} (def example-guess [:y :y :y :g]) (frequencies example-guess) -> {:y 3, :g 1}

Aby przekształcić te dwa wywołania funkcji frequencies na funkcję unordered-matches, trzeba wykonać dwie dodatkowe operacje:  uwzględnić tylko klucze występujące zarówno w niejawnym ciągu, jak i w ciągu podanym przez gracza;  zliczyć tylko powtarzające się elementy (czyli uwzględnić minimalną wartość dla każdego klucza). Ponownie zakładamy, że potrzebne funkcje już istnieją. Rzeczywiście tak jest. Potrzebne klucze można pobrać za pomocą funkcji select-keys. (select-keys (frequencies example-secret) example-guess) -> {:g 2} (select-keys (frequencies example-guess) example-secret) -> {:g 1}

Powtarzające się elementy z dwóch odwzorowań można zliczyć przy użyciu funkcji merge-with. (merge-with min {:g 1} {:g 2}) -> {:g 1}

Za pomocą funkcji frequencies, select-keys i merge-with można utworzyć przedstawioną poniżej definicję funkcji unordered-matches. clojurebreaker/src/clojurebreaker/game.clj

(defn unordered-matches "Na podstawie dwóch kolekcji zwraca odwzorowanie, w którym każdy klucz to element występujący w obu kolekcjach, a każda wartość to mniejsza z liczb wystąpień tego elementu." [c1 c2] (let [f1 (select-keys (frequencies c1) c2) f2 (select-keys (frequencies c2) c1)] (merge-with min f1 f2)))

Oczywiście należy sprawdzić działanie funkcji w środowisku REPL. (unordered-matches [:r :g :g :b] [:y :y :y :g]) -> {:g 1}

Rozdział 10. • Tworzenie aplikacji



273

Funkcja unordered-matches działa dobrze, choć z jednym zastrzeżeniem — zlicza wystąpienia niezależnie od kolejności, natomiast w grze liczą się wystąpienia w niewłaściwej kolejności. Choć na pozór wydaje się, że w grze funkcja unordered-matches jest nieprzydatna, okazuje się ona wartościowa. Wynika to z dwóch powodów:  Funkcja unordered-matches wykonuje dokładnie jedno zadanie. Aby napisać funkcję do określania liczby pasujących elementów z różnych pozycji (na przykład not-ordered-match), musielibyśmy ponownie napisać kod funkcji exact-matches w funkcji unordered-matches.  Dwie napisane tu proste funkcje pozwalają określić liczbę pasujących elementów na różnych pozycjach. Wystarczy odjąć wartość zwróconą przez funkcję exact-matches od wartości funkcji unordered-matches. Po napisaniu dwóch prostych funkcji wystarczy połączyć je w funkcji score. clojurebreaker/src/clojurebreaker/game.clj

(defn score [c1 c2] (let [exact (exact-matches c1 c2) unordered (apply + (vals (unordered-matches c1 c2)))] {:exact exact :unordered (- unordered exact)}))

W środowisku REPL funkcja działa poprawnie. (score [:r :g :g :b] [:r :y :y :g]) -> {:exact 1, :unordered 1}

W ten sposób udzieliliśmy częściowej odpowiedzi na pytanie o prawidłowy proces pisania kodu. Podsumujmy ją:  Podziel problem, aby zidentyfikować czyste funkcje.  Poznaj bibliotekę standardową, aby znaleźć już napisane funkcje.  Unikaj konkretnych obiektów (używaj danych jako danych).  Przetestuj kod w środowisku REPL. Według naszych doświadczeń programiści, którzy stosują ten proces po raz pierwszy, popełniają dwa typowe błędy:  piszą za dużo kodu;  komplikują testy. Jeśli nie rozumiesz działania konstrukcji, a nie przetestowałeś i nie poznałeś wszystkich konstrukcji podrzędnych, wskazuje to na napisanie zbyt dużej ilości kodu. Wielu programistów intuicyjnie pisze X wierszy kodu, a następnie przeprowadza testy. X jest tu najmniejszą liczbą wierszy kodu, który wykonuje

274



Programowanie w języku Clojure

istotne zadanie. W języku Clojure X jest mniejsze niż jeden, dlatego kładziemy nacisk na budowanie funkcji w środowisku REPL. Skomplikowane testy to bardziej złożone zagadnienie. Omawiamy je w następnym podrozdziale.

10.2. Testowanie kodu zwracającego wynik W poprzednim podrozdziale krok po kroku utworzyliśmy funkcję score w środowisku REPL. Potwierdziliśmy, że działa poprawnie dla kilku przykładowych danych. Nie trzeba być szczególnie przewrażliwionym na punkcie jakości, aby nie ograniczać się do takiego sprawdzania poprawności! Zacznijmy od wyodrębnienia operacji, które programiści mają na myśli, mówiąc o testach. Testy obejmują następujące zadania:  zastanawianie się nad poprawnością kodu;  wykonywanie kodu krok po kroku w środowisku programowania, gdzie można podejrzeć wszystkie operacje;  opracowanie danych wejściowych w celu uwzględnienia różnych ścieżek wykonania;  opracowanie danych wyjściowych odpowiadających danym wejściowym;  uruchamianie kodu dla różnych danych wejściowych;  sprawdzanie poprawności wyników;  automatyzowanie sprawdzania poprawności wyników;  porządkowanie testów pod kątem późniejszych automatycznych testów regresji. Lista ta nie jest wyczerpująca, jednak wystarcza do udowodnienia tego, co chcemy pokazać w tym podrozdziale. Ujmijmy to krótko — testy często są skomplikowane, ale można je łatwo przeprowadzić. Tradycyjne testy jednostkowe obejmują wiele z wymienionych wcześniej zadań z obszaru testowania. Na przykład dane wejściowe, dane wyjściowe, wykonywanie kodu i sprawdzanie poprawności są wbudowane w poszczególne metody testowe. Jednak ograniczone testy w środowisku REPL, które stosowaliśmy wcześniej, to za mało. Czy można uzyskać korzyści związane z przedstawionymi wcześniej pomysłami, a jednocześnie uniknąć złożoności testów jednostkowych? Spróbujmy osiągnąć taki efekt.

Rozdział 10. • Tworzenie aplikacji



275

Tworzenie danych wejściowych Pokazaliśmy już, jak działa funkcja score dla kilku ręcznie podanych danych wejściowych. Ile takich danych trzeba przygotować, aby przekonać się, że funkcja jest poprawna? Najlepiej byłoby sprawdzić funkcję dla wszystkich danych, ale prawie nigdy nie jest to możliwe. Mamy jednak szczęście, ponieważ problem określania wyniku jest taki sam niezależnie od liczby kolorów lub elementów. Dlatego można wygenerować wszystkie możliwe dane wejściowe dla małej wersji gry. Dziedzina matematyki, która dotyczy różnych sposobów tworzenia wzorców, to tak zwana kombinatoryka przeliczeniowa. Okazuje się, że w bibliotece math.combinatorics języka Clojure znajdują się funkcje potrzebne do wygenerowania wszystkich możliwych danych wejściowych. Dodajmy poniższą konstrukcję do klucza :dependencies w pliku project.clj (jeśli jeszcze się w nim nie znajduje). [org.clojure/math.combinatorics "0.0.1"] Funkcja selections przyjmuje dwa argumenty, kolekcję i rozmiar, a zwraca każdą strukturę podanego rozmiaru składającą się z elementów kolekcji. Wypróbujmy tę funkcję dla małej wersji gry Clojurebreaker (z trzema kolorami i dwiema kolumnami). (require '[clojure.math.combinatorics :as comb]) (comb/selections [:r :g :b] 2) -> ((:r :r) (:r :g) (:r :b) (:g :r) (:g :g) (:g :b) (:b :r) (:b :g) (:b :b))

Tak więc funkcja selections pozwala uzyskać możliwy niejawny ciąg lub ciąg podawany przez użytkownika. A co z generowaniem danych wejściowych dla funkcji score? Wystarczy wybrać dwa wyniki zwrócone przez funkcję selections. (-> (comb/selections [:r :g :b] 2) (comb/selections 2)) -> (81 możliwych par z uwagi na zwięzłość pominięto)

Umieśćmy cały kod w funkcji o określonej nazwie. clojurebreaker/src/clojurebreaker/game.clj

(defn generate-turn-inputs "Generuje wszystkie możliwe dane wejściowe gry Clojurebreaker dla kolorów colors i n kolumn." [colors n] (-> (comb/selections colors n) (comb/selections 2)))

276



Programowanie w języku Clojure

W porządku, dane wejściowe są wygenerowane. Na razie pomijamy kwestię danych wyjściowych (z powodów, które wkrótce staną się oczywiste) i przechodzimy do uruchamiania mechanizmu zwracania wyników dla wygenerowanych danych wejściowych.

Przeprowadzanie testu Napiszmy funkcję, która przyjmuje sekwencję danych wejściowych, a następnie tworzy sekwencję danych wejściowych i wyników wywołania funkcji score. Na razie nie chcemy określać sposobu sprawdzania poprawności wyników. Może odpowiadać za to człowiek lub program. W obu sytuacjach dobrą reprezentacją każdego wyniku będzie odwzorowanie z kluczami secret, guess i score. Nowa funkcja musi tylko wywoływać funkcję score i tworzyć kolekcję odpowiedzi. clojurebreaker/src/clojurebreaker/game.clj

(defn score-inputs "Przyjmuje sekwencję danych wejściowych, zwraca leniwą sekwencję odwzorowań z kluczami :secret, :guess i :score." [inputs] (map (fn [[secret guess]] {:secret (seq secret) :guess (seq guess) :score (score secret guess)}) inputs))

Wypróbujmy tę funkcję w środowisku REPL. (->> (generate-turn-inputs [:r :g :b] 2) (score-inputs)) -> ({:secret (:r :r), :guess (:r :r), :score {:exact 2, :unordered 0}} {:secret (:r :r), :guess (:r :g), :score {:exact 1, :unordered 0}} ;; Ze względu na zwięzłość pozostałe wyniki pominięto.

Jeśli wyniki ma czytać człowiek, można zapisać je w tabeli tekstowej, używając instrukcji score print-table. Wygenerujmy teraz większą grę (cztery kolory w czterech kolumnach) i zapiszmy taką tabelę do pliku. (use 'clojure.pprint) (require '[clojure.java.io :as io]) (with-open [w (io/writer "scoring-table")] (binding [*out* w] (print-table (->> (generate-turn-inputs [:r :g :b :y] 4) (score-inputs))))) -> nil

W pliku scoring-table znajdziesz 65 536 różnych kombinacji „niejawny ciąg/ciąg podany przez gracza” i odpowiadające im wyniki.

Rozdział 10. • Tworzenie aplikacji



277

Sprawdzanie poprawności danych wyjściowych Na tym etapie oczywiste jest już, dlaczego pominęliśmy tworzenie danych wyjściowych. Program robi to automatycznie. Musimy tylko ustalić, ile wysiłku chcemy poświęcić na sprawdzenie ich poprawności. Przedstawiamy tu kilka możliwych podejść.  Ludzki ekspert może przejrzeć całą tabelę danych wyjściowych dla małej wersji gry. Zaletą tego podejścia jest analiza wszystkich wyników, jednak mogą przy tym umknąć błędy logiczne, pojawiające się tylko w większych wersjach gry.  Ludzki ekspert może też sprawdzić losową próbkę wyników z większej wersji gry. Ponieważ etap sprawdzania poprawności jest niezależny od generowania danych wyjściowych i uruchamiania programu, poszczególne kroki można zaprojektować i napisać osobno (i w innym czasie). Ponadto jednostka sprawdzająca poprawność nie zna sposobu generowania danych wejściowych. W testach jednostkowych dane wejściowe i dane wyjściowe są generowane przez programistę w mniej więcej tym samym czasie. Jeśli programista konsekwentnie popełnia pewien błąd, może napisać testy, które nie wykrywają usterki. Jest to niemożliwe, kiedy dane wyjściowe są sprawdzane losowo lub w całości. Do programowego sprawdzania poprawności wracamy dalej. Najpierw jednak zajmijmy się testami regresji.

Testy regresji Chcesz utworzyć zestaw testów bardziej kompletny niż dotychczasowe testy poprawności? Nie ma sprawy.  Napisz program, którego wyniki nie powinny się zmieniać.  Uruchom program raz i zapisz wyniki do pliku o odpowiedniej nazwie.  Uruchamiaj program po wprowadzeniu dowolnych zmian i porównuj wynik z zawartością zapisanego pliku. Różnice wskazują na błąd w programie. Atrakcyjną cechą testów regresji jest to, że działają nawet wtedy, jeśli programista nigdy nie sprawdzał poprawności wyników. Oczywiście należy ją sprawdzić, ponieważ pomaga to określić, gdzie wystąpił problem. Jeśli programista nie sprawdził poprawności wyników, rozbieżność w czasie testów regresji może być wynikiem naprawienia usterki występującej w dawnym kodzie.

278



Programowanie w języku Clojure

Jak trudne jest napisanie programu, który za każdym razem ma generować dokładnie te same dane? Wystarczy wywoływać w kodzie tylko czyste funkcje. Dokładnie tak działa funkcja score-inputs. Powiązanie testów regresji z procesem budowania w ramach ciągłej integracji nie jest trudne. Jeśli chcesz zastosować to podejście, pomyśl o wykorzystaniu frameworku testowego. Po części odpowiedzieliśmy już na pytanie o sprawdzanie poprawności kodu. Podsumujmy nasze odkrycia:  Należy tworzyć małe jednostki kodu, które można ze sobą łączyć. Większość kodu powinny stanowić czyste funkcje.  Konstrukcje należy testować w środowisku REPL.  W czasie pisania kodu testów należy w odrębnych krokach generować dane wejściowe, wykonywać program i sprawdzać dane wyjściowe. Ostatni punkt jest tak ważny, że zasługuje na obsługę ze strony biblioteki. Dlatego przed przejściem dalej przedstawiamy bibliotekę test.generative. Ma ona upraszczać testowanie.

10.3. Biblioteka test.generative Biblioteka test.generative pozwala podzielić testy na trzy podstawowe etapy:  generowanie danych wejściowych testu;  wywoływanie funkcji testu;  sprawdzanie poprawności wyników. Każdy z tych trzech etapów należy zaimplementować w postaci funkcji łączonych następnie w testy w konstrukcji defspec. Zainstalujmy i wypróbujmy bibliotekę test.generative. W pliku project.clj należy umieścić następujący wiersz: [org.clojure/test.generative "0.1.3"]

Teraz trzeba ponownie wczytać kod projektu i przejść do środowiska REPL, aby wygenerować dane wejściowe testu.

Rozdział 10. • Tworzenie aplikacji



279

Generowanie danych W środowisku REPL należy wywołać instrukcję generators.

require

dla przestrzeni nazw

(require '[clojure.test.generative.generators :as gen])

Przestrzeń nazw generators obejmuje funkcje do generowania pseudolosowych wartości dla różnych często spotykanych typów danych. Na przykład dla każdego typu prostego Javy istnieje funkcja generatora o nazwie identycznej jak dany typ. Wypróbujmy kilka takich funkcji. (gen/int) -> 977378563 (gen/char) -> \A (gen/boolean) -> true

Warto zauważyć, że na każdym komputerze wyniki będą inne, ponieważ wartości są zwracane losowo. Można też wygenerować losowe wartości dla różnych kolekcji języka Clojure. Służące do tego funkcje są sparametryzowane. Przyjmują jako parametry typ kolekcji i zapisywanych danych. Oto przykładowe wywołania. (gen/vec gen/boolean) -> [false false true true true false] (gen/hash-map gen/byte gen/int) -> {32 -504310803, 100 424501842, 5 1439482147, 37 1161641068}

Można też wykorzystać kilka mechanizmów do kontrolowania sposobu generowania typów. Jedną z możliwości jest stosowanie rozkładu prawdopodobieństwa. (gen/geometric 0.02) -> 10

Można też kontrolować wielkość kolekcji (albo za pomocą stałej, albo przy użyciu rozkładu). (gen/list gen/int 2) -> (-1029960512 1985289448) (gen/list gen/int (gen/uniform 0 5)) -> (315829211)

Jest też kilka innych ciekawych możliwości, jednak nie zamierzamy psuć Ci zabawy z ich samodzielnym odkrywaniem. Wywołaj instrukcję dir dla przestrzeni nazw i zapoznaj się z innymi standardowymi generatorami.

280



Programowanie w języku Clojure

Aby przetestować mechanizm podawania wyników w grze Clojurebreaker, potrzebujemy funkcji, która losowo generuje niejawny ciąg (albo ciąg podawany przez gracza). Można ponownie przedstawić kolory elementów za pomocą słów kluczowych i napisać następującą funkcję: clojurebreaker/test/clojurebreaker/game_test.clj

(defn random-secret [] (gen/vec #(gen/one-of :r :g :b :y) 4))

Pamiętaj o tym, aby przed przejściem do sprawdzania poprawności sprawdzić funkcję random-secret w środowisku REPL.

Programowe sprawdzanie poprawności Programowe sprawdzanie poprawności powinno być czymś więcej niż porównywaniem ręcznie podanych wartości. Takie sprawdzanie to strata czasu, skoro można łatwo ustalić poprawność zbioru danych wyjściowych i wykorzystać je w testach regresji. Przy programowym sprawdzaniu poprawności należy wymuszać logiczne niezmienniki dotyczące danych wejściowych i wyjściowych. Oto niektóre niezmienniki funkcji do obliczania wyników w grze Clojurebreaker:  Obliczanie wyników przebiega „symetrycznie”. Po zamianie miejscami niejawnego ciągu i ciągu podanego przez gracza wynik powinien być taki sam.  Suma dokładnych i niedokładnych dopasowań musi być większa lub równa zeru i mniejsza lub równa liczbie elementów.  Po przestawieniu elementów w ciągu podanym przez gracza suma dokładnych i niedokładnych dopasowań powinna być taka sama. Zapiszmy każdy z tych niezmienników jako funkcję logiczną od kolekcji secret,

guess i score.

clojurebreaker/test/clojurebreaker/game_test.clj

(ns clojurebreaker.game-test (:use [clojure.test.generative :only (defspec) :as test]) (:require [clojure.test.generative.generators :as gen] [clojurebreaker.game :as game] [clojure.math.combinatorics :as comb])) (defn matches "Na podstawie wyniku zwraca łączną liczbę dokładnych i niedokładnych dopasowań."

Rozdział 10. • Tworzenie aplikacji



281

[score] (+ (:exact score) (:unordered score))) (defn scoring-is-symmetric [secret guess score] (= score (game/score guess secret))) (defn scoring-is-bounded-by-number-of-pegs [secret guess score] (< 0 (matches score) (count secret))) (defn reordering-the-guess-does-not-change-matches [secret guess score] (= #{(matches score)} (into #{} (map #(matches (game/score secret %)) (comb/permutations guess)))))

Choć często najpierw wpisujemy kod w środowisku REPL, a następnie przeklejamy go do odpowiedniego pliku, funkcje sprawdzające niezmienniki utworzyliśmy w inny sposób — zaczęliśmy od zapisania ich w pliku z kodem źródłowym. Nie oznacza to, że funkcji tych nie można szybko sprawdzić w środowisku REPL. Można w nim ponownie wczytać przestrzeń nazw, a następnie przejść do niej za pomocą instrukcji in-ns. (require :reload 'clojurebreaker.game-test) (in-ns 'clojurebreaker.game-test)

Po przejściu do odpowiedniej przestrzeni nazw otrzymujemy dostęp do wszystkich publicznych nazw i aliasów. Pozwala to na wygodne przygotowanie przykładowych danych, umożliwiających szybkie przetestowanie funkcji do sprawdzania poprawności. (def secret [:r :g :g :b]) (def guess [:r :b :b :y]) (scoring-is-symmetric secret guess (game/score secret guess)) -> true (scoring-is-bound-by-number-of-pegs secret guess (game/score secret guess)) -> true (reordering-the-guess-does-not-change-matches secret guess (game/score secret guess)) -> true

Po utworzeniu generatora danych testowych i kilku funkcji do sprawdzania poprawności można przejść do połączenia wszystkich elementów w funkcji defspec.

282



Programowanie w języku Clojure

Funkcja defspec Funkcja defspec przyjmuje trzy wymagane argumenty. Oto one:  nazwa specyfikacji,  testowana funkcja,  generowane argumenty. Po argumentach można podać zero lub więcej konstrukcji przeznaczonych do sprawdzania poprawności. Poniżej przedstawiamy prosty przykład. clojurebreaker/snippets.clj

(defspec closed-under-addition +' [^long a ^long b] (assert (integer? %)))

Argumenty wyglądają na zwykłe argumenty języka Clojure, trzeba jednak pamiętać o pewnym zastrzeżeniu. Dla argumentów normalnych funkcji można opcjonalnie określić sugerowany typ. W specyfikacjach trzeba to zrobić, a sugerowany typ określa sposób generowania argumentów. Funkcja generate-test-data z biblioteki test.generative przyjmuje specyfikację argumentu i generuje nieskończoną sekwencję danych testowych. Funkcję generate-test-data można wywołać bezpośrednio, aby wygenerować argumenty niezależnie od wykonywania testu. (take 3 (test/generate-test-data '[long long])) -> ([-5025562857975149833 -5843495416241995736] [5694868678511409995 5111195811822994797] [-6169532649852302182 -1782466964123969572])

W ciele instrukcji defspec można uzyskać dostęp do wszystkich argumentów za pomocą nazwy, tak jak w normalnych funkcjach. Ponadto dostępny jest symbol %, który przechowuje wartość zwróconą przez funkcję testową. W przykładowej instrukcji closed-under-addition znajduje się jedna instrukcja do sprawdzania poprawności. Określa ona, czy wynik dodawania to liczba całkowita. Nie sprawdza przy tym argumentów wejściowych. (assert (integer? %))

W bardziej skomplikowanych funkcjach do sprawdzania poprawności zwykle uwzględnia się zarówno argumenty wejściowe, jak i wynik.

Rozdział 10. • Tworzenie aplikacji



283

Przeprowadzanie testów Specyfikacje to funkcje, dlatego można je uruchomić przez zwykłe wywołanie. Jeśli w czasie lektury poprzedniego punktu uruchomiłeś specyfikację closed-under-addition w środowisku REPL, teraz możesz to zrobić jeszcze raz, przekazując kilka liczb całkowitych. (closed-under-addition 1 2) -> nil

Jednak ciekawsze jest uruchamianie specyfikacji dla wygenerowanych danych wejściowych. W bibliotece test.generative znajdują się trzy służące do tego funkcje pomocnicze.  Funkcja test-vars przyjmuje jedną lub więcej zmiennych i uruchamia specyfikacje powiązane z tymi zmiennymi.  Funkcja test-dirs przyjmuje jeden lub więcej katalogów i za pomocą refleksji wyszukuje oraz uruchamia wszystkie specyfikacje z tych katalogów.  Funkcja test-namespaces przyjmuje przestrzenie nazw i za pomocą refleksji wyszukuje oraz uruchamia wszystkie specyfikacje z tych przestrzeni. Wywołajmy specyfikację closed-under-addition. (require '[clojure.test.generative :as test]) (test/test-vars #'closed-under-addition) -> [# #]

Funkcja test-vars zwraca pewną liczbę przyszłych wywołań (tu w danych wyjściowych są dwa takie wywołania). Następnie przez około 10 sekund nic się nie dzieje, po czym środowisko REPL wyświetla dane wyjściowe podobne do poniższych. {:iterations 179766, :msec 10228, :var #'clojurebreaker.game-test/closed-under-addition, :seed 43} {:iterations 156217, :msec 10249, :var #'clojurebreaker.game-test/closed-under-addition, :seed 42}

Na zapleczu biblioteka test.generative przygotowała przyszłe wywołania (ang. future) dla każdego procesora z danego komputera. Każde przyszłe wywołanie powoduje uruchomienie tylu testów, ile można wykonać w ciągu 10 sekund.

284



Programowanie w języku Clojure

W danych wyjściowych można zobaczyć, ile powtórzeń wykonano w każdym wątku, ile czasu zegarowego działał każdy wątek, a także losowe ziarno użyte do wygenerowania danych dla każdej testowanej zmiennej. Biblioteka test.generative udostępnia kilka dynamicznych zmiennych pozwalających dostosować przebieg testów do potrzeb. Za pomocą zmiennej test/*msec* można określić czas przebiegu testów, a zmienna test/*cores* pozwala podać liczbę rdzeni używanych w testach. Ponadto przy użyciu zmiennej test/*verbose* można włączyć wyświetlanie w trakcie testów wszystkich danych wejściowych na wyjściu *out*.

Zgłaszanie błędu przez specyfikację Jeśli specyfikacja zgłasza błąd, biblioteka test.generative udostępnia dodatkowe dane wyjściowe pozwalające odtworzyć problem. Napiszmy specyfikację, która zgłasza błąd (z uwagi na warunek, że suma dwóch liczb jest mniejsza od każdej z nich). clojurebreaker/snippets.clj

(defspec incorrect-spec +' [^long a ^long b] (assert (< a %)) (assert (< b %)))

Specyfikacja incorrect-spec po uruchomieniu szybko zgłasza błąd. {:form (#'clojurebreaker.game-test/incorrect-spec -5025562857975149833 -5843495416241995736), :iteration 0, :seed 42, :error "Assert failed: (< a %)", :exception #}

Klucz :form zawiera dane wejściowe, dla których wystąpił błąd, a klucz :iteration określa, które wywołanie generatora danych wejściowych było przyczyną problemu. Ponieważ proces generowania danych testowych jest pseudolosowy i znane jest ziarno, można odtworzyć błąd, ponownie uruchamiając cały test (można przy tym dodać mechanizm rejestrowania informacji lub dołączyć debuger). Jeśli nie chcesz powtarzać całego testu do miejsca wystąpienia błędu, możesz uzyskać dostęp do kolekcji konstrukcji, które spowodowały problem. Służy do tego funkcja failures.

Rozdział 10. • Tworzenie aplikacji



285

(test/failures) -> ({:form (#'clojurebreaker.game-test/incorrect-spec -5025562857975149833 -5843495416241995736)} {:form (#'clojurebreaker.game-test/incorrect-spec -5027215341191833961 -2715953330829768452)}

Teraz wystarczy przejść po kolekcji i wywołać funkcję danych wejściowych, które doprowadziły do błędu.

eval

dla wcześniejszych

(eval (:form (first (test/failures)))) -> AssertionError Assert failed: (< a %)

Napisaliśmy i uruchomiliśmy specyfikację, a także pokazaliśmy, jak sprawdzić przyczyny błędów. Pora zastosować opisane techniki do przetestowania funkcji score z gry Clojurebreaker.

Testowanie funkcji score z wykorzystaniem generowania danych W punkcie „Programowe sprawdzanie poprawności” utworzyliśmy kilka funkcji do sprawdzania niezmienników z funkcji score z programu Clojurebreaker. Umieśćmy te funkcje w specyfikacji. clojurebreaker/test/clojurebreaker/game_test.clj

(defspec score-invariants game/score [^{:tag `random-secret} secret ^{:tag `random-secret} guess] (assert (scoring-is-symmetric secret guess %)) (assert (scoring-is-bounded-by-number-of-pegs secret guess %)) (assert (reordering-the-guess-does-not-change-matches secret guess %)))

W tej specyfikacji zastosowaliśmy nowy element składni instrukcji defspec — symbol dosłownego podawania składni (`). Dosłownie podana instrukcja random-secret oznacza, że jako generator używana jest niestandardowa funkcja napisana przez programistę. Przy podawaniu nazw w specyfikacji argumentu obowiązują dwie reguły. Oto one:  Jeśli składnia nie jest podawana dosłownie, nazwy są interpretowane w kontekście jednego z wbudowanych generatorów z przestrzeni nazw clojure.test.generative.generators. Nazwy te odpowiadają typom pomocniczym i nazwom fabryk z przestrzeni nazw clojure.core, dlatego nazwy wbudowanych generatorów wyglądają jak sugestie typów.  Jeśli nazwa jest podana dosłownie, jest interpretowana według reguł z przestrzeni nazw, w której znajduje się dana instrukcja defspec. Pozwala to korzystać ze standardowych narzędzi przestrzeni nazw do zarządzania własnymi generatorami.

286



Programowanie w języku Clojure

Nadeszła chwila prawdy. Czy po uruchomieniu specyfikacji niezmienniki zostaną zachowane? #'clojurebreaker.game-test/score-invariants -> [# #] ;; Mija 10 sekund. {:iterations 1794, :msec 10002, :seed 42, :var #'clojurebreaker.game-test/score-invariants} {:iterations 1787, :msec 10001, :seed 43, :var #'clojurebreaker.game-test/score-invariants}

Kod działa poprawnie. Czy potrafisz wymyślić inne ciekawe niezmienniki do sprawdzenia? W tym miejscu tylko zaczęliśmy omawianie biblioteki test.generative. Ponadto, choć odpowiada nam podział na generowanie danych wejściowych, wykonywanie kodu i sprawdzanie poprawności, w języku Clojure oczywiście można stosować też bardziej popularne testy jednostkowe oraz podejścia TDD (ang. test driven development) i BDD (ang. behavior driven development). Jeśli interesują Cię te techniki, powinieneś zajrzeć do wymienionych poniżej bibliotek. 1  Biblioteka clojure.test to skromna biblioteka służąca do przeprowadzania testów jednostkowych, wbudowana w pakiet testów języka Clojure. 2  Biblioteka Lazytest to uniwersalna biblioteka do tworzenia testów w różnych podejściach na podstawie kilku uniwersalnych reprezentacji testów. 3  Autorzy biblioteki Midje skoncentrowali się na czytelności testów, a przy tym umożliwili tworzenie testów typu „góra-dół” i „dół-góra”. Wiele pomysłów wykorzystanych w bibliotece test.generative zaczerpnięto z QuickCheck4, biblioteki testów napisanej pierwotnie w Haskellu, ale obecnie przeniesionej do wielu innych języków. Jeśli biblioteka test.generative Cię zaciekawiła, koniecznie wypróbuj bibliotekę QuickCheck. Jest ona rozwijana już od długiego czasu i obejmuje wiele funkcji niedostępnych na razie w bibliotece test.generative. Skoro wiemy już, że funkcja zwracająca wynik działa poprawnie, zobaczmy, jak uruchamiać grę Clojurebreaker w sieci WWW. 1 2 3 4

http://clojure.github.com/clojure/clojure.test-api.html https://github.com/stuartsierra/lazytest https://github.com/marick/Midje http://en.wikipedia.org/wiki/QuickCheck

Rozdział 10. • Tworzenie aplikacji



287

10.4. Tworzenie interfejsu Po zdobyciu solidnych podstaw, zapoznanie się z interfejsem i gotową wersją gry będzie przyjemnością. Zacznijmy od utworzenia prostej aplikacji sieciowej. Framework sieciowy noir posłuży jako wygodny punkt wyjścia do tworzenia aplikacji. Proces tworzenia nowego projektu za pomocą tego frameworku jest prosty. Najpierw należy zainstalować wtyczkę lein-noir Leiningena. $ lein plugin install lein-noir 1.2.0

Teraz można wygenerować aplikację i ją uruchomić. $ lein noir new clojurebreaker $ cd clojurebreaker $ lein run

Wpisz w przeglądarce adres http://localhost:8080. Zobaczysz domyślną stronę wejściową frameworku noir. Na razie nie ma ona znaczenia — jest tylko dowodem na to, że prawidłowo skonfigurowałeś framework.

Pora dodać stan Pora dodać jedyny element stanu. Ponieważ chcemy umożliwić jednoczesną grę kilku osobom i przechowywać rozwiązanie między żądaniami, program musi przechowywać odpowiednie dane. Najlepsze jest to, że stanem nie trzeba zarządzać w kodzie — wystarczy zapisać stan w sesji przeglądarki. Zacznijmy od otwarcia pliku src/clojurebreaker/views/welcome.clj. Zmień stronę /welcome na samo /. Potrzebny jest sposób na zapisanie danych w sesji, jeśli jeszcze się tam nie znajdują. Na szczęście framework noir udostępnia instrukcje session/put! i session/get. Wykorzystajmy je. (defpage "/" [] (when-not (session/get :game) (session/put! :game (.nextInt (java.util.Random.) 1000000))) (common/layout [:p "Witaj w grze Clojurebreaker. Identyfikator gry to: " (session/get :game)]))

Po odświeżeniu strony w przeglądarce zobaczysz identyfikator gry zapisany w sesji. Przy późniejszych odświeżeniach strony identyfikator się nie zmienia. Dopiero po zamknięciu sesji odświeżenie strony prowadzi do wygenerowania nowego identyfikatora. Dalej wykorzystamy tę technikę, jednak losowo generowany identyfikator nie będzie potrzebny. Tu zastosowaliśmy go w celu pokazania, że pamięć sesji działa prawidłowo.

288



Programowanie w języku Clojure

Utwórzmy nowy plik, game.clj, w katalogu src/clojurebreaker/models. W pliku tym należy umieścić funkcję, która generuje nowy niejawny ciąg i zwraca go do widoku. (ns clojurebreaker.models.game) (defn create [] (vec (repeatedly 4 (fn [] (rand-nth ["r" "g" "b" "y"])))))

Wypróbujmy tę funkcję w środowisku REPL, aby się upewnić, że generuje potrzebne dane. (in-ns 'clojurebreaker.models.game) clojurebreaker.models.game=> (dotimes [_ 5] (println (create))) | [g y g b] | [g r r r] | [r y g r] | [b y y b] | [b g r g] -> nil

To już wszystko, co potrzebne do uruchomienia gry. Teraz wystarczy wrócić do widoku i powiązać kod. Warto pamiętać, że biblioteka test.generative także potrafi utworzyć niejawny ciąg, dlatego możesz użyć jej zamiast przedstawionej tu funkcji. Tu chcieliśmy tylko pokazać inny sposób na tworzenie niejawnego ciągu. (defpage "/" [] (when-not (session/get :game) (session/put! :game (game/create))) (common/layout [:p "Witaj w grze Clojurebreaker. Rozwiązanie to: " (session/get :game)]))

Należy też dołączyć odpowiednią przestrzeń nazw — clojurebreaker.models.game :as game. Po odświeżeniu strony w przeglądarce pojawi się rozwiązanie. Jeśli zamiast niego nadal widnieje identyfikator, należy usunąć dane sesji i odświeżyć stronę. Spróbuj otworzyć inną przeglądarkę i uruchomić grę. Pojawi się inne rozwiązanie.

Interfejs gracza Kiedy stan gry jest już gotowy, potrzebny jest interfejs dla graczy. Zacznijmy od utworzenia planszy. Do deklaracji przestrzeni nazw należy dodać przestrzeń hiccup.form-helpers i inne importowane elementy biblioteki hiccup. Wykorzystujemy też makro defpartial frameworku noir.

Rozdział 10. • Tworzenie aplikacji



289

(defpartial board [] (form-to [:post "/guess"] (text-field "one") (text-field "two") (text-field "three") (text-field "four") (submit-button "Zgadnij")))

Makro defpartial jest tu przydatne, ponieważ można go używać jak zwykłej funkcji języka Clojure. Będzie to użyteczne przy dołączaniu kodu do zwracania wyniku. Wypróbujmy nowy kod w środowisku REPL. clojurebreaker.models.game=> (in-ns 'clojurebreaker.views.welcome) clojurebreaker.views.welcome=> (board) | " | | | | | "

Teraz wystarczy połączyć fragmenty kodu. (defpage "/" [] (when-not (session/get :game) (session/put! :game (game/create))) (common/layout (board)))

Ponieważ stwierdziliśmy, że wszystkie aspekty sesji działają prawidłowo, możemy pominąć ich wyświetlanie i dodać kod gry. Teraz użytkownicy mają jak zgadywać niejawny ciąg — a przynajmniej mogą wprowadzać dane w przeglądarce. Połączmy ten kod z kodem działającym po stronie serwera. W kodzie widoku należy utworzyć nową stronę do reagowania na przesłanie żądania strony /guess. Na nowej stronie trzeba wykonać następujące operacje:  przyjąć cztery wprowadzone dane i przesłać je do funkcji obliczającej wynik;  określić, czy gracz wygrał (jest tak, jeśli wykryto cztery dokładne dopasowania); jeśli tak, należy pogratulować wygranej i wyświetlić przycisk pozwalający rozpocząć nową grę;  jeżeli użytkownik nie wygrał, należy zwrócić liczbę dokładnych i niedokładnych dopasowań, a także zestaw ostatnio wprowadzonych danych; wszystkie te informacje należy wyświetlić użytkownikowi. Do obliczania wyniku służą funkcje exact-matches, unordered-matches i score utworzone wcześniej w rozdziale. Dodajmy je do modelu. Funkcje te można wywołać w metodzie obsługi przesłanego żądania strony i zakończyć pierwszy etap gry.

290



Programowanie w języku Clojure

clojurebreaker/src/clojurebreaker/views/welcome.clj

(defpage [:post "/guess"] {:keys [one two three four]} (let [result (game/score (session/get :game) [one two three four])] (if (= (:exact result) 4) (do (session/remove! :game) (common/layout [:h2 "Gratulacje – rozwiązałeś zagadkę!"] (form-to [:get "/"] (submit-button "Nowa gra")))) (do (session/flash-put! result) (render "/" {:one one :two two :three three :four four :exact (:exact result) :unordered (:unordered result)})))))

To prawda, kod działa nieco podobnie jak w środowisku REPL. Możemy ustalić, czy gracz wygrał, i odpowiednio obsłużyć tę sytuację. W kodzie występuje jednak także kilka nowych elementów z frameworku noir, których jeszcze nie opisaliśmy. Podział struktury :keys po wywołaniu defpage pozwala pobrać argumenty przekazane przez przeglądarkę. Instrukcja session/remove! działa dokładnie tak, jak na to wskazuje nazwa. Ciekawy kod rozpoczyna się wraz z instrukcją session/flash-put!. Powoduje ona dodanie do sesji danych wykorzystywanych przy przetwarzaniu żądania. Następnie dane te są usuwane. Podobnie działają metody z rodziny flash we frameworku Ruby on Rails. Funkcja render wywołuje adres dla podanych dalej argumentów. Wystarczy wprowadzić kilka zmian, po czym można wypróbować grę. Adres / musi przyjmować argumenty przekazywane przez wywołanie napisanej właśnie funkcji render. Na razie jest inaczej — zmieńmy to. Najpierw należy zmodyfikować funkcję częściową board tak, aby przyjmowała i dzieliła strukturę z przekazanymi argumentami oraz wyświetlała wyniki. clojurebreaker/src/clojurebreaker/views/welcome.clj

(defpartial board [{:keys [one two three four exact unordered]}] (when (and exact unordered) [:div "Dokładne: " exact " Niedokładne: " unordered]) (form-to [:post "/guess"] (text-field "one" one) (text-field "two" two) (text-field "three" three) (text-field "four" four) (submit-button "Zgadnij")))

Rozdział 10. • Tworzenie aplikacji



291

Zmieniliśmy tu sposób wyświetlania planszy w reakcji na próby gracza. Program wyświetla ocenę próby i pokazuje wcześniejszą próbę na ekranie. Gracz może zmienić swoje typy i kontynuować grę. Wypróbujmy funkcję w środowisku REPL, aby zobaczyć, jakie dane wyjściowe generuje dla określonych danych wejściowych. clojurebreaker.views.welcome=> (board {:one "r" :two "b" :three "y" :four "g"}) | " | | | | -> " clojurebreaker.views.welcome=> (board {:one "r" :two "b" :three "y" :four "g" :exact 2 :unordered 0}) | "Dokładne: 2 Niedokładne: 0 | | | | | -> "

Jeszcze tylko jedna zmiana w definicji strony / i można rozpocząć grę! clojurebreaker/src/clojurebreaker/views/welcome.clj

(defpage "/" {:as guesses} (when-not (session/get :game) (session/put! :game (game/create))) (common/layout (board (or guesses nil))))

Strona przyjmuje argumenty z funkcji render, co pozwala przekazać je do funkcji częściowej board. Ponieważ board działa jak standardowa funkcja języka Clojure, przekazywanie argumentów jest bardzo proste. Przed uruchomieniem gry pozostało nam do wykonania jedno zadanie. Trzeba dodać bibliotekę math.combinatorics do pliku project.clj w projekcie opartym na frameworku noir.

292



Programowanie w języku Clojure

clojurebreaker/project.clj (defproject clojurebreaker "0.1.0-SNAPSHOT" :description "Gra Clojurebreaker z książki Programowanie w Clojure" :dependencies [[org.clojure/clojure "1.3.0"] [org.clojure/math.combinatorics "0.0.1"] [org.clojure/test.generative "0.1.3"] [noir "1.2.0"]] :main clojurebreaker.server)

Teraz wystarczy ponownie uruchomić instrukcje można do woli grać w Clojurebreaker.

lein deps

i

lein run,

po czym

10.5. Instalowanie kodu Aplikacja jest już gotowa. Pora udostępnić ją światu! Instalowanie produkcyjnej wersji aplikacji może być długim i żmudnym zadaniem, które obejmuje dodawanie kodu lub modyfikowanie elementów aplikacji. Na szczęście możemy pominąć wszystkie te etapy i od razu przejść do rzeczy. Istnieje serwis, dzięki któremu instalowanie aplikacji języka Clojure jest niezwykle proste. Ten serwis to Heroku5. Najpierw trzeba założyć konto w serwisie Heroku, jednak potem proces instalacji polega tylko na umieszczeniu kodu w repozytorium systemu kontroli kodu źródłowego. To już wszystko, co trzeba zrobić — instalacja polega na wywołaniu instrukcji git push. Aby móc zainstalować aplikację, trzeba zarejestrować konto w serwisie Heroku. Jest to prosty i bezpłatny proces. Serwisu Heroku oferuje też płatne konta, ale przykładowy kod można zainstalować bez ponoszenia żadnych kosztów. Po zarejestrowaniu się należy utworzyć aplikację na platformie Heroku.

Plik Procfile Z programem należy powiązać specjalny plik, informujący serwis Heroku, kiedy należy uruchomić dany program. Plik ten nosi nazwę Procfile. Dodajmy go do katalogu głównego projektu. web: lein run

5

http://heroku.com

Rozdział 10. • Tworzenie aplikacji



293

Jeśli chcesz się upewnić, że aplikacja jest prawidłowo skonfigurowana, możesz zainstalować dodatek foreman i wywołać instrukcję foreman start. Jeśli aplikacja zostanie poprawnie uruchomiona, także w serwisie Heroku nie powinno być problemów. foreman start 14:35:28 14:35:33 14:35:33 14:35:33 14:35:33 14:35:33 14:35:33 14:35:33

web.1 web.1 web.1 web.1 web.1 web.1 web.1 web.1

| | | | | | | |

started with pid 34538 Starting server... 2011-12-09 14:35:33.042:INFO::Logging to STDERR Server started on port [5000]. You can view the site at http://localhost:5000 # 2011-12-09 14:35:33.044:INFO::jetty-6.1.26 2011-12-09 14:35:33.070:INFO::Started [email protected]:5000

Biblioteka serwisu Heroku Serwis Heroku udostępnia wygodny sposób na programową interakcję z platformą. Jedynym problemem jest to, że zastosowanie tego sposobu wymaga wykonania dodatkowych etapów konfiguracyjnych. Potrzebny jest język programowania Ruby i narzędzie RubyGems. Możliwe powinno być pobranie tych elementów za pomocą menedżera pakietów. Zawsze można też pobrać odpowiednie pliki ze strony http://ruby-lang.org. Do zainstalowania biblioteki serwisu Heroku należy użyć frameworku RubyGems. gem install heroku

Git Teraz dostępne jest polecenie heroku, które należy stosować we wszystkich interakcjach z wykorzystaniem serwisu Heroku. Następny krok polega na utworzeniu repozytorium git dla bazowego kodu gry Clojurebreaker. Odpowiednią wersję gita należy pobrać za pomocą menedżera pakietów lub ze strony http://git-scm.org. Po zainstalowaniu gita wystarczy uruchomić instrukcję głównym aplikacji Clojurebreaker. git init -> Initialized empty Git repository in ~/clojurebreaker/.git/

git init

w katalogu

294

Programowanie w języku Clojure



W ten sposób można utworzyć początkową powłokę gita w aplikacji. Następnie należy lokalnie przesłać kod. git add . git commit -m "Initial commit" [master (root-commit) dd4f8a8] initial commit 13 files changed, 65860 insertions(+), 0 deletions(-) create mode 100644 .gitignore create mode 100644 project.clj create mode 100644 resources/public/css/reset.css create mode 100644 src/clojurebreaker/game.clj create mode 100644 src/clojurebreaker/models/game.clj create mode 100644 src/clojurebreaker/server.clj create mode 100644 src/clojurebreaker/views/common.clj create mode 100644 src/clojurebreaker/views/welcome.clj

Zapisywanie programu w docelowym miejscu Kod jest już przygotowany w lokalnym systemie do przeniesienia do serwisu Heroku. Pora utworzyć aplikację Heroku, w której znajdzie się program. heroku create --stack cedar Creating freezing-waterfall-3937... done, stack is cedar http://freezing-waterfall-3937.herokuapp.com/ | [email protected]:freezing-waterfall-3937.git Git remote heroku added

Argument --stack pozwala określić dostępną w serwisie Heroku platformę obsługującą aplikacje języka Clojure. Aplikacja ta otrzymuje losową nazwę określoną przez serwis Heroku. Nazwa ta jest częścią adresu URL, pod który należy przejść, aby zobaczyć program. Później można zmienić tę nazwę.

Instalowanie Ostatni krok to wywołanie instrukcji odpowiada serwis Heroku.

git push.

git push heroku master Counting objects: 24, done. Delta compression using up to 4 threads. Compressing objects: 100% (15/15), done. Writing objects: 100% (24/24), 230.67 KiB, done. Total 24 (delta 0), reused 0 (delta 0) -----> Heroku receiving push -----> Clojure app detected -----> Installing Leiningen Downloading: leiningen-1.5.2-standalone.jar

Za wszystkie dalsze operacje

Rozdział 10. • Tworzenie aplikacji



295

Downloading: rlwrap-0.3.7 Writing: lein script -----> Installing dependencies with Leiningen ... Dane wyjściowe pominięto ... -----> Discovering process types Procfile declares types -> web -----> Compiled slug size is 12.8MB -----> Launching... done, v4 http://stormy-water-3888.herokuapp.com deployed to Heroku

Aplikacje można otworzyć także za pomocą polecenia heroku. heroku open

Polecenie to powoduje otwarcie przeglądarki i wyświetlenie w niej zainstalowanej aplikacji. Pierwsze wczytanie aplikacji może chwilę potrwać, jednak późniejsze żądania są obsługiwane szybciej. Dodatkowy samouczek dotyczący serwisu Heroku i języka Clojure znajdziesz w materiałach dla programistów pod adresem http://devcenter.heroku.com/articles/clojure-web-application. Życzymy miłej zabawy z nową aplikacją sieciową w języku Clojure. Usprawnij ją, nadaj jej osobisty charakter i udostępnij innym!

10.6. Pożegnanie Gratulacje. Przebyłeś długą drogę w krótkim czasie. Poznałeś wiele elementów, które wspólnie sprawiają, że Clojure to znakomity język. Te elementy to Lisp, Java, programowanie funkcyjne i bezpośrednio wyrażana współbieżność. W tym rozdziale zobaczyłeś jeden z wielu możliwych procesów rozwijania kompletnych aplikacji w języku Clojure. Omówiliśmy tu tylko niewielką część możliwości, jakie daje Clojure. Mamy nadzieję, że będziesz kontynuował przygodę z tym językiem i staniesz się aktywnym członkiem skupionej wokół niego społeczności. Możesz dołączyć do listy mailingowej6 i zaglądać na kanał IRC7. Społeczność użytkowników języka Clojure jest przyjazna i otwarta. Czekamy na wieści od Ciebie.

6 7

http://groups.google.com/group/clojure Kanał #clojure na serwerze irc.freenode.net.

296



Programowanie w języku Clojure

Dodatek A

Edytory kodu

P

oziom obsługi języka Clojure w edytorach szybko się zmienia, dlatego niektóre informacje z tego dodatku wkrótce mogą stać się nieaktualne. Najnowsze doniesienia znajdziesz na stronie Getting Started1 w wiki społeczności. Strona ta prowadzi do opisu wielu środowisk programowania.

Kod w języku Clojure jest zwięzły i pozwala wyrażać wiele konstrukcji. Dlatego obsługa języka w edytorach nie jest tu tak istotna jak dla innych języków. Przydatny jest jednak edytor, który potrafi przynajmniej poprawnie dodawać wcięcia i parować nawiasy. W trakcie pisania tej książki używaliśmy Emacsa i wtyczki clojure-mode2 napisanej przez Jeffreya Chu. Obsługa języka Clojure w Emacsie jest całkiem dobra, jeśli jednak nie używałeś wcześniej tego edytora, wygodniejsze może być korzystanie z jednego ze znanych Ci edytorów z poniższej listy.

1 2

Edytor

Nazwa projektu

Adres URL projektu

Eclipse Emacs IntelliJ IDEA jEdit NetBeans TextMate Vim

Counterclockwise clojure-mode La Clojure jedit modes enclojure textmate-clojure VimClojure

http://code.google.com/p/counterclockwise/ http://github.com/jochu/clojure-mode http://plugins.intellij.net/plugin/?id=4050 http://github.com/djspiewak/jedit-modes/tree/master/ http://enclojure.org https://github.com/swannodette/textmate-clojure http://www.vim.org/scripts/script.php?script_id=2501

http://dev.clojure.org/display/doc/Getting+Started. http://github.com/jochu/clojure-mode.

298



Programowanie w języku Clojure

Dodatek B

Bibliografia

[Goe06] Goetz Brian, Java Concurrency in Practice. Addison-Wesley, Reading, MA, 2006. [Hof99] Hofstadter Douglas R., Gödel, Escher, Bach: An Eternal Golden Braid. Basic Books, Nowy Jork, NY, Stany Zjednoczone, 20th Anniv, 1999. [McC06] McConnell Steve, Software Estimation: Demystifying the Black Art. Microsoft Press, Redmond, WA, 2006.

300



Programowanie w języku Clojure

Skorowidz

A agenty, 158 bieżąca wartość, 159 sprawdzanie poprawności, 160 transakcje, 161 tworzenie, 158 wykrywanie błędów, 160 algebra relacji, 110 Apache Ant, 244 atomy, 157 dereferencja, 157 tworzenie, 157 ustawienie wartości, 157

B biblioteki, 20, 40 dosłowne podawanie nazwy, 40 drzewo właściwości systemowych, 238 znajdowanie dokumentacji, 42

C Clojure, 15, 23 aktualizacja, 261 aspekty, 23 biblioteki, 20, 40 drzewo właściwości systemowych, 238 inspector, 238

Lazytest, 286 math.combinatorics, 275 Midje, 286 sekwencje, 87 test, 239 test.generative, 278 cechy języka, 24, 209 czytnik, 46 makra odczytu, 55 edytory kodów, 297 funkcje, 56 czyste, 38 dodawanie sugestii typów, 253 impure, 38 wywołanie, 56 wyższego rzędu, 25 Java, 15, 33, 243 dostęp, 243 interfejsy, 183, 255 javadoc, 70 kompilacja AOT, 248 konstrukcja new, 68 korzystanie z kolekcji, 258 mechanizm wywołań zwrotnych, 256 parsery SAX, 255 środowisko uruchomieniowe, 34 tworzenie klas, 255

302



Clojure Java tworzenie pośredników, 255 tworzenie tablic, 258 wykorzystanie możliwości, 247 wywoływanie kodu, 68 wywoływanie metody, 69 język funkcyjny, 30 cechy, 30 konstrukcje składniowe, 24, 46 Leiningen, 34, 261 liczby całkowite, 248 operatory, 248 Lisp, 15, 24, 27 makra, 28, 201 taksonomia, 216 maszyny JVM, 29 metadane, 77 model czasu, 24 niezmienne struktury danych, 27 obsługa wyjątków, 244 kontrolowane wyjątki, 245 porządkowanie zasobów, 245 reagowanie na wyjątki, 247 optymalizowanie wydajności, 250 dodawanie sugestii typów, 253 używanie typów prostych, 250 pobieranie zależności, 261 połączenie z witryną, 261 programowanie, 16 funkcyjne, 18, 24, 115 pętle, 74 reguły, 120 współbieżne, 32 protokoły, 24, 179, 184 przestrzeń nazw, 36, 61, 65 rejestrowanie informacji, 264 rekordy, 193 rozkładanie struktury, 63 możliwości, 65 sekwencje, 29, 81 biblioteka, 87 cechy, 83

Programowanie w języku Clojure

Java, 98 manipulowanie, 197 wymuszanie realizacji, 97 wyrażenia listowe, 95 stan, 147 model aktualizacji, 163 model funkcyjny, 149 model referencyjny, 149 sterowanie przepływem, 70 instrukcje, 70 makra, 71 rekurencja, 72 środowisko REPL, 35 zmienne specjalne, 36 tożsamość, 147 typy referencyjne, 147 tworzenie aplikacji, 269 gra Clojurebreaker, 270 typy danych, 179, 188 cechy, 188 typy referencyjne, 38 atom, 38 wartość, 147 wiązania, 61 wielometody, 225 współbieżność, 18, 32, 148 powody stosowania, 148 sytuacja wyścigu, 149 zakleszczenie, 149 zmienne, 36, 61 cechy, 62

D definicja pustego łańcucha znaków, 26 duck typing, 250

F funkcje, 56 anonimowe, 58 konstrukcja, 59 powody tworzenia, 58 stosowanie, 61 unikanie, 223

Skorowidz



bez sprawdzania przepełnienia, 251 częściowe wywołanie, 134 czyste, 38, 116 niezmienne dane, 116 dodawanie sugestii typów, 253 efekty uboczne, 71 funkcje wyższego rzędu, 25 leniwe, 127 liczba argumentów, 57 listy, 105 łańcuchy znaków dokumentacji, 42 odwzorowania, 53, 106 tworzenie, 108 operatory matematyczne, 47 predykaty, 52, 56 przejrzyste referencyjnie, 118 memoizacja, 119 rozwinięcie funkcji, 135 implementacja, 135 sekwencje, 83 filtrowanie, 91 predykaty, 92 przekształcanie, 93 tworzenie, 88 wyrażenia regularne, 100 słowa kluczowe, 54 wektory, 105 wiązania, 62 zasięg leksykalny, 63 wywołanie, 47, 56 notacja przedrostkowa, 47 notacja wrostkowa, 47 zbiory, 109 złożone, 134

H hermetyzacja, 119 efekty uboczne, 120 Heroku, 292 biblioteka, 293 git init, 293 git push, 294 plik Procfile, 292

polecenie heroku, 293 rejestracja konta, 292 repozytorium git, 293

I instrukcje add-points, 171 agent-errors, 160 alias, 233 alter, 152 assoc, 171 atom, 157 binding, 164 clear-agent-errors, 160 commute, 154 concat, 210 cond, 227 condp, 182 conj, 38, 86 cons, 83, 132, 171 def, 38, 61, 218 defmacro, 203 defmethod, 228 defmulti, 175, 228 defn, 57 defonce, 133 defrecord, 27, 54 deref, 39, 150 derive, 236 dir, 279 do, 71, 219 doall, 97 dosync, 33, 150 faux-curry, 135 file-seq, 101 first, 76, 83 fn, 58 for, 75 force, 221 if, 70, 202 import, 67, 199 in-ns, 66, 281 inspect-tree, 238

303

304



instrukcje lazy-cat, 129 lazy-seq, 142 lein deps, 261 lein test, 40 let, 212 line-seq, 102 loop, 72 loop/recur, 72 macroexpand, 207 map, 219 memoize, 165 next-counter, 155 partial, 134 partition, 132 prefer-method, 232 println, 116, 203 project, 112 proxy, 176 pst, 37 recur, 72, 120, 130 ref, 156 refer, 41 ref-set, 150 require, 40, 41, 279 reset!, 157 rest, 83 score print-table, 276 select, 112 send, 159 send-off, 161 seq, 101, 258 set, 90 set!, 167, 254 source, 43 start, 163 swap!, 158 take-while, 91 trampoline, 139 unless, 202 use, 41, 66 var, 62 with-open, 102

Programowanie w języku Clojure

J Java, 15, 16, 243 interfejsy, 183 wady, 183 zalety, 183 klasy BigDecimal, 249 BigInteger, 249 Character, 50 ChunkedSeq, 84 Person, 26 Random, 68 StringUtils, 74 Thread, 33 WidgetFactory, 205 metody egzemplarza, 204 obsługa, 245 obsługa wyjątków, 244 kontrolowane wyjątki, 244 porządkowanie zasobów, 245 sekwencje, 98 kolekcje sekwencyjne, 98 wywoływanie kodu, 68

K kod gry Snake, 169 interfejs GUI, 169, 175 aktualizowanie, 175 defmulti, 175 fill-point, 175 game, 176 game-panel, 175 paint, 175 proxy, 176 tworzenie nowej gry, 176 wyświetlanie węża i jabłka, 175 model funkcyjny, 169 add-points, 170, 171 assoc, 171 cons, 171 dodawanie punktów, 170 eats?, 172

Skorowidz

head-overlaps-body?, 172 lose?, 172 move, 171 point-to-screen-rect, 171 ruch węża, 171 sprawdzanie warunków zwycięstwa, 172 turn, 173 tworzenie nowego jabłka, 171 tworzenie węża, 171 tworzenie zestawu stałych, 169 win?, 172 wykrywanie zetknięcia, 172 zjadanie jabłka, 172 zmiana kierunku węża, 173 model zmiennego stanu, 169, 173 reset-game, 173 update-direction, 174 update-positions, 174 wydłużanie węża, 174 zmiany pozycji węża i jabłka, 173 konstrukcje składniowe, 24 liczba, 46 BigDecimal, 48 BigInt, 48 całkowita, 48 Ratio, 48 wektor, 47 zmiennoprzecinkowa, 48 lista, 46, 47 wywoływanie funkcji, 47 łańcuch znaków, 46, 49 wywołanie, 50 odwzorowania, 46, 52 sekwencje, 85 rekordy, 52 wywoływanie, 54 słowo kluczowe, 46, 53 symbol, 46, 49 wartość logiczna, 46 reguły działania, 51 wartość nil, 46 wektory, 46 sekwencje, 84



zbiory, 46 sekwencje, 85 znak, 46

L Leiningen, 34, 261 pobieranie zależności, 35 wtyczka lein-noir, 287 leniwe sekwencje, 121, 125 moment realizacji, 127 Lisp, 15, 24

M makra, 28, 201 amap, 260 and, 207, 216 areduce, 260 assert, 222 bad-unless, 207 bench, 212 binding, 164, 221 chain, 209 comment, 217 cond, 30 czas kompilacji, 203 czas rozwijania makra, 203 declare, 137, 218 definterface, 183 defpartial, 288 defprotocol, 185 defrecord, 28, 193 defstruct, 218 deftype, 189 delay, 220 dosync, 221 dotimes, 250 extend-protocol, 186 extend-type, 186 for, 95 import-static, 220 is, 239 język szablonów, 210

305

306



makra konstrukcje specjalne, 215 lazy-seq, 125 let, 221 letfn, 124 manipulowanie listą, 210 ns, 67 obsługa kilku konstrukcji, 208 obsługa szablonów, 211 przechwytywanie symboli, 213 splicing unquote, 211 unquote, 211 przetwarzanie, 203 reguły stosowania, 202 reify, 198 rozwijanie, 206 rekurencyjne, 207 tworzenie, 212 sprawdzanie, 206 sterowanie przebiegiem programu, 202 tablice Javy, 260 taksonomia, 214 kategorie, 216 time, 212, 221 tworzenie, 203 tworzenie nazw, 212 nazwy lokalne, 214 tworzenie zmiennych, 218 unikanie funkcji anonimowych, 223 unless, 203 upraszczanie, 209 wartościowanie argumentów, 203, 206 nakładki, 221 odraczanie, 220 warunkowe, 216 when, 208 when-not, 202, 208 with-open, 221, 245 with-out-str, 221 współdziałanie z Javą, 219 wzorzec projektowy, 205 makra odczytu, 55 dereferencja, 56

Programowanie w języku Clojure

funkcja anonimowa, 56 komentarz, 55, 56 metadane, 56 podawanie zmiennych, 56 przytaczanie, 56 syntax-quote, 56 unquote, 56 unquote-slicing, 56 wzorzec wyrażenia regularnego, 56 maszyny JVM, 15, 29 autorekurencja, 124 Clojure, 16 Java, 16 memoizacja, 165 metadane, 77 standardowe klucze, 78 model czasu, 24

N niezmienne struktury danych, 27

O optymalizacja TCO, 73, 123

P pamięć STM, 32, 151 technika MVCC, 153 transakcje, 151 ACI, 151 aktualizacje, 151 polimorfizm, 231 programowa pamięć transakcyjna, Patrz pamięć STM programowanie, 16, 18, 31, 97, 148 funkcyjne, 18, 24, 30, 116 autorekurencja, 124 czyste funkcje, 116 definicje rekurencyjne, 121 leniwe podejście, 118, 121 leniwe sekwencje, 121, 125 problemy rekurencyjne, 138 prosta rekurencja, 122

Skorowidz

reguły, 120 rekurencja, 118 rekurencja końcowa, 123 rekurencja wzajemna, 136 trwałe struktury danych, 117 wielokrotne wykorzystanie kodu, 119 współużytkowanie struktur, 117 wyrażenie listowe, 31 zalety, 119 imperatywne, 31 kod pętli, 74 prawidłowy proces pisania kodu, 273 sekwencyjne, 97 testowanie kodu, 274 BDD, 286 generowanie danych testu, 279 podstawowe etapy, 278 programowe sprawdzanie poprawności, 280 przeprowadzanie testów, 276, 283 sprawdzanie poprawności danych wyjściowych, 277 sprawdzanie poprawności kodu, 278 TDD, 286 testy jednostkowe, 286 testy regresji, 277 tworzenie danych wejściowych, 275 zgłaszanie błędu, 284 współbieżne, 148 powody stosowania, 148 wykorzystanie abstrakcji, 180 obsługa dodatkowych typów, 181 odczyt, 180 zapis, 180 protokoły, 24, 179, 184 zalety, 184 przestrzeń nazw, 36, 65 clojure.core, 66 clojure.string, 67 funkcje, 68 modyfikacja deklaracji, 264 myapp, 66 user, 36, 65 wiązania, 65



307

R referencje, 150 sprawdzanie poprawności, 156 transakcje, 150 ACID, 151 aktualizowanie informacji, 152 atomowe, 151 izolowane, 151 licznik, 155 pamięć STM, 151 skoordynowane, 151 spójne, 151 trwałe, 151 wartość wewnątrztransakcyjna, 153 właściwości, 151 zmiana encji, 150 tworzenie, 150 rekordy, 193 dodawanie metod, 195 dostęp do pól, 194 implementacja protokołu, 195 Note, 193 odwzorowania, 193 Person, 26 tworzenie, 193 rekurencja, 72, 118 autorekurencja, 124 definicje, 121 indukcja, 121 przypadek bazowy, 121 problemy rekurencyjne, 138 prosta rekurencja, 122 przyspieszanie, 143 rekurencja końcowa, 123 optymalizacja TCO, 123 rekurencja wzajemna, 136 memoizacja, 144 optymalizowanie, 139 przekształcanie na autorekurencję, 138 zastępowanie leniwym podejściem, 141

308

Programowanie w języku Clojure



S sekwencje, 29, 81 biblioteka, 86, 87 funkcje, 88 cechy, 83 filtrowanie, 91 funkcje, 91 funkcje, 83 into, 86 Java, 98 kolekcje sekwencyjne, 82, 98 niezmienne, 87 odwzorowania, 85 predykaty, 92 przekształcanie, 93 funkcje, 93 strumienie, 102 system plików, 101 funkcje, 101 tryb leniwy, 86, 96 stosowanie, 97 zalety, 96 tworzenie, 88 funkcje, 88 wektory, 84 wymuszanie realizacji, 97 wyrażenia regularne, 100 funkcje, 100 zbiory, 85 stan, 147 agenty, 147, 158 bieżąca wartość, 159 sprawdzanie poprawności, 160 transakcje, 161 tworzenie, 158 wykrywanie błędów, 160 atomy, 147, 157 dereferencja, 157 tworzenie, 157 ustawienie wartości, 157 model aktualizacji, 163 modele zarządzania, 168

referencje, 147, 150 transakcje, 150 tworzenie, 150 wywoływane zwrotnie metody, 166 zmienne, 147, 163

Ś środowisko REPL, 35 zmienne specjalne, 36

T tworzenie aplikacji, 269 instalowanie kodu, 292 git push, 292, 294 Heroku, 292 plik Procfile, 292 testowanie kodu wyniku, 274 defspec, 282 dir, 279 failures, 284 generate-test-data, 282 generowanie danych testu, 279 in-ns, 281 programowe sprawdzanie poprawności, 280 random-secret, 280 require, 279 score print-table, 276 score-inputs, 278 selections, 275 sprawdzanie poprawności danych wyjściowych, 277 test dla danych wejściowych, 276 test-dirs, 283 test-namespaces, 283 test-vars, 283 testy regresji, 277 tworzenie danych wejściowych, 275 zgłaszanie błędu, 284 tworzenie interfejsu, 287 board, 290 defpartial, 288 dodanie stanu, 287

Skorowidz



framework sieciowy, 287 interfejs gracza, 288 render, 290 session/flash-put!, 290 session/get, 287 session/put!, 287 session/remove!, 290 wynik, 270 exact-matches, 271 frequencies, 271 generowanie, 270 merge-with, 272 score, 273 select-keys, 272 unordered-matches, 272 typy danych, 179, 188 anonimowe egzemplarze, 198 cechy, 188 CryptoVault, 179, 188 definiowanie metod, 189 dostęp do pól, 189 rekordy, 193 tworzenie, 189 typy referencyjne, 38 atom, 38

W wiązania, 62 bindings, 63 dynamiczne, 164 modyfikacje na odległość, 164 dynamicznie określany zasięg, 164 funkcje, 62 zasięg leksykalny, 63 lokalne, 164 mechanizm rozkładania struktury, 63 podstawowe, 61 przestrzeń nazw, 65 zmienne, 62

309

wielometody, 225 account-level, 235 assert-expr, 240 definiowanie, 228 dodawanie implementacji metod, 228 domyślne działanie, 230 doraźne taksonomie, 233 dziedziczenie, 236 mechanizm wybierania implementacji, 229 my-print, 228, 231 paint, 175 service-charge, 235 stosowanie, 237 reguły, 241 wybieranie metod, 229 dla typu kolekcji, 231 na podstawie typu pierwszego argumentu, 231 rozwiązywanie konfliktów, 232 współbieżność, 32 wyrażenie listowe, 94 elementy, 94

Z zmienne, 36, 61, 163 aliasy, 62 cechy, 62 mechanizm rozkładania struktury, 63 metadane, 62 rodzaje definicji, 133 specjalne, 164 modyfikacje na odległość, 164 tworzenie, 133 makra, 218 wiązania, 62 lokalne dla wątku, 164 podstawowe, 61

Smile Life

When life gives you a hundred reasons to cry, show life that you have a thousand reasons to smile

Get in touch

© Copyright 2015 - 2024 AZPDF.TIPS - All rights reserved.