Typy danych pozwalają klasyfikować wartości pod względem różnych cech i wykształcać relacje między tak powstałymi klasami. Programiście pomaga to definiować operacje przeprowadzane na danych różnych rodzajów, a mechanizmom języka zarządzać pamięcią i wykrywać niektóre rodzaje błędów. W Clojure mamy do czynienia z kilkoma powiązanymi ze sobą systemami typów, które możemy rozszerzać, a wykorzystując ich polimorficzne mechanizmy jesteśmy w stanie abstrahować zarządzanie danymi i budować ujednolicone interfejsy wymiany informacji.
Systemy typów
Z powodu korzystania z jednowymiarowej pamięci operacyjnej o skończonym rozmiarze program komputerowy musi być w stanie przewidzieć (w czasie kompilacji lub uruchamiania), jak wiele miejsca zarezerwować na określone dane i jaką nadać im postać. Poza tym programista powinien móc na jakiejś podstawie rozróżniać, z jakimi rodzajami informacji ma do czynienia, aby stosować względem reprezentujących je struktur odpowiednio dobrane algorytmy. Aby rozwiązać te kwestie, można wprowadzić klasyfikację rodzajów danych, a każdą wartość przechowywaną w pamięci odpowiednio oznaczać.
Typy danych
Typ danych (ang. data type) to taka klasa wartości, którą cechują pewne właściwości wspólne dla danego rodzaju informacji i sposobów zarządzania nią przez mechanizmy języka programowania. Właściwościami tymi mogą być zakresy, wielkości zajmowanych przestrzeni, sposoby uporządkowania czy reprezentowania danych, a także inne atrybuty przyjęte za wyróżniające w ramach tak zwanego systemu typów.
W teorii typy danych jednoznacznie określają, jakie operacje możemy wykonywać na wartościach. W praktyce wykorzystywanie typów do tego rodzaju weryfikacji może być skomplikowane i mało skuteczne, szczególnie podczas testowania złożonych danych biznesowych aplikacji. W związku z tym poza typami – które w językach dynamicznych pomagają bardziej kompilatorowi bądź interpreterowi, niż programiście – stosuje się także dodatkowe mechanizmy testowania i walidacji danych.
Typy dostarczają kompilatorowi bądź interpreterowi informacji o tym, jak zarządzać przestrzenią pamięciową i automatycznymi konwersjami danych jednego rodzaju do danych innego. W zależności od języka programowania wiedza o typach używanych w programie będzie mniej lub bardziej eksponowana. W językach z tzw. silnym typizowaniem mamy obowiązek oznaczania danych typami, aby kompilator mógł wyłapać niezgodności między naszymi deklaracjami a rzeczywistymi zastosowaniami; np. przekazywanie do funkcji liczby, gdy wymagana jest sekwencja znakowa będzie błędem kompilacji.
Jednym z zastosowań wiedzy o typach danych może być polimorficzne optymalizowanie kodu, które polega na generowaniu wielu wersji tego samego podprogramu w zależności od rodzajów przyjmowanych obiektów wejściowych. Na przykład argumentem tworzonej funkcji może być liczba całkowita lub łańcuch znakowy. Podczas kompilacji powstaną wtedy dwa warianty funkcyjnego obiektu w zależności od typu akceptowanego wejścia. Wygenerowana zostanie też odpowiednia funkcja dyspozycyjna, której zadaniem będzie wywoływanie pasującego do typu podprogramu obsługi. Dzięki temu kompilator będzie w stanie zastosować optymalizacje związane z konkretnym rodzajem przetwarzanych wartości w każdym wariancie funkcji (np. w przeciwieństwie do łańcuchów znakowych dane numeryczne nie muszą być rygorystycznie sprawdzane pod kątem liczby elementów czy symboli terminujących).
Charakterystyki systemów typów
System typów (ang. type system) to w językach programowania mechanizm, który dokonuje oznaczania każdej wartości, odniesienia do wartości lub obszaru przechowującego wartość typem danych, a także dba o przestrzeganie reguł typizowania i zachowywania relacji między typami.
Można powiedzieć, że każdemu typowi danych przypisany jest w ramach systemu typów zestaw reguł, które muszą być przestrzegane. Chroni to przed występowaniem błędów w czasie pracy aplikacji i pozwala tworzyć przewidywalne interfejsy wymiany danych między różnymi częściami oprogramowania.
Rodzaje typizowania
Sprawdzanie, czy operacje i reguły systemu typów są stosowane, może odbywać się podczas kompilacji lub w trakcie działania programu. W tym pierwszym przypadku nazwiemy ten proces statycznym typizowaniem (ang. static typing), a w drugim dynamicznym typizowaniem (ang. dynamic typing).
W językach statycznie typizowanych sposobem na zwolnienie programisty z obowiązku określania typu każdego wyrażenia jest inferencja typu (ang. type inference). Polega ona na automatycznym wykrywaniu przez kompilator typów danych poszczególnych wyrażeń i wewnętrznym oznaczaniu ich we właściwy sposób.
Możemy wyróżnić języki programowania, które są słabo typizowane (ang. weakly typed) i mocno typizowane (ang. strongly typed). Te ostatnie dokonują sprawdzania typów danych podczas przekazywania wartości do podprogramów (np. funkcji) czy przypisywania ich do zmiennych, natomiast pierwsze nie wprowadzają takich restrykcji, lecz programista musi być bardziej czujny, aby nie dokonywać operacji na danych, których rodzaje (typy) nie mogą być przez te operacje poprawnie obsłużone. Gdy programista o to nie zadba, wystąpią usterki podczas pracy programu, ponieważ nie dojdzie do wykrycia niekompatybilności na etapie kompilacji.
W Clojure mamy do czynienia z dynamicznym typizowaniem, czyli dynamicznym sprawdzaniem typów (ang. dynamic type-checking). Oznacza to, że sprawdzanie typów danych i kompatybilności pamięciowych obiektów pod tym względem odbywa się w czasie uruchamiania programu, a nie na etapie kompilacji.
Język Clojure to również typizowanie słabe (ang. weak typing), które polega na tym, że programista nie musi deklarować typów danych będących w użyciu. Jest to możliwe m.in. dzięki inferencji typów i wyposażeniu języka w generyczne konstrukcje, które potrafią operować na danych dowolnego rodzaju (szczególnie w przypadku kolekcji i sekwencji).
Hierarchie typów
W obrębie systemu typów mogą być utrzymywane hierarchiczne relacje
przynależności. Możemy wtedy mówić o podtypach (ang. subtypes) i nadtypach
(ang. supertypes) względem wybranych typów. Tego rodzaju mechanizmy pozwalają nie
tylko rozróżniać relacje klas wartości i korzystać z tych informacji do sterowania
logiką aplikacji, ale często też bywają zintegrowane z konstrukcjami pozwalającymi
operować na danych. Na przykład w językach mocniej typizowanych do funkcji, której
typ argumentów z góry określono, możemy przekazywać wartości również ich
podtypów. W Javie będą to m.in. metody, które przyjmują argumenty typu Object
,
określonego klasą będącą przodkiem niemal wszystkich innych klas obiektów (za
wyjątkiem samej klasy Object
oraz interfejsów). Możemy więc do takich funkcji
przekazywać wartości innych typów (np. String
czy Integer
), które są
bezpośrednimi lub pośrednimi podtypami typu Object
.
Obsługę podtypów i nadtypów, pozwalającą operować na przekazywanych wartościach w wyżej opisany sposób, nazywamy polimorfizmem podtypowym (ang. subtype polymorphism) lub polimorfizmem inkluzyjnym (ang. inclusion polymorphism). Dzięki niej możemy abstrahować operacje na danych, zachowując relacje między ich określonymi typami i korzystać z tzw. koercji.
Role systemów typów
Podsumowując, możemy wyróżnić następujące funkcje typizowania:
- rozróżnianie danych biznesowych (np. typy
Człowiek
iRoślina
); - rozróżnianie danych implementacyjnych (np. typy
String
iInteger
); - określanie relacji między klasami wartości (np. typ
Zwierzę
i podtypSsak
); - określanie wymogów interfejsów odnośnie danych wejściowych i wyjściowych (np. typy argumentów czy wartości zwracanych przez funkcje),
- porównywanie właściwości konstruktów służących do przechowywania danych (np. możliwych zakresów czy sposobów odzwierciedlania wartości w pamięci).
Systemy typów w Clojure
Clojure jest językiem słabo i dynamicznie typizowanym, chociaż niektóre optymalizacje mogą wykorzystywać typizowanie statyczne JVM, a wybrane konstrukcje (np. argumenty i wartości zwracane przez funkcje czy niektóre klasy wartości) możemy oznaczać typami, chociaż nie jest to obowiązkowe i nie umożliwia wprowadzenia mocnego typizowania.
System typów języka Clojure składa się z trzech podsystemów:
- obiektowego systemu typów platformy gospodarza,
- podstawowego systemu typów platformy gospodarza,
- doraźnego, hierarchicznego systemu typów.
Obiektowy system typów
Platformy uruchomieniowe języka Clojure, takie jak np. JVM czy CLR, są zorientowane obiektowo. W środowiskach takich możemy nie tylko korzystać z wbudowanych, ale też tworzyć nowe typy danych, nazywane czasem typami obiektowymi (ang. object types).
Obiekty
Obiekt (ang. object), czyli wartość określonego typu, będziemy nazywali instancją (ang. instance) lub egzemplarzem pewnej klasy.
Obiekty składają się ze zmiennych, w których przechowywane są ich dane, a także z odwołań do zdefiniowanych w klasach funkcji (zwanych metodami), które służą do operowania na zmiennych.
Klasy
Klasa (ang. class) charakteryzuje typ danych – jest wzorcem służącym do jego zdefiniowania i składa się z:
deklaracji zmiennych składowych (ang. member variables), zwanych też polami (ang. fields) lub atrybutami (ang. attributes), w których instancjach egzemplarze klas (obiekty) przechowują dane;
definicji funkcji składowych (ang. member functions), zwanych też metodami (ang. methods), które są operacjami, jakie można wykonywać na danych egzemplarza.
Klasa przypomina znaną z języka C konstrukcję zwaną strukturą (ang. structure), ale oprócz pól ma jeszcze funkcje, które pozwalają na nich operować. Egzemplarz klasy to – kontynuując analogię – zmienna, której typ określono konkretną strukturą. W praktyce mechanizmy programowania zorientowanego obiektowo można zaimplementować w języku C, jednak kody źródłowe takich programów byłyby dłuższe niż odpowiedniki w językach z wbudowaną obsługą klas i obiektów.
Dziedziczenie
Sposobem na dodawanie nowych metod lub pól do istniejących klas jest
dziedziczenie (ang. inheritance), czyli tworzenie klas pochodnych. Na przykład
klasa Zwierzę
może być klasą bazową względem klasy Ssak
. W tej ostatniej nie
musimy wtedy definiować właściwości i operacji typowych dla obiektów typu Zwierzę
,
bo zostaną one odziedziczone. Możemy więc dokonywać rozszerzania istniejących o nowe
lub ich modyfikowania.
Z kolei sposobem na zadbanie o to, aby różne klasy były wyposażone w określone z góry
zestawy operacji jest użycie tzw. interfejsów (ang. interfaces). Raz stworzony
interfejs, zawierający deklaracje metod, może być potem implementowany przez wiele
klas. Na przykład klasa Ssak
może implementować interfejs Zwierzęce
, który będzie
zawierał deklaracje operacji typowych dla zwierząt (np. pobierz_liczbę_łap
,
ustaw_wagę
itd.). Klasa Ssak
będzie musiała zdefiniować je wszystkie, ponieważ
interfejsy, w przeciwieństwie do klas bazowych, nie definiują metod, a tylko
zapowiadają konieczność ich wystąpienia.
Dowiedzieliśmy się wcześniej o polimorfizmie podtypowym, który jest obecny także w obiektowych systemach typów. Może być on wtedy określony również jako dziedziczenie interfejsów (ang. interface inheritance), natomiast zgodnie z przyjętą nomenklaturą bezpośredni nadtyp danego typu nazwiemy też:
- klasą bazową (ang. base class),
- klasą macierzystą (ang. parent class),
- nadklasą (ang. superclass);
a bezpośredni podtyp:
- klasą pochodną (ang. derived class),
- klasą potomną (ang. child class),
- podklasą (ang. subclass).
Za podtypy będziemy również uważali wszystkie klasy potomne, zwane czasem potomkami (ang. descendants), a za nadtypy wszystkie klasy nadrzędne, zwane przodkami (ang. ancestors).
Interfejsy
Interfejs (ang. interface) to zbiór deklaracji metod o wspólnym przeznaczeniu. Możemy traktować interfejs jak kontrakt między klasą, a resztą programu, w którym klasa przez obietnicę zaimplementowania interfejsu deklaruje, że określony nim zestaw zdolności znajdzie odzwierciedlanie w zdefiniowanych w niej metodach.
Klasa może implementować więcej, niż jeden interfejs, a każda zadeklarowana w interfejsie metoda musi być zdefiniowana w klasie. Co więcej, klasy mogą być później rozszerzane o obsługę nowych interfejsów. Jest to jeden z mechanizmów dynamicznego polimorfizmu.
Gdybyśmy na przykład stworzyli interfejs IPoliczalne
, w którym zadeklarowana byłaby
metoda zlicz
służąca do kalkulowania liczby elementów jakiejś abstrakcyjnej
kolekcji, każda klasa, którą oznaczylibyśmy jako implementującą IPoliczalne
,
musiałaby być wyposażona w metodę zlicz
. W przypadku klasy odpowiedzialnej za
reprezentowanie łańcuchów znakowych mogłaby ona obliczać liczbę znaków składających
się na przechowywany w egzemplarzu napis, w przypadku tablic liczbę ich elementów
itd. W programie moglibyśmy następnie sprawdzać, czy dana klasa implementuje nasz
interfejs i w ten sposób spodziewać się, że będzie zawierała określoną kontraktem
metodę zliczającą.
W Clojure zwykle nie będziemy musieli bezpośrednio zajmować się interfejsami systemu gospodarza, chyba że zechcemy dokonywać optymalizacji z wykorzystaniem operacji polimorficznych.
Definiowanie interfejsów, definterface
W Clojure możemy dynamicznie tworzyć nowe interfejsy Javy. Definicje zawierać mogą sygnatury metod, które będą musiały zostać zdefiniowane w każdej klasie implementującej interfejs.
Warto nadmienić, że w Clojure nie zaleca się bezpośredniego tworzenia interfejsów, ponieważ istnieje mechanizm zwany protokołami, który zastępuje je i wzbogaca.
Aby wytworzyć nowy interfejs Javy, możemy skorzystać z makra definterface
.
Użycie:
(definterface nazwa sygnatura…)
;
Nazwa powinna być niezacytowanym symbolem, natomiast sygnatury listowymi S-wyrażeniami w postaci:
(nazwa-metody [& argument…])
lub(^typ nazwa-metody [& argument…])
.
Dokładny opis użycia makra definterface
można znaleźć w rozdziale
poświęconym polimorfizmowi.
Generowanie interfejsów, gen-interface
Do niskopoziomowego generowania interfejsów Javy służy makro gen-interface
. Sprawia
ono, że w pamięci generowany jest kod bajtowy JVM dla interfejsu o podanej nazwie,
która powinna być pełną nazwą pakietu Javy. Dodatkowo, jeżeli program jest
kompilowany (w trybie AOT), w katalogu systemu plikowego, którego nazwa znajduje się
w zmiennej dynamicznej *compile-path*
umieszczony zostanie plik z rozszerzeniem
class
.
Użycie:
(gen-interface & opcja…)
.
Dokładny opis użycia makra gen-interface
można znaleźć w rozdziale
poświęconym polimorfizmowi.
Protokoły
Odpowiednikiem interfejsów Javy w Clojure są tzw. protokoły (ang. protocols), opisane w dalszej części. Bazują one na interfejsach, ale zawierają dodatkowe struktury danych i mechanizmy obsługi typowe dla Clojure.
Jedynymi sytuacjami, kiedy zechcemy wybrać interfejsy zamiast protokołów, będą te, gdzie ze względów wydajnościowych zdecydujemy, aby deklarowane funkcje przyjmowały jako argumenty lub zwracały jako wartości dane typów podstawowych.
Możemy postrzegać protokoły jako jeden ze sposobów na to, aby móc rozszerzać istniejące funkcje o obsługę nowych typów danych bez konieczności ingerowania w stworzony wcześniej kod. Jest to mechanizm polimorfizmu dynamicznego, który rozwiązuje tzw. problem wyrazu.
W protokole deklarujemy zestaw funkcji, z którego każda musi być potem zdefiniowana w wariancie dla każdego obiektowego typu danych implementującego dany protokół. Gdy dojdzie do wywołania takiej polimorficznej funkcji, wtedy typ danych wartości przekazywanej jako jej pierwszy argument zdecyduje o tym, do której konkretnie implementacji funkcji zostanie skierowane wywołanie.
Tworzone protokoły można przypisywać do typów danych – należy wtedy od razu zdefiniować zadeklarowane funkcje. Można też wzbogacać nowo tworzone typy danych o implementacje protokołów, a nawet rozszerzać istniejące typy o zgodność z wybranymi protokołami.
Zobacz także:
- „Protokoły”, rozdział XVII.
Definiowanie protokołów, defprotocol
Aby utworzyć nowy protokół, możemy skorzystać z makra defprotocol
.
Użycie:
(defprotocol nazwa łańcuch-dok? & opcja… & sygnatura…)
.
Podawana jako pierwszy argument nazwa protokołu powinna być niezacytowanym symbolem, który stanie się nazwą tworzonego interfejsu (w przypadku platformy JVM). Po nazwie można umieścić opcjonalny łańcuch dokumentujący wyrażony łańcuchem znakowym w postaci literału ujętego w znaki cudzysłowu. Kolejne argumenty makra to opcje i deklaracje polimorficznych funkcji wyrażone sygnaturami zbudowanymi na bazie listowych S-wyrażeń.
Makro defprotocol
wymaga, aby deklarowane metody zawsze specyfikowały pierwszy
argument, który będzie służył do przekazywania wartości dopasowanej do wykrytego typu
danych podczas podejmowania decyzji o tym, jaki wariant funkcji wywołać.
Dokładny opis użycia makra defprotocol
można znaleźć w rozdziale
poświęconym polimorfizmowi.
Tworzenie typów
Tworzenie nowych typów obiektowych polega na definiowaniu klas, które będą określały ich właściwości. W Clojure możemy korzystać z pewnych abstrakcyjnych konstrukcji, dzięki którym fakt generowania klas systemu gospodarza jest przed nami ukrywany, chociaż istnieją też odpowiednie makra pozwalające na bliską integrację z mechanizmami Javy i bardziej precyzyjne wpływanie na budowę klas.
Definiowanie typów, deftype
Makro deftype
pozwala definiować obiektowe typy danych. Jego
użycie sprawia, że zostanie dynamicznie wygenerowana kompilowana do kodu bajtowego
klasa definiująca typ danych, wraz z zawierającym ją pakietem Javy o nazwie
takiej samej, jak nazwa bieżącej przestrzeni nazw.
W fazie pełnej kompilacji programu (jeżeli do niej dojdzie) nazwa klasy z dołączoną
z przodu kropką i nazwą pakietu utworzą nazwę pliku, do której dodane będzie
rozszerzenie class
. Do pliku wpisana będzie definicja klasy i umieszczony on
zostanie w katalogu o nazwie ścieżkowej określonej dynamiczną zmienną specjalną
*compile-path*
(domyślnie: classes
).
W instancjach tworzonego z użyciem makra deftype
typu danych zawarte będą określone
przez programistę pola. Będzie też można korzystać z metod zadeklarowanych
w implementowanych protokołach Clojure oraz
interfejsach Javy, a zdefiniowanych w wywołaniu makra.
Do instancji stworzonego typu będzie można dynamicznie dodawać nowe pola i ich wartości. Tego rodzaju pola nazywamy polami rozszerzeniowymi (ang. extension fields).
Użycie:
(deftype nazwa pole… & opcja… & specyfikacja…)
.
Pierwszym argumentem makra powinna być nazwa typu, a kolejnymi (opcjonalnymi) nazwy pól, które mogą zawierać sugestie typów.
Wyrażane symbolami pola można opcjonalnie wyposażyć w metadane, które mają wpływ na ich obsługę:
:volatile-mutable
– ustawienie wartościtrue
(lub użycie^:volatile-mutable
) sprawi, że pole zostanie oznaczone jako mutowalny obiekt ulotny (modyfikatorvolatile
Javy);:unsynchronized-mutable
– ustawienie wartościtrue
(lub użycie:unsynchronized-mutable
) sprawi, że pole zostanie oznaczone jako obiekt mutowalny.
Wartości pól definiowanych typów są – podobnie jak inne wartości w Clojure – niemutowalne, ale zastosowanie powyższych metadanych może to zmienić. Należy korzystać z nich tylko wtedy, gdy naprawdę jest taka potrzeba i potrafimy obsłużyć ewentualne sytuacje graniczne, np. związane z obsługą współbieżności.
Uwaga: Nazwami pól nie mogą być __meta
oraz __extmap
– są to symbole
zarezerwowane, które służą do wewnętrznej obsługi metadanych i pól
rozszerzeniowych.
Po nazwach pól możemy podać opcje (obecnie nieobsługiwane) i tzw. specyfikacje (ang. skr. specs).
Każda specyfikacja powinna składać się z nazwy protokołu lub interfejsu platformy gospodarza, po której może (ale nie musi) pojawić się jedna lub więcej definicji metod w postaci:
(nazwa-metody [this & argument…] ciało)
.
Możemy i powinniśmy zdefiniować metody, które zostały zadeklarowane w protokołach lub
interfejsach. Dodatkowo możemy umieszczać przeciążone wersje metod klasy Object
.
Pierwszym przyjmowanym argumentem w przypadku definiowania metod zadeklarowanych
w interfejsach powinien być obiekt instancji bieżącej (odpowiednik this
z Javy). Podczas ich bezpośredniego wywoływania (z wykorzystaniem mechanizmów
odwoływania się do obiektów platformy gospodarza) będzie on przekazywany
automatycznie i nie należy podawać go wprost.
W odniesieniu do argumentów i wartości zwracanych przez wszystkie metody możliwe jest zastosowanie sugerowania typów.
W ciałach metod jesteśmy w stanie odwoływać się do definiowanej klasy (i w związku z tym do jej klasowych metod) przez podawanie jej symbolicznej nazwy.
Uwaga: Definicje metod nie zamykają w ciałach powiązań z leksykalnego otoczenia ich definicji. Mamy dostęp wyłącznie do zadeklarowanych pól.
Do nowo powstałego typu dodany będzie również konstruktor, który pozwala inicjować tworzone obiekty wartościami pól. Poza tym zdefiniowana zostanie wywołująca go funkcja:
(->nazwa wartość…)
.
gdzie nazwa
jest nazwą utworzonego typu, wartość
wartością pola. Pozwala ona
tworzyć obiekty przez podanie wartości pól wyrażonych pozycyjnie.
Uwaga: Nie należy tworzyć instancji typów przez bezpośrednie wywoływanie
konstruktorów obiektu. Lepiej skorzystać z funkcji języka Clojure, która służy do
tego celu: ->typ
.
W efekcie pracy makra zdefiniowana zostanie klasa o podanych polach i metodach.
Tworzenie obiektów, ->typ
Tworzyć obiekty zdefiniowanych samodzielnie typów możemy (i powinniśmy!) nie tylko
bezpośrednio, odwołując się do konstruktora powstałego w efekcie wywołania
deftype
, lecz również z użyciem generowanej podczas definiowania nowego
typu funkcji ->typ
(gdzie typ
jest nazwą typu).
Użycie:
(->typ pole…)
.
Argumentami wywołania funkcji powinny być wartości kolejnych pól definiowanych przez typ obiektu. Wartością zwracaną jest obiekt zdefiniowanego typu.
Definiowanie rekordów, defrecord
Makro defrecord
pozwala definiować tzw. rekordowe typy danych, które
są nowymi typami platformy gospodarza służącymi do przechowywania informacji
w postaci rekordów (ang. records).
Do instancji typu rekordowego (zwanych rekordami) można odwoływać się z użyciem funkcji operujących na mapach. Każde zadeklarowane w klasie pole jest w egzemplarzu rekordu uwidaczniane jako klucz wirtualnej mapy.
Do obiektów powstałych na bazie zdefiniowanego typu można też dynamicznie dodawać nowe pola wraz z wartościami, nawet jeżeli nie zostały one zadeklarowane w momencie tworzenia typu. Tego rodzaju pola nazywamy polami rozszerzeniowymi (ang. extension fields).
Użycie:
(defrecord nazwa pole… & opcja… & specyfikacja…)
.
Pierwszym argumentem makra powinna być nazwa typu rekordowego, a kolejnymi (opcjonalnymi) nazwy pól. Po nazwach pól możemy podać opcje i tzw. specyfikacje (ang. skr. specs).
Dokładny opis użycia makra defrecord
można znaleźć w rozdziale poświęconym
polimorfizmowi.
Generowanie klas, gen-class
Gdy pojawia się konieczność utworzenia nowej, „czystej” klasy Javy, możemy skorzystać
z makra gen-class
. Sprawia ono, że podczas kompilacji generowany jest kod
bajtowy JVM dla klasy o podanej nazwie, która powinna być w pełni kwalifikowaną nazwą
pakietu Javy. W katalogu systemu plikowego, którego nazwa znajduje się w zmiennej
dynamicznej *compile-path*
umieszczony zostanie plik z rozszerzeniem class
.
Gdy program nie jest kompilowany (chodzi o kompilację AOT), to gen-class
nie wywoła
żadnego efektu.
Z użyciem gen-class
będziemy mogli tworzyć klasy Javy, lecz z ograniczeniami
(m.in. bez mutowalnych, publicznych pól). Wynika to z przeznaczenia gen-class
oraz
innych konstrukcji tego typu. Służą one przede wszystkim do zapewniania
interoperacyjności z Javą, a nie do generowania wszystkich możliwych konstrukcji
tego języka. Na przykład gen-class
zastosowujemy po to, aby kod bajtowy pochodzący
z Clojure integrować z bibliotekami Javy, które będą chciały odwoływać się bądź
rozszerzać tak wyeksponowane klasy lub ich instancje.
Najpowszechniejszym przypadkiem użycia makra gen-class
jest wykorzystanie go
do wygenerowania klasy wyposażonej w publiczną, statyczną metodę main
, której
implementację można zdefiniować w postaci funkcji języka Clojure, np.: (def -main []
("hej"))
. Dzięki temu daje się budować samodzielne aplikacje, które łatwo
uruchamiać. Z podobnymi konstrukcjami spotkamy się też, gdy program w Clojure będzie
przeznaczony do obsługi żądań w modelu serwletowym.
Użycie:
(gen-class & opcja…)
.
Możliwe do zastosowania opcje to pary klucz–wartość, w których klucze są słowami kluczowymi, a wartości różnymi typami, zależnymi od konkretnych opcji:
:name nazwa
– nazwa klasy Javy;:extends klasa
– nazwa nadklasy, której publiczne metody zostaną nadpisane;:implements [interfejs…]
– nazwy interfejsów, których metody będą zdefiniowane;:init konstruktor
– nazwa funkcji, która stanie się konstruktorem;:constructors {[typy-parametrów] [typy-parametrów-nadklasy] …}
– sygnatury konstruktorów, jeżeli odmienne od odziedziczonych;:post-init wyzwalacz
– nazwa funkcji, która zostanie wywołana podczas instancjonowania;:methods [ [nazwa [typy-parametrów] typ-zwracany] …]
– sygnatury dodatkowych metod, które znajdą się w klasie;:main przełącznik
– gdy przełącznik jesttrue
, wygenerowana zostanie statyczna funkcjamain
;:factory fabrykator
– tworzy statyczne funkcje fabrykujące o podanej nazwie;:state pole
– tworzy publiczne, finalne pole o podanej nazwie;:exposes {pole-chronione {:get nazwa :set nazwa} …}
– generuje gettery i settery dla chronionych pól nadklasy;:exposes-methods {nazwa-metody {:get nazwa :set nazwa} …}
– generuje gettery i settery dla chronionych metod nadklasy;:prefix przedrostek
– ustawia przedrostek dla nazw funkcji Clojure implementujących metody;:impl-ns przestrzeń-nazw
– przestrzeń nazw z obiektamiVar
wskazującymi implementacje metod;:load-impl-ns przełącznik
– gdytrue
, implementacja statycznego inicjalizatora klasy poszukiwana będzie w funkcji z tej samej przestrzeni nazw.
Warto pamiętać, że nowa klasa nie będzie wyposażona w metody, które należy
zdefiniować z użyciem funkcji w jednej przestrzeni nazw programu. Będą one
dynamicznie wywoływane, gdy pojawią się odwołania do metod klasy o takich samych
nazwach (z ustalonym przedrostkiem lub domyślnym przedrostkiem -
).
Aby skompilować powyższy przykład należy umieścić zaprezentowany kod źródłowy w odpowiednim pliku:
Zobacz także:
- „gen-class”, ClojureDocs
- „gen-class – how it works and how to use it”, kotka.de
Generowanie pośredniczących, proxy
Podobnym do gen-class
makrem jest proxy
. Również dokonuje ono wygenerowania nowej
klasy, jednak z trzema istotnymi różnicami:
klasa rezyduje w pamięci
(a nie tylko w pliku.class
podczas kompilacji AOT);klasa nie ma publicznej nazwy,
do której można by się potem odwołać bez uciekania się do pewnych sztuczek;klasa jest natychmiast instancjonowana
(wartością zwracaną przez makro jest obiekt).
Do klas pośredniczących (ang. proxy) nie możemy dodawać nowych metod. Służą one
do „opakowywania” istniejących klas i przesłaniania istniejących tam metod, albo
do definiowania metod zadeklarowanych wcześniej (w przypadku
interfejsów). Definiowane w konstrukcji proxy
metody nie są metodami klasy, lecz
funkcjami, do których wywołania metod anonimowej klasy są przekierowywane.
Makra proxy
użyjemy najczęściej w celu szybkiego zapewnienia kompatybilności
z integrowanymi bibliotekami Javy.
Użycie:
(proxy [klasa-lub-interfejs…] [argument…] & definicja…)
,
gdzie:
[klasa-lub-interfejs…]
jest wektorowym S-wyrażeniem z nazwami klas i/lub interfejsów;[argument…]
jest wektorowym S-wyrażeniem z argumentami przekazywanymi do konstruktora nadklasy;definicja…
jest listowym S-wyrażeniem definiującym funkcje.
Zauważmy, że wykorzystywana w naszej implementacji funkcja str
wewnętrznie również
odwołuje się do toString
, ale w oryginalnej implementacji. Poza tym warto wiedzieć,
że proxy
domyka wszystkie leksykalne powiązania, które znajdują się
w leksykalnym otoczeniu jej wywołania.
Gdybyśmy jednak w naszym przykładzie spróbowali wykonać jakąś operację arytmetyczną
na wartości x
, zgłoszony zostanie wyjątek Javy UnsupportedOperationException
,
ponieważ w obiekcie pośredniczącym zostaną uwidocznione wyłącznie zdefiniowane przez
nas metody.
W funkcjach implementujących metody możemy odwoływać się również do oryginalnych
odpowiedników z klasy przodka. Służy do tego funkcja proxy-super
.
Zobacz także:
- „proxy”, ClojureDocs
- „proxy – gen-class little brother”, kotka.de
Konkretyzowanie, reify
Makro reify
pozwala nam na reifikację, czyli konkretyzowanie
interfejsów Javy i protokołów Clojure. Jest to
starszy brat makra proxy
i w stosunku do niego bardziej polecana konstrukcja.
Dzięki reify
możemy tworzyć obiekty, które zachowują się zgodnie
ze specyfikacją określoną podanymi wzorcami. W tym celu tworzona jest anonimowa klasa
implementująca definiowane metody. Dzięki temu można szybko wytworzyć jednorazową
instancję potrzebnego typu zmodyfikowaną pod względem zachowania w odpowiadający nam
sposób.
Użycie:
(reify opcja… specyfikacja…)
.
Każda z podanych specyfikacji powinna składać się z nazwy protokołu, interfejsu, lub
klasy Object
, a także listowych S-wyrażeń zawierających definicje metod:
nazwa-wzorca
(nazwa-metody [argument…] ciało) …
Definiowane metody domykają wartości powiązań z leksykalnego otoczenia.
Różnice w stosunku do makra proxy
są następujące:
Definiowane metody są faktycznymi metodami anonimowej klasy, a nie dynamicznymi odwołaniami do funkcyjnych obiektów poza klasą. W związku z tym wywołania metod są szybsze – nie zachodzi konieczność rozdzielania z użyciem specjalnej mapy przyporządkowującej nazwy metod do funkcyjnych obiektów.
W związku z powyższym nie można dokonywać dynamicznego podmieniania metod.
Konkretyzować możemy wyłącznie interfejsy bądź protokoły, nie klasy.
Definicje metod powinny przyjmować jako pierwszy argument wartość obiektu własnego.
Spróbujmy zobaczyć reify
w akcji, posługując się przykładem ze wzbogacaniem typu
danych o cechę policzalności.
W powyższym przykładzie możemy zaobserwować, że doszło do domknięcia wartości
argumentu funkcji wartość
w metodzie anonimowej klasy wytworzonej przez makro
reify
. Wyprodukowany obiekt, gdy odwoła się do metody toString
będzie korzystał
z jej zmienionej wersji, w której wartością jest zawsze ta podana podczas wywołania
funkcji numeryczna
, czyli wygenerowania obiektu.
Rozszerzanie typów
Nadtypami istniejących typów obiektowych nazwiemy nie tylko typy danych zdefiniowane klasami, po których klasy definiujące typy dziedziczą, ale również implementowane interfejsy i protokoły.
Deklarować, że dany typ implementuje protokół, możemy również po tym, gdy dany typ został zdefiniowany. Gdy mamy już protokół, który jest (bądź nie) implementowany przez jakieś typy danych, możemy pokusić się o rozszerzenie o jego obsługę nowych typów. Będzie to polegało na skojarzeniu typu z protokołem i zdefiniowaniu wymaganych funkcji obsługi.
Aby dokonać rozszerzenia typów o obsługę protokołów możemy skorzystać z jednej z trzech form:
Działają one podobnie, lecz wybór konkretnej będzie zależał od tego, czy mamy wiele
typów danych, które chcemy wzbogacić o implementację jakiegoś protokołu
(extend-protocol
), czy może mamy jeden typ, który pragniemy rozszerzyć
(extend-type
). Obydwa makra korzystają z funkcji extend
, która działa podobnie
do pierwszego wymienionego, lecz wymaga korzystania z map, w których kluczami
nazywającymi definiowane funkcje są słowa kluczowe, a wartościami
obiekty funkcyjne.
Dokładny opis użycia funkcji extend
i makr extend-type
oraz extend-protocol
można znaleźć w rozdziale poświęconym
polimorfizmowi.
Testowanie typów
Gdy w Clojure korzystamy z jakiejś wartości, będzie ona miała zawsze przypisany
obiektowy typ danych, pochodzący z platformy gospodarza. Typ ten będzie
odwzorowany w postaci klas z przestrzeni nazw java
(najczęściej java.lang
w przypadku JVM) lub dodany przez klasy zdefiniowane w przestrzeni clojure.lang
.
Spójrzmy na przykładowe literały i obiektowe typy danych odpowiadające reprezentowanym przez nie wartościom:
"a"
–java.lang.String
,\a
–java.lang.Character
,true
–java.lang.Boolean
,123
–java.lang.Long
,123M
–java.math.BigDecimal
,123N
–clojure.lang.BigInt
,:a
–clojure.lang.Keyword
,'a
–clojure.lang.Symbol
,'(1)
–clojure.lang.PersistentList
,[1]
–clojure.lang.PersistentVector
,{:a 2}
–clojure.lang.PersistentArrayMap
.
Badanie klasy, class
Funkcja class
zwraca klasę podanego obiektu.
Użycie:
(class wartość)
.
Argumentem wywołania funkcji powinna być dowolna wartość, a wartością zwracaną będzie klasa.
Zwróćmy uwagę na różnicę w wartości zwracanej w ostatnim wyrażeniu względem
przykładu wywołania type
.
Asercja typu, cast
Funkcja cast
służy do warunkowania dalszego wykonywania programu w zależności od
tego, czy mamy do czynienia z podanym typem lub jednym z jego nadtypów.
Użycie:
(cast typ wartość)
.
Pierwszym argumentem funkcji powinien być typ danych, a drugim wartość poddawana
sprawdzaniu. Jeżeli typ wartości jest podanym typem lub jednym z jego nadtypów (jedną
z klas nadrzędnych lub interfejsów), zwracana jest podana wartość. W przeciwnym
przypadku zgłaszany jest wyjątek java.lang.ClassCastException
.
Zauważmy, że nazwa tej funkcji może być nieco myląca, ponieważ nie mamy do czynienia z rzutowaniem – widać to dobrze w ostatnim wyrażeniu przykładu.
Predykat klasowości, class?
Funkcja class?
służy do sprawdzania, czy mamy do czynienia z klasą.
Użycie:
(class? wartość)
.
Funkcja zwraca true
, jeżeli podana wartość jest klasą. W przeciwnym przypadku
zwraca false
.
Predykat instancyjności, instance?
Funkcja instance?
służy do sprawdzania, czy obiekt reprezentujący wartość jest
instancją klasy lub interfejsu o podanej nazwie.
Użycie:
(instance? klasa wartość)
.
Funkcja zwraca true
, jeżeli podana jako drugi argument wartość jest instancją klasy
przekazanej jako pierwszy argument. W przeciwnym przypadku zwraca false
.
Sprawdzone będą wszystkie interfejsy, które implementuje podany obiekt, a także wszystkie jego klasy nadrzędne w ścieżce dziedziczenia.
Predykat dziedziczenia, isa?
Funkcja isa?
pozwala sprawdzić, czy podany typ jest bezpośrednim lub pośrednim
podtypem innego typu.
Użycie:
(isa? potomek rodzic)
.
Pierwszym przyjmowanym argumentem jest znacznik typu potomnego, a drugim znacznik typu macierzystego.
Wartością zwracaną będzie true
, jeżeli podany typ pochodny jest bezpośrednim lub
pośrednim potomkiem typu macierzystego. W przeciwnym razie zwracaną wartością będzie
false
.
Zbadane będą wszystkie nadklasy podanej klasy potomnej, a także wszystkie ich interfejsy.
Zwróćmy uwagę, że isa?
umożliwia też badanie relacji typów obiektowych do
(omówionych dalej) typów znacznikowych, gdy zostały one określone hierarchią. Funkcji
można używać również w odniesieniu do typów znacznikowych.
Nadklasy i interfejsy, supers
Funkcja supers
służy do sprawdzania nadklas (zwanych też superklasami,
ang. superclasses) oraz interfejsów podanej klasy.
Użycie:
(supers klasa)
.
Funkcja zwraca zbiór zawierający bezpośrednią i wszystkie pośrednie nadklasy, a także wszystkie interfejsy, które implementuje podana jako argument klasa lub podany jako argument interfejs.
Nadklasa i interfejsy, bases
Funkcja bases
służy do sprawdzania nadklasy oraz bezpośrednich interfejsów
podanej klasy.
Użycie:
(bases klasa)
.
Funkcja zwraca zbiór zawierający klasę bazową, a także wszystkie bezpośrednie interfejsy, które implementuje podana jako argument klasa (mogąca również być interfejsem).
Nadtypy, parents
Funkcja parents
pozwala sprawdzić, jakie są nadtypy podanego typu danych (klasy lub
interfejsu klasy).
Użycie:
(parents klasa)
.
Jako argument należy przekazać nazwę klasy, a wartością zwracaną jest zbiór zawierający jej klasę bazową oraz wszystkie bezpośrednio implementowane przez klasę interfejsy i wszystkie bezpośrednie nadtypy bazujące na znacznikach, jeżeli wytworzono tego typu relacje. Istnieje również wariant tej funkcji, który obsługuje te ostatnie.
Jeżeli podany typ nie ma nadtypów, zwracaną wartością jest nil
.
Wszystkie nadtypy, ancestors
Funkcja ancestors
pozwala sprawdzić, jakie są wszystkie bezpośrednie i pośrednie
klasy macierzyste podanej klasy lub interfejsu, a także wszystkie implementowane
przez nią interfejsy. Uwzględniane są również ewentualne typy bazujące na
znacznikach, jeżeli są nadtypami podanej klasy.
Użycie:
(ancestors klasa)
.
Przyjmowanym argumentem jest klasa lub interfejs, których nadtypy mają być sprawdzone.
Funkcja zwraca zbiór zawierający wszystkie bezpośrednie i pośrednie nadklasy, wszystkie implementowane interfejsy oraz nadtypy bazujące na znacznikach podanej klasy. Istnieje również wariant tej funkcji, który operuje na znacznikowych typach doraźnych.
Jeżeli podana klasa nie ma nadklas, zwracaną wartością jest nil
.
System typów podstawowych
Typy obiektowe to nie jedyna rodzina typów dostępna w Clojure. W niektórych przypadkach będziemy mogli korzystać również z tzw. typów podstawowych. One również pochodzą z platformy gospodarza.
Podstawowe typy danych (ang. primitive data types) to takie typy, które są wbudowane w język i pełnią funkcję podstawowych jednostek, z których tworzone mogą być typy złożone.
Na przykład obiekt może być wyposażony w atrybuty, które nie są obiektami, lecz właśnie typami podstawowymi, nie zdefiniowanymi w żadnych klasach.
W przypadku języka Clojure i JVM typami podstawowymi będą:
boolean
– wartości logiczne (8- do 32-bitowe, wartości:true
lubfalse
);byte
– bajty (8-bitowe, zakres: -128 do 127);char
– znaki alfabetu Unicode (16-bitowe, zakres: 0 do 65535);short
– krótkie liczby całkowite (16-bitowe, zakres: -32768 do 32767);int
– liczby całkowite (32-bitowe, zakres: -231 do 231-1);long
– liczby całkowite długie (64-bitowe, zakres: -263 do 263-1);float
– liczby zmiennoprzecinkowe (32-bitowe, zgodne z IEEE 754);double
– liczby zmiennoprzecinkowe podwójnej precyzji (64-bitowe, IEEE 754).
Typy opakowane
Zazwyczaj wartości typów podstawowych w Javie (a więc i w Clojure) nie są reprezentowane bezpośrednio, lecz automatycznie umieszczane w specjalnych obiektach. Podstawowe typy wchodzące w skład takich obiektów nazywamy typami opakowanymi (ang. boxed types), a proces ich obiektowego reprezentowania automatycznym opakowywaniem (ang. autoboxing).
Spójrzmy, jakie typy obiektowe będą używane do opakowywania typów podstawowych:
boolean
–java.lang.Boolean
;byte
–java.lang.Byte
;char
–java.lang.Character
;short
–java.lang.Short
;int
–java.lang.Integer
;long
–java.lang.Long
;float
–java.lang.Float
;double
–java.lang.Double
.
Kiedy więc w programie widzimy zapis 123
to naprawdę mamy do czynienia z typem
java.lang.Long
. Wewnątrz obiektu tego typu znajduje się pole typu podstawowego
(long
) przechowujące właściwą wartość. Poza tym w klasie java.lang.Long
znajdziemy funkcje składowe, czyli metody, które pozwalają dokonywać na instancjach
różnych operacji.
Dzięki opakowywaniu możemy korzystać z wartości typów podstawowych tak samo, jak z innych obiektów (np. wywoływać pewne metody, umieszczać w kolekcjach, przekazywać jako argumenty itp.), a także operować na samych typach (np. tworzyć względem nich klasy pochodne, rozszerzać definiujące je klasy itp.). Minusem jest jednak wydajność przetwarzania i zajmowana przestrzeń pamięciowa.
W pewnych warunkach możliwe jest bezpośrednie korzystanie z danych typów
podstawowych z pominięciem mechanizmu opakowywania. Przydaje się to np. w pętlach,
które wykonują wiele iteracji, za każdym razem zmieniając jakąś numeryczną
wartość. Gdyby zamiast zmiennej typu int
, skorzystać ze zmiennej referencyjnej
odnoszącej się do obiektu klasy Integer
, to każdy przebieg pętli oznaczałby
tworzenie nowej instancji odkładanej na stertę, a następnie niszczonej przez
mechanizmy odśmiecania pamięci.
Poza korzystaniem z typów opakowanych możemy uzyskiwać bezpośredni dostęp do typów rozpakowanych (ang. unboxed types). Będą to takie typy podstawowe, których dane, mimo że z reguły opakowywane w obiektach, w pewnych sytuacjach użytkowane są bezpośrednio. Tracimy wtedy oczywiście wiele mechanizmów obiektowych, lecz zyskujemy na prędkości realizowania obliczeń i zdolności kompilatora do przeprowadzania optymalizacji.
W Clojure typy podstawowe platformy gospodarza obsługiwane są z użyciem obiektów opakowujących, jednak możemy wykorzystać tzw. sugerowanie typu bądź rzutowanie, aby w pewnych warunkach przeprowadzać operacje na wartościach typów rozpakowanych.
Obsługa typów podstawowych możliwa jest w wielu kontekstach, do których należą:
- operacje arytmetyczne,
- powiązania leksykalne i parametryczne,
- powiązania w rekurencji ogonowej z użyciem
recur
, - operacje tablicowe z użyciem odpowiednich form.
Doraźny system typów
System typów języka Clojure jest elastyczny i rozszerzalny. Poza tym, że
każda wartość ma przypisany typ danych określony przez platformę gospodarza
(np. JVM), możemy tworzyć własne dodatkowe oznaczenia typowe, korzystając
z metadanych, a konkretnie z klucza :type
– oczywiście dla tych
struktur, które pozwalają dodawać metadane.
Dodatkowe, niezależne od platformy typy mogą być używane do rozpoznawania struktur danych, które mają znaczenie w kontekście przyjętej logiki aplikacji, ale także podczas tworzenia niektórych polimorficznych operacji, które zostaną omówione później.
Hierarchiczność
System typów stworzony na bazie własnych oznaczeń możemy nazywać znacznikowym systemem typów lub doraźnym systemem hierarchicznym (ang. ad hoc hierarchy system). Jest on hierarchiczny, tzn. umożliwia tworzenie relacji między typami, a więc wyróżnianie nadtypów i podtypów. Możemy tworzyć wiele różnych hierarchii lub korzystać z domyślnej hierarchii globalnej.
Zgodnie z przyjętą nomenklaturą bezpośredni nadtyp danego typu znacznikowego nazwiemy też:
- rodzicem (ang. parent),
- typem macierzystym (ang. parent type),
- typem bazowym (ang. base type),
- bezpośrednim przodkiem (ang. direct ancestor).
Z kolei bezpośredni podtyp:
- dzieckiem (ang. child),
- typem potomnym (ang. child type),
- typem pochodnym (ang. derived type),
- bezpośrednim potomkiem (ang. direct descendant).
Za podtypy będziemy również uważali wszystkie typy potomne, zwane potomkami (ang. descendants) lub typami pochodnymi (ang. derived types), a za nadtypy wszystkie typy nadrzędne, zwane przodkami (ang. ancestors) lub typami bazowymi (ang. base types).
Obsługa w Clojure
Oznaczanie wartości typami
Użycie:
^{:type znacznik} wartość
,'^{:type znacznik} wartość
.
Identyfikatorami oznaczeń typu powinny być słowa kluczowe lub symbole (w formach stałych) z dookreślonymi przestrzeniami nazw. Z kolei wartościami powinny być struktury danych, które obsługują metadane (np. symbole, kolekcje, zmienne globalne, niektóre obiekty referencyjne).
Tworzenie hierarchii, make-hierarchy
Funkcja make-hierarchy
pozwala tworzyć własne hierarchie typów bazujących na
oznaczeniach.
Użycie:
(make-hierarchy)
.
Funkcja nie przyjmuje argumentów, a wartością zwracaną jest mapa, w której przechowywana będzie informacja o hierarchicznych relacjach typów bazujących na oznaczeniach.
Derywacja typów, derive
Funkcja derive
pozwala dla danej hierarchii typów oznaczonych ustanowić relację
przodek–potomek między dwoma typami.
W hierarchiach możemy wytwarzać relacje nie tylko między typami bazującymi na oznaczeniach własnych, ale też między typami własnymi a wbudowanymi. Typy wbudowane mogą być podtypami typów znacznikowych. Dzięki temu możemy na przykład wskazać, że tworzony typ reprezentowany może być określonymi strukturami danych.
Użycie:
(derive typ przodek)
,(derive hierarchia typ przodek)
.
W podstawowym wariancie funkcja przyjmuje dwa argumenty. Pierwszym powinien być typ
wyrażony symbolem (z dookreśloną przestrzenią nazw), słowem kluczowym (z dookreśloną
przestrzenią nazw) lub klasą. Wartością drugiego przekazywanego argumentu powinien
być typ, który ma być względem niego nadrzędny (być jego nadtypem), wyrażony symbolem
lub kluczem. Zmiana zostanie wprowadzona w globalnej hierarchii typów oznaczonych,
a zwróconą wartością będzie nil
.
W wersji trójargumentowej pierwszym argumentem powinna być mapa hierarchii, a wywołanie nie spowoduje powstania efektu ubocznego w postaci jej modyfikacji, lecz zwróci zaktualizowany obiekt.
Usuwanie derywacji typów, underive
Funkcja underive
pozwala dla danej hierarchii typów oznaczonych usunąć wskazaną
relację przodek–potomek między dwoma typami.
Użycie:
(underive typ przodek)
,(underive hierarchia typ przodek)
.
W podstawowym wariancie funkcja przyjmuje dwa argumenty. Pierwszym powinien być typ
wyrażony symbolem (z dookreśloną przestrzenią nazw), słowem kluczowym (z dookreśloną
przestrzenią nazw) lub klasą. Wartością drugiego przekazywanego argumentu powinien
być typ, który jest względem niego nadrzędny, wyrażony symbolem lub kluczem. Zmiana
zostanie wprowadzona w globalnej hierarchii typów oznaczonych, a zwróconą wartością
będzie nil
.
W wersji trójargumentowej pierwszym argumentem powinna być mapa hierarchii, a wywołanie nie spowoduje powstania efektu ubocznego w postaci jej modyfikacji, lecz zwróci zaktualizowany obiekt.
Predykat dziedziczenia, isa?
Funkcja isa?
pozwala sprawdzić, czy podany typ jest bezpośrednim lub pośrednim
podtypem innego podanego typu.
Użycie:
(isa? potomek rodzic)
,(isa? hierarchia potomek rodzic)
,
Pierwszym przyjmowanym argumentem jest znacznik typu potomnego lub obiektowy typ systemu gospodarza, a drugim znacznik typu macierzystego.
W wariancie trójargumentowym pierwszym argumentem powinna być mapa definiująca hierarchię typów.
Wartością zwracaną będzie true
, jeżeli podany typ pochodny jest bezpośrednim lub
pośrednim potomkiem typu macierzystego. W przeciwnym razie zwracaną wartością będzie
false
.
Zwróćmy uwagę, że isa?
umożliwia też badanie relacji typów obiektowych do
znacznikowych, gdy zostały one określone hierarchią. Funkcji można używać również
w wariancie obiektowym.
Nadtypy, parents
Funkcja parents
pozwala sprawdzić, jakie są bezpośrednie nadtypy podanego typu
danych.
Użycie:
(parents typ)
,(parents hierarchia typ)
.
W wariancie jednoargumentowym należy przekazać znacznik typu, a w wariancie
dwuargumentowym hierarchię określoną mapą (stworzoną z użyciem
make-hierarchy
) oraz znacznik typu.
Funkcja zwraca zbiór zawierający wszystkie bezpośrednie nadtypy podanego typu. Obsługiwane są również typy obiektowe: funkcja zwraca klasy bazowe i bezpośrednio implementowane interfejsy podanej klasy, a także typy bazujące na znacznikach, które pozostają w relacji rodzicielskiej względem podanych typów obiektowych systemu gospodarza.
Jeżeli podany typ nie ma nadtypów, zwracaną wartością jest nil
.
Wszystkie podtypy, descendants
Funkcja descendants
pozwala sprawdzić, jakie są bezpośrednie i pośrednie podtypy
typu identyfikowanego podanym znacznikiem.
Użycie:
(descendants typ)
,(descendants hierarchia typ)
.
Argumentem powinien być znacznik określający badany typ, a w wariancie
dwuargumentowym mapa hierarchii (stworzonej z użyciem
make-hierarchy
) i znacznik badanego typu.
Funkcja zwraca zbiór zawierający wszystkie bezpośrednie i pośrednie typy potomne względem podanego typu bazującego na znaczniku.
Wszystkie nadtypy, ancestors
Funkcja ancestors
pozwala sprawdzić, jakie są wszystkie bezpośrednie
i pośrednie nadtypy podanego typu danych.
Użycie:
(ancestors typ)
,(ancestors hierarchia typ)
.
W wariancie jednoargumentowym należy przekazać znacznik typu, a w wariancie
dwuargumentowym hierarchię określoną mapą (stworzoną z użyciem
make-hierarchy
) oraz znacznik typu.
Funkcja zwraca zbiór zawierający wszystkie bezpośrednie i pośrednie nadtypy podanego typu. Obsługiwane są również typy obiektowe: funkcja zwraca nadklasy i wszystkie implementowane interfejsy podanej klasy, a także typy bazujące na znacznikach, które pozostają w relacji rodzicielskiej względem podanych typów obiektowych systemu gospodarza.
Jeżeli podany typ nie ma nadtypów, zwracaną wartością jest nil
.
Operacje generyczne
Istnieją w Clojure generyczne operacje wspólne dla wszystkich systemów typów. Niektóre z nich umożliwiają nawet miksowanie typów odmiennych rodzin (np. znacznikowego i obiektowego).
Sprawdzanie typów
Badanie typu wartości, type
Funkcja type
umożliwia zbadanie z jakiego typu wartością mamy do
czynienia. Działa dla typów doraźnych i obiektowych.
Użycie:
(type wartość)
.
Argumentem wywołania funkcji powinna być dowolna wartość, a wartością zwracaną będzie jej typ.
Typ zostanie odczytany z metadanych wartości (klucz :type
), a jeżeli
się to nie powiedzie, zwrócona będzie nazwa klasy, której obiekt jest instancją.
Widzimy, że niektóre typy danych pochodzą bezpośrednio z Javy, a niektóre są
hierarchicznymi typami specyficznymi dla Clojure. W ostatnim wyrażeniu nadaliśmy
własne oznaczenie typu dla symbolu a
. Zwróćmy uwagę na różnicę w wartości zwracanej
przez to wyrażenie względem przykładu wywołania class
.
Operacje polimorficzne
Konwersja, koercja i rzutowanie
Pewne operacje na typach danych możemy uznać za proste mechanizmy polimorficzne, gdyż pozwalają traktować wartości danego typu tak, jakby były wartościami innego. Te operacje to:
- konwersja – tworzenie wartości nowego typu na bazie wartości innego typu;
- rzutowanie – traktowanie wartości tak, jakby była wartością innego typu;
- koercja – konwersja typu wartości przekazywanej jako argument funkcji.
Warto na wstępie zaznaczyć, że w praktyce niektóre z wymienionych terminów używane bywają zamiennie z uwagi na nieprecyzyjne konwencje nazewnictwa i różnice w szczegółach działania kompilatorów.
Konwersja, koercja i rzutowanie są operacjami obiektowego oraz podstawowego systemu typów platformy gospodarza.
Polimorfizm typów obiektowych
W Clojure możemy tworzyć nowe typy obiektowe, korzystając z mechanizmu tzw. rekordów (ang. records) oraz definiowania typów. Rekordy i typy własne mogą być następnie dopasowywane do właściwych operacji zgodnie z zasadami określonymi tzw. protokołami (ang. protocols).
Polimorfizm typów doraźnych
Hierarchiczne typy doraźne bazujące na znacznikach mogą być tworzone,
a następnie używane w połączeniu z mechanizmem multimetod, aby
konstruować bazujące na nich (type
) lub na relacjach między nimi
(isa?
) polimorficzne operacje.
Wbudowane typy danych
Wbudowane typy obiektowe
Typy proste
clojure.lang.BigInt
– liczby całkowite nieograniczone,clojure.lang.Keyword
– słowa kluczowe,clojure.lang.Ratio
– ułamki,clojure.lang.Symbol
– symbole.
Typy proste JVM
java.math.BigDecimal
– liczby dziesiętne nieograniczone,java.math.BigInteger
– liczby całkowite nieograniczone.java.lang.Boolean
– wartości logiczne,java.lang.Byte
– bajty,java.lang.Character
– znaki,java.lang.Double
– liczby zmiennoprzecinkowe podwójnej precyzji,java.lang.Float
– liczby zmiennoprzecinkowe,java.lang.Integer
– liczby całkowite,java.lang.Long
– liczby całkowite długie,java.lang.Short
– liczby całkowite krótkie.
Typy referencyjne
clojure.lang.Agent
– Agenty,clojure.lang.Atom
– Atomy,clojure.lang.Delay
– Delay’e,clojure.lang.Promise
– Promise’y,clojure.lang.Ref
– Refy,clojure.lang.Var
– Vary,clojure.lang.Volatile
– Volatile’e.
Typy funkcyjne
clojure.lang.IFn
– interfejs anonimowych klas funkcji.
Typy powtórzeniowe
clojure.lang.Iterate
– iteracje,clojure.lang.RecordIterator
– iteracje po rekordach,clojure.lang.Repeat
– powtórzenia wartości.
Typy wyjątkowe
clojure.lang.ExceptionInfo
– informacje dot. wyjątku.
Typy warunkowe
clojure.lang.Reduced
– warunki zakończenia redukcji.
Typy strumieniowe
clojure.lang.XMLHandler
– uchwyt parsera XML.
Typy zakresowe
clojure.lang.LongRange
– zakresy długich liczb całkowitych,clojure.lang.Range
– zakresy liczb całkowitych.
Typy sekwencyjne
clojure.lang.Cons
– komórki cons (leniwe listy),clojure.lang.ChunkedCons
– komórki cons list fragmentowanych,clojure.lang.Cycle
– sekwencje powtórzeniowe,clojure.lang.IndexedSeq
– sekwencje indeksowane,clojure.lang.LazySeq
– leniwe sekwencje,clojure.lang.SeqEnumeration
– sekwencje enumeracyjne,clojure.lang.SeqIterator
– sekwencje iteracyjne,clojure.lang.StringSeq
– sekwencje znakowe.
Typy kolekcyjne
clojure.lang.MapEntry
– element mapy (asocjacja),clojure.lang.Namespace
– przestrzenie nazw,clojure.lang.PersistentArrayMap
– mapy tablicowe,clojure.lang.PersistentHashMap
– mapy,clojure.lang.PersistentHashSet
– zbiory,clojure.lang.LazilyPersistentVector
– leniwe wektory,clojure.lang.PersistentList
– listy,clojure.lang.PersistentQueue
– kolejki,clojure.lang.PersistentStructMap
– mapy strukturalizowane,clojure.lang.PersistentTreeMap
– mapy sortowane,clojure.lang.PersistentTreeSet
– zbiory sortowane,clojure.lang.PersistentVector
– wektory,clojure.lang.TransactionalHashMap
– mapy transakcyjne.
Typy transakcyjne
clojure.lang.LockingTransaction
– transakcje.
Typy literałowe
clojure.lang.TaggedLiteral
– literały oznaczone.
Wbudowane typy podstawowe
Typy proste JVM
long
– liczby całkowite długie,double
– liczby zmiennoprzecinkowe podwójnej precyzji.
Typy złożone JVM
ints
– tablice liczb całkowitych,longs
– tablice liczb całkowitych dłuższych,floats
– tablice liczb zmiennoprzecinkowych,doubles
– tablice liczb zmiennoprzecinkowych podwójnej precyzji.
Predykaty typów złożonych JVM
(ints? wartość)
– czy tablice liczb całkowitych,(longs? wartość)
– czy tablice liczb całkowitych dłuższych,(floats? wartość)
– czy tablice liczb zmiennoprzecinkowych,(doubles? wartość)
– czy tablice liczb zmiennoprzecinkowych podwójnej precyzji.