Poczytaj mi Clojure, cz. 4A: Typy danych

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 generalizować lub uszczegóławiać operacje przeprowadzane na danych, 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.

Typy danych

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ć odpowiednią dla niej klasą.

Typ danych (ang. data type) to klasa wartości, którą cechują pewne właściwości wspólne dla danego rodzaju informacji i sposobów zarządzania nią. Właściwościami tymi mogą być zakresy, wielkości zajmowanych przestrzeni, sposoby uporządkowania czy reprezentowania danych i inne atrybuty przyjęte za wyróżniające w ramach tak zwanego systemu typów.

Systemy 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ść jakimś typem danych, a także dba o relacje między typami i pozwala dokonywać na nich pewnych operacji.

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.

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

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 stosować operacji na danych, dla których operacje te nie zadziałają lub zadziałają błędnie. Do czynienia będziemy mieć wtedy z usterkami podczas pracy programu, ponieważ nie dojdzie do wykrycia niekompatybilności na etapie kompilacji.

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ą biznesową aplikacji, ale często też są 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. Przykładem w Javie będą tu 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, Integer czy innych), 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.

Podsumowując, możemy wyróżnić następujące funkcje typizowania:

  • rozróżnianie danych biznesowych (np. typy CzłowiekRoślina);
  • rozróżnianie danych implementacyjnych (np. typy StringInteger);
  • określanie relacji między klasami wartości (np. typ Zwierzę i podtyp Ssak);
  • 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).

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

Obiekt, czyli wartość określonego typu, będziemy nazywali instancją (ang. instance) lub egzemplarzem pewnej klasy (ang. class). Klasa z kolei określa typ danych – jest wzorcem służącym do jego zdefiniowania i składa się z:

  • pól (ang. fields), zwanych też atrybutami (ang. attributes), w których egzemplarz przechowuje dane;
  • funkcji składowych, 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ć.

Sposobem na dodawanie nowych metod lub pól do istniejących klas jest dziedziczenie (tworzenie klas pochodnych), a na implementowanie w różnych klasach spójnych zestawów operacji korzystanie z tzw. interfejsów (ang. interfaces). Raz stworzony interfejs, zawierający deklaracje metod i potrzebnych pól, może być potem implementowany przez wiele klas.

Gdy w Clojure korzystamy z jakiejś wartości, będzie ona miała zawsze pewien typ pochodzący z platformy gospodarza (odwzorowany w przestrzeni nazw java, najczęściej java.lang – w przypadku JVM) lub dodany przez zdefiniowanie klasy (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,
  •     \ajava.lang.Character,
  •   truejava.lang.Boolean,
  •    123java.lang.Long,
  •   123Mjava.math.BigDecimal,
  •   123Nclojure.lang.BigInt,
  •     :aclojure.lang.Keyword,
  •     'aclojure.lang.Symbol,
  •   '(1)clojure.lang.PersistentList,
  •    [1]clojure.lang.PersistentVector,
  • {:a 2}clojure.lang.PersistentArrayMap.

Wspomnieliśmy wcześniej o polimorfizmie podtypowym, który jest obecny także w obiektowych systemach typów. Może być on wtedy określony mianem dziedziczenia 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).

Z kolei 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).

Nadtypami będą również interfejsy lub domieszki (ang. mixins) klas w językach, które je obsługują. Domieszki to mechanizm rozszerzania klas przypominający interfejsy, z tą różnicą, że mogą one zawierać definicje metod, a nie tylko ich deklaracje; spotkamy się z nim np. w języku Ruby.

W języku Clojure zwykle nie będziemy musieli zajmować się dziedziczeniem interfejsów (wyróżnianiem podtypów i nadtypów), chyba że zechcemy tworzyć nowe typy danych lub będziemy dokonywali optymalizacji z wykorzystaniem prostych operacji polimorficznych.

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.

Przykłady użycia funkcji class
1
2
3
4
5
6
7
8
9
10
11
12
13
(class 1)        ; => java.lang.Long
(class "abc")    ; => java.lang.String
(class \a)       ; => java.lang.Character
(class nil)      ; => nil
(class true)     ; => java.lang.Boolean
(class false)    ; => java.lang.Boolean
(class (fn []))  ; => user$eval10652$fn__10653
(class [])       ; => clojure.lang.PersistentVector
(class :a)       ; => clojure.lang.Keyword
(class 'a)       ; => clojure.lang.Symbol

(class '^{:type ::Coś} a)
; => clojure.lang.Symbol

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.

Przykłady użycia funkcji cast
1
2
3
4
5
6
7
8
9
10
11
12
13
(cast clojure.lang.Keyword :a)
; => :a

(cast Object :a)
; => :a

(cast String :a)
; >> java.lang.ClassCastException:
; >> Cannot cast clojure.lang.Keyword to java.lang.String

(cast Long (int 5))
; >> java.lang.ClassCastException:
; >> Cannot cast java.lang.Integer to java.lang.Long

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 wypadku zwraca false.

Przykłady użycia funkcji class?
1
2
3
4
5
6
7
(class? 1)                    ; => false
(class? "abc")                ; => false
(class? nil)                  ; => false
(class? true)                 ; => false
(class? Long)                 ; => true
(class? java.lang.Long)       ; => true
(class? clojure.lang.Symbol)  ; => true

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 wypadku zwraca false.

Sprawdzone będą wszystkie interfejsy, które implementuje podany obiekt, a także wszystkie jego klasy nadrzędne w ścieżce dziedziczenia.

Przykłady użycia funkcji instance?
1
2
3
4
5
6
7
8
9
(instance? Long       1)  ; => true
(instance? Integer    1)  ; => false
(instance? Number     1)  ; => true
(instance? Object     1)  ; => true
(instance? String "abc")  ; => true

(instance? java.lang.Number      1)  ; => true
(instance? clojure.lang.keyword :a)  ; => true
(instance? clojure.lang.IFn     :a)  ; => true

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.

Przykład użycia funkcji isa?
1
2
3
4
5
6
7
8
(isa? String  Object)                           ; => true
(isa? Class   Object)                           ; => true
(isa? Integer Number)                           ; => true
(isa? clojure.lang.Keyword java.lang.Runnable)  ; => true

(derive clojure.lang.Associative ::kolekcja)
(isa?   clojure.lang.Associative ::kolekcja)
; => true

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 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 (mogąca również być interfejsem).

Przykłady użycia funkcji supers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(supers Long)
; => #{java.io.Serializable
; =>   java.lang.Comparable
; =>   java.lang.Number
; =>   java.lang.Object}

(supers clojure.lang.IFn)
; => #{java.lang.Runnable
; =>   java.util.concurrent.Callable}

(supers nil)
; => nil

(supers java.lang.Boolean)
; => #{java.io.Serializable
; =>   java.lang.Comparable
; =>   java.lang.Object}

(supers (class :a))
; => #{clojure.lang.IFn
; =>   clojure.lang.IHashEq
; =>   clojure.lang.Named
; =>   java.io.Serializable
; =>   java.lang.Comparable
; =>   java.lang.Object
; =>   java.lang.Runnable
; =>   java.util.concurrent.Callable}

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

Przykłady użycia funkcji bases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(bases Long)
; => #{java.lang.Comparable
; =>   java.lang.Number}

(bases clojure.lang.IFn)
; => #{java.lang.Runnable
; =>   java.util.concurrent.Callable}

(bases nil)
; => nil

(bases java.lang.Boolean)
; => #{java.io.Serializable
; =>   java.lang.Comparable
; =>   java.lang.Object}

(bases (class :a))
; => #{clojure.lang.IFn
; =>   clojure.lang.IHashEq
; =>   clojure.lang.Named
; =>   java.io.Serializable
; =>   java.lang.Comparable}

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.

Przykład użycia funkcji parents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(parents Object)
; => nil

(parents String)
; => #{java.io.Serializable
; =>   java.lang.CharSequence
; =>   java.lang.Comparable
; =>   java.lang.Object}

(parents Integer)
; => #{java.lang.Comparable
; =>   java.lang.Number}

(parents clojure.lang.Symbol)
; => #{class clojure.lang.AFn
; =>   interface clojure.lang.IHashEq
; =>   interface clojure.lang.IObj
; =>   interface clojure.lang.Named
; =>   interface java.io.Serializable
; =>   interface java.lang.Comparable}

(derive clojure.lang.Associative ::kolekcja)
(parents clojure.lang.Associative)
; => #{:user/kolekcja
; =>   clojure.lang.ILookup
; =>   clojure.lang.IPersistentCollection}

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.

Przykład użycia funkcji ancestors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(ancestors Object)
; => nil

(ancestors String)
; => #{java.io.Serializable
; =>   java.lang.CharSequence
; =>   java.lang.Comparable
; =>   java.lang.Object}

(ancestors Integer)
; => #{java.io.Serializable
; =>   java.lang.Comparable
; =>   java.lang.Number
; =>   java.lang.Object}

(derive clojure.lang.Associative ::kolekcja)
(ancestors clojure.lang.Associative)
; => #{:user/kolekcja
; =>   clojure.lang.ILookup
; =>   clojure.lang.IPersistentCollection
; =>   clojure.lang.Seqable}

System typów podstawowych

Typy obiektowe to nie jedyne rodziny typów dostępne 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 lub false);
  •    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, zgodne z IEEE 754).

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:

  • booleanjava.lang.Boolean;
  •    bytejava.lang.Byte;
  •    charjava.lang.Character;
  •   shortjava.lang.Short;
  •     intjava.lang.Integer;
  •    longjava.lang.Long;
  •   floatjava.lang.Float;
  •  doublejava.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 mamy tam trochę funkcji składowych, czyli metod, które pozwalają dokonywać na tej wartości różnych operacji.

Dzięki opakowywaniu możemy więc 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 potem niszczonej przez mechanizmy odśmiecania pamięci).

Poza typami opakowanymi możemy więc wyróżnić konstrukty odwrotne, czyli typy rozpakowane (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żą:

Doraźny system typów

System typów języka Clojure jest elastycznyrozszerzalny. 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 biznesowej aplikacji, ale także podczas tworzenia niektórych polimorficznych operacji, które zostaną omówione później.

Podsystem 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 w obrębie tego systemu typów lub korzystać z 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).

Oznaczanie wartości typami

Użycie:

  • ^{:type znacznik} wartość,
  • '^{:type znacznik} wartość.

Identyfikatorami oznaczeń typu powinny być słowa kluczowe lub symbole (w formułach 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).

Przykład oznaczania typu danych
1
2
3
4
5
6
7
(def Paweł
  ^{:type ::Człowiek} {:imię     "Paweł"
                       :nazwisko "Wilk"
                       :płeć     :m})

(type Paweł)
; => :user/Człowiek

Tworzenie hierarchii typów, 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.

Przykład użycia funkcji make-hierarchy
1
(def h (make-hierarchy))

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.

Przykłady użycia funkcji derive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
;; własna hierarchia:

(def h
  (-> (make-hierarchy)
      (derive ::kot       ::zwierzę)
      (derive ::pies      ::zwierzę)
      (derive ::owczarek  ::pies)
      (derive ::pudel     ::pies)
      (derive clojure.lang.Associative ::owczarek)
      (derive clojure.lang.Associative ::pudel)))

h
; => {:parents
; =>  {:user/kot      #{:user/zwierzę},
; =>   :user/pies     #{:user/zwierzę},
; =>   :user/owczarek #{:user/pies},
; =>   :user/pudel    #{:user/pies},
; =>   clojure.lang.Associative #{:user/pudel :user/owczarek}},
; =>  :ancestors
; =>  {:user/kot      #{:user/zwierzę},
; =>   :user/pies     #{:user/zwierzę},
; =>   :user/owczarek #{:user/pies :user/zwierzę},
; =>   :user/pudel    #{:user/pies :user/zwierzę},
; =>   clojure.lang.Associative #{:user/pudel :user/owczarek}},
; =>  :descendants
; =>  {:user/zwierzę  #{:user/pies :user/owczarek :user/kot :user/pudel},
; =>   :user/pies     #{:user/owczarek :user/pudel},
; =>   :user/owczarek #{clojure.lang.Associative},
; =>   :user/pudel    #{clojure.lang.Associative}}}

;; globalna hierarchia:

(derive ::kot       ::zwierzę)
(derive ::pies      ::zwierzę)
(derive ::owczarek  ::pies)
(derive ::pudel     ::pies)
(derive clojure.lang.Associative ::owczarek)
(derive clojure.lang.Associative ::pudel)
; => nil

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.

Przykłady użycia funkcji underive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
;; własna hierarchia:

(def h
  (-> (make-hierarchy)
      (derive ::kot  ::zwierzę)
      (derive ::pies ::zwierzę)))

h
; => {:parents {:user/kot #{:user/zwierzę}, :user/pies #{:user/zwierzę}},
; =>  :ancestors {:user/kot #{:user/zwierzę}, :user/pies #{:user/zwierzę}},
; =>  :descendants {:user/zwierzę #{:user/pies :user/kot}}}

(alter-var-root (var h) underive ::kot ::zwierzę)
; => {:ancestors {:user/pies #{:user/zwierzę}}
; =>  :descendants {:user/zwierzę #{:user/pies}}
; =>  :parents {:user/pies #{:user/zwierzę}}}

;; globalna hierarchia:

(derive   ::kot  ::zwierzę)
(derive   ::pies ::zwierzę)
(underive ::kot  ::zwierzę)
; => nil

@#'clojure.core/global-hierarchy
; => {:ancestors {:user/pies #{:user/zwierzę}}
; =>  :descendants {:user/zwierzę #{:user/pies}}
; =>  :parents {:user/pies #{:user/zwierzę}}}

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.

Przykład użycia funkcji isa?
1
2
3
4
5
6
7
8
9
10
11
12
13
(derive ::kot       ::zwierzę)
(derive ::pies      ::zwierzę)
(derive ::owczarek  ::pies)
(derive ::pudel     ::pies)

(isa? ::kot      ::zwierzę)  ; => true
(isa? ::pudel    ::pies)     ; => true
(isa? ::kot      ::pies)     ; => false
(isa? ::owczarek ::kot)      ; => false

(derive clojure.lang.Associative ::kolekcja)
(isa?   clojure.lang.Associative ::kolekcja)
; => true

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.

Przykład użycia funkcji parents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(derive ::kot       ::zwierzę)
(derive ::pies      ::zwierzę)
(derive ::owczarek  ::pies)
(derive ::pudel     ::pies)
(derive clojure.lang.Associative ::kolekcja)

(parents ::kot)       ; => #{:user/zwierzę}
(parents ::owczarek)  ; => #{:user/pies}
(parents ::kolekcja)  ; => nil

(parents clojure.lang.Associative)
; => #{:user/kolekcja
; =>   clojure.lang.ILookup
; =>   clojure.lang.IPersistentCollection}

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

Przykład użycia funkcji descendants
1
2
3
4
5
6
7
8
9
10
(derive ::kot       ::zwierzę)
(derive ::pies      ::zwierzę)
(derive ::owczarek  ::pies)
(derive ::pudel     ::pies)
(derive clojure.lang.Associative ::kolekcja)

(descendants ::kot)       ; => nil
(descendants ::owczarek)  ; => nil
(descendants ::pies)      ; => #{:user/owczarek :user/pudel}
(descendants ::kolekcja)  ; => #{clojure.lang.Associative}

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.

Przykład użycia funkcji ancestors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(derive ::kot       ::zwierzę)
(derive ::pies      ::zwierzę)
(derive ::owczarek  ::pies)
(derive ::pudel     ::pies)
(derive clojure.lang.Associative ::kolekcja)

(ancestors ::kot)       ; => #{:user/zwierzę}
(ancestors ::owczarek)  ; => #{:user/pies :user/zwierzę}
(ancestors ::kolekcja)  ; => nil

(ancestors clojure.lang.Associative)
; => #{:user/kolekcja
; =>   clojure.lang.ILookup
; =>   clojure.lang.IPersistentCollection
; =>   clojure.lang.Seqable}

Operacje na typach

Istnieją w Clojure operacje, które możemy przeprowadzać na danych różnych systemów typów.

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

Przykłady użycia funkcji type
1
2
3
4
5
6
7
8
9
10
11
12
13
(type 1)        ; => java.lang.Long
(type "abc")    ; => java.lang.String
(type \a)       ; => java.lang.Character
(type nil)      ; => nil
(type true)     ; => java.lang.Boolean
(type false)    ; => java.lang.Boolean
(type (fn []))  ; => user$eval10652$fn__10653
(type [])       ; => clojure.lang.PersistentVector
(type :a)       ; => clojure.lang.Keyword
(type 'a)       ; => clojure.lang.Symbol

(type '^{:type ::Coś} a)
; => :Coś

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 w tym wyrażeniu 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 danego typu 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 czy maszyn wirtualnych.

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

Typy proste JVM

Typy referencyjne

Typy funkcyjne

Typy powtórzeniowe

Typy wyjątkowe

  • clojure.lang.ExceptionInfo – informacje dot. wyjątku.

Typy warunkowe

Typy strumieniowe

Typy zakresowe

Typy sekwencyjne

Typy kolekcyjne

Typy transakcyjne

Typy literalne

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

Ten materiał jest częścią większej całości, którą można zobaczyć odwiedzając poniższy odnośnik:

Komentarze