Poczytaj mi Clojure, cz. 15: Polimorfizm

Polimorfizm to zbiór mechanizmów, dzięki którym te same konstrukcje języków programowania mogą być używane w odniesieniu do danych różnych rodzajów. Dzięki niemu możemy przetwarzać informacje z użyciem wyabstrahowanych, generycznych operacji, zyskując na czasie i czytelności kodu. Polimorfizm w Clojure to przede wszystkim obsługa wieloargumentowości, multimetod, protokołów i rekordów, a także korzystanie z interfejsów Javy.

Polimorfizm

Polimorfizm (ang. polymorphism, z gr. polis – wiele i morfi – postać, kształt), w dosłownym tłumaczeniu wielopostaciowość, to zbiór mechanizmów języka programowania, dzięki którym te same konstrukcje mogą być używane w odniesieniu do danych o różnych charakterystykach, na przykład o różnych typach.

Używając bardziej ogólnych terminów, polimorfizmem możemy nazwać możliwość tworzenia uogólnionych interfejsów dostępu do pewnych klas operacji, które można przeprowadzać na danych różnych rodzajów.

Dzięki operowaniu na różnych danych z użyciem tych samych interfejsów możemy stwarzać warstwy abstrakcji, które upraszczają wyrażanie rozwiązań problemów. Tworzenie i modernizowanie programów zajmuje wtedy mniej czasu, a kod jest czytelniejszy.

Prostym przykładem polimorfizmu może być obecna w wielu językach programowania konstrukcja print. Pozwala ona wysyłać na standardowe wyjście łańcuchy znakowe, które reprezentują wartości podawanych argumentów, chociaż te ostatnie mogą być danymi różnych typów: pojedynczymi znakami i łańcuchami znakowymi, liczbami całkowitymi bądź zmiennoprzecinkowymi itp.

Inny przykład to wektor odniesień do obiektów umieszczonych w pamięci. Każdy z jego elementów może mieć inny typ, ale struktura danych umożliwia grupowanie ich wszystkich, ponieważ rodzaje obiektów, z których się składa, stanowią ich nadtyp (tzw. polimorfizm inkluzyjny) lub zawierają dodatkowe pola znacznikowe, określające typy przechowywanych danych (tzw. rekordy wariantowe, przykład tzw. polimorfizmu doraźnego).

Zdefiniowaliśmy polimorfizm jako mechanizm uniezależniania operacji od typów danych, ale też od innych cech charakterystycznych. Owe cechy mogą być specyficzne dla języka lub wskazywane przez programistę i niekoniecznie związane z typami w rozumieniu ich klasycznej definicji. Na przykład dwa wektory przechowujące tylko liczby typu całkowitego mogą różnić się wyłącznie długością, a wtedy polimorfizmem będzie operacja ich przetwarzania, która doprowadzi do wywołania różnych wariantów funkcji w zależności od liczby elementów. Idąc tym tropem, polimorfizmem nazwiemy też zautomatyzowane wywoływanie funkcji obsługujących tę samą operację w zależności od charakteru wartości przekazanych jako argumenty (np. mieszczeniu się w określonym zakresie czy dopasowaniu do jakiegoś wzorca).

Dzięki polimorfizmowi możliwe jest tzw. programowanie generyczne (ang. generic programming), zwane też programowaniem uogólnionym – styl tworzenia oprogramowania, w którym algorytmy mogą operować na danych o właściwościach (a w szczególności typach) nieznanych lub znanych tylko częściowo podczas ich implementowania.

W kolejnych sekcjach wyjaśnione będą teoretyczne podstawy polimorfizmu i przedstawiona zostanie klasyfikacja jego różnych rodzajów. Jeśli celem lektury tego rozdziału jest znalezienie praktycznego sposobu na rozwiązanie jakiegoś problemu, można przenieść się do odpowiednich sekcji:

  • Jeżeli tworzona przez ciebie funkcja ma przyjmować argumenty, których wartości powinny być przekształcane do wartości innych typów (ze względu na kompatybilność lub wydajność), przeczytaj o koercji typu.

  • Jeżeli chcesz zmienić dane jednego typu w dane innego, zapoznaj się z pojęciem konwersji typu.

  • Jeżeli szukasz sposobu na ponowne definiowanie funkcji, która już istnieje i zastąpienie jej nową wersją, zajrzyj do części poświęconej nadpisywaniu funkcji.

  • Jeżeli chcesz utworzyć warianty tej samej funkcji, które będą różniły się liczbą przyjmowanych argumentów, zajrzyj do opisu przeciążania argumentowości.

  • Jeżeli twoja funkcja ma mieć różne warianty w zależności od typu wartości przekazywanej jako pierwszy argument, przeczytaj o protokołach. Pozwolą one też na przypisywanie nowych wariantów funkcji do nowo tworzonych typów danych, nieznanych podczas deklarowania operacji.

  • Jeżeli twoja funkcja ma mieć różne warianty w zależności od typów lub innych właściwości wartości przekazywanych jako jej dowolne argumenty, zainteresuj się multimetodami. One również mogą być rozszerzane o nowe warianty operacji.

  • Jeżeli chcesz utworzyć rekordowy typ danych, przypominający w użyciu mapę, którego dodatkowe operacje mogą być opcjonalnie opisane protokołami, skorzystaj z rekordów.

  • Jeżeli chcesz utworzyć obiektowy typ danych, który nie będzie wyposażony w interfejs mapy, lecz również pozwoli na implementowanie protokołów, spróbuj obiektowych typów własnych.

Problem wyrazu

Mechanizmy polimorficzne związane z obsługą operacji na danych różnych typów mogą być sposobem na poradzenie sobie z tzw. problemem wyrazu (ang. expression problem), który został sformułowany przez Philipa Waldera z Bell Labs w roku 1998. Wcześniej nazywany był on też problemem ekspresywności (ang. expressiveness problem).

Termin “ekspresywność” odnosi się tu do tzw. siły wyrazu (ang. expressive power) języków programowania. Oznacza ona zdolność do wyrażania algorytmów w przejrzysty i zwięzły sposób. W praktyce języki o dużej sile wyrazu wyposażone będą w konstrukcje składniowe i mechanizmy pozwalające tak abstrahować problemy, że ich reprezentacje w kodzie źródłowym będą przejrzyste oraz czytelne, tzn. zrozumiałe, jednoznaczne i bez zbędnych powtórzeń.

Języki, które pozwalają dodawać nowe konstrukcje składniowe będą miały większą siłę wyrazu od tych, gdzie polegamy wyłącznie na syntaktyce zaproponowanej przez ich twórców. Jednak składnia to nie wszystko, równie ważne są też zakorzenione w paradygmacie sposoby abstrahowania rodzajów danych i operacji, które można na nich wykonywać.

W niepublikowanym oficjalnie opracowaniu badacz streścił problem wyrazu następująco:

Problem Wyrazu jest nową nazwą starego problemu. Celem jest definiowanie typu danych z użyciem przypadków, gdzie do typu możemy dodawać nowe przypadki wraz z nowymi funkcjami obsługi bez rekompilacji istniejącego kodu i z zachowaniem bezpieczeństwa statycznego typizowania (np. bez rzutowania). Za typ danych możemy uznać na przykład wyrażenia, zaczynając od jednego przypadku (stałych) i jednej funkcji (ewaluatorów), a następnie dodać jeszcze jeden konstrukt (plus) i jeszcze jedną funkcję (konwersję do łańcucha znakowego). To, czy język potrafi rozwiązać Problem Wyrazu, jest dobitnym wskaźnikiem jego zdolności wyrażania.

Czym są wspomniane przypadki? Ogólnie rzecz ujmując, możemy nazwać je przypadkami zastosowań, a bardziej szczegółowo i implementacyjnie interfejsami lub protokołami dostępu do danych.

Program, nawet w języku dynamicznie typizowanym, składa się z danych określonych typów i z operacji, które na danych tych typów możemy przeprowadzać. Kwestią problematyczną jest znalezienie sposobu na to, aby dodawać obsługę nowych przypadków użycia, czyli nowych operacji na danych istniejących typów lub nowych typów danych, na których dałoby się przeprowadzać już zdefiniowane operacje. Powinno być to możliwe bez konieczności zmiany i ponownego kompilowania istniejącego kodu i bez korzystania z rzutowania typów czy sprawdzania ich w podprogramach dodawanych operacji (np. w funkcjach bądź metodach).

Aby zademonstrować problem, Walder zaproponował tabelę podobną do poniższej:

definicje typów:
class Liczba class Łańcuch
warianty
operacji:
  def plus(a, b);   … end   def plus(a, b);   … end
  def równe?(a, b); … end   def równe?(a, b); … end
end end

Widzimy, że w językach zorientowanych obiektowo (tu przykłady w Rubym) łatwo możemy dodawać nowe typy danych. Wystarczy utworzyć nową klasę. Trudniej dodawać operacje (wyrażane metodami), ponieważ wymaga to do dopisania czegoś do istniejących już klas. W języku Ruby rozwiążemy to np. przez domieszkowanie modułów, a w obiektowych językach typizowanych statycznie przez użycie tzw. klas abstrakcyjnych czy mechanizmu szablonów.

Spójrzmy, jak wyglądałaby podobna tabela dla języka zorientowanego funkcyjnie, czyli dla Clojure:

warianty obsługi typów:
definicje
operacji:
(defn plus   [a b] (cond (integer? a) … (string? a) … ))
(defn równe? [a b] (cond (integer? a) … (string? a) … ))

W Clojure z łatwością dodamy obsługę nowych operacji (w postaci funkcji), ale trudniej będzie nam dodawać obsługę nowych typów. Pojawienie się nowego rodzaju danych, który należy obsługiwać nieco inaczej, spowoduje konieczność modyfikowania istniejących funkcji.

W językach funkcyjnych, szczególnie z silnym typizowaniem, ogólnym sposobem na zgeneralizowaną obsługę danych różnych typów jest zaimplementowanie tzw. algebraicznego typu danych (ang. algebraic data type). Jest to typ złożony (ang. composite type), który pozwala grupować elementy innych (dowolnych) typów. Przykładami mogą być tu tzw. typy produktowe (krotki, rekordy) czy typy sumaryczne (rekordy z wariantami). Operacje przeprowadzane na typie algebraicznym mogą być uszczegóławiane przez tworzenie nowych funkcji, chociaż typ danych nie zmienia się. Wadą takiego podejścia będzie jednak konieczność tworzenia osobnych, dodatkowych funkcji, o których trzeba pamiętać, gdy operuje się na strukturach konkretnych rodzajów. Użycie typu algebraicznego nie oznacza automatycznie, że będziemy mieli do czynienia z abstrakcyjnymi, polimorficznymi operacjami.

Wróćmy jednak do wcześniejszej kwestii: w jaki sposób możemy sobie poradzić z problemem wyrazu w Clojure – języku dynamicznie typizowanym, którego system typów bazuje na obiektowej platformie gospodarza?

Poszukujemy rozwiązania, które spełnia trzy podstawowe warunki:

  • możliwość dodawania nowych typów (rodzajów) danych i nowych operacji ich obsługi, a także łatwego rozszerzania istniejących operacji tak, aby działały dla nowych typów danych;

  • brak wymogu powielania istniejącego kodu czy jego zmiany;

  • otwarta możliwość rozszerzania programu o nowe typy i operacje (z użyciem rozszerzeń pochodzących z różnych źródeł, których można używać jednocześnie).

Chociaż Clojure jest językiem dynamicznie typizowanym, nie oznacza to, że informacje o typach nie są przydatne. Typów potrzebujemy choćby po to, aby wybierać takie warianty generycznych operacji, które nadają się do operowania na strukturach danych o pewnych właściwościach. Na przykład inaczej zrealizujemy algorytm dodawania do siebie dwóch łańcuchów znakowych, a inaczej wartości numerycznych.

Zamiast różnych funkcji przeznaczonych dla różnych typów chcielibyśmy mieć jedną, generyczną funkcję, która potrafi wykryć z jaką strukturą ma do czynienia i zaaplikować odpowiednią dla niej operację (możliwe, że realizowaną jako inna funkcja, chociaż nie jest to warunkiem koniecznym). Przykładem takiej wbudowanej, polimorficznej funkcji jest conj, która posługuje się innym algorytmem, gdy jako argument podamy wektor, innym, gdy będzie to lista, a jeszcze innym w przypadku podania mapy:

1
2
3
(conj  [1 2] 3)        ; => [1 2 3]
(conj '(1 2) 3)        ; => (1 2 3)
(conj  {:a 2} {:b 4})  ; => {:a 2 :b 4}

Czy jesteśmy w stanie samodzielnie skonstruować funkcję, którą można rozszerzać o obsługę nowych typów danych, bez jej przepisywania, a tym samym rozwiązać problem wyrazu? W Clojure możemy poradzić sobie z tym na kilka sposobów:

  • wprowadzając wydajny, dynamiczny polimorfizm, bazujący na typach rekordowychprotokołach (pozwala on na rozdzielanie wywołań w zależności od typu pierwszego argumentu przekazywanego do funkcji);

  • wprowadzając wydajny, dynamiczny polimorfizm, bazujący na obiektowych typach własnychprotokołach (działa podobnie do rekordów, ale nie wymusza interfejsu w postaci asocjacyjnej struktury danych);

  • korzystając z multimetod – elastycznego mechanizmu dynamicznego polimorfizmu, w którym decyzja o wywołaniu konkretnej implementacji operacji zależy od rezultatów obliczeń przeprowadzonych na dowolnych argumentach przekazanych do funkcji;

  • implementując własne operacje polimorficzne, podobne do multimetod:

    • w postaci funkcji wyższego rzędu, które będzie można łatwo nadpisywać (zachowując odwołania do oryginałów) i uzależniać wynik od typu argumentów lub innych właściwości;

    • w postaci funkcji, które będą korzystały ze współdzielonej, globalnej zmiennej, zawierającej mapę odwołań do konkretnych implementacji w zależności od typu argumentów lub innych właściwości.

Rodzaje polimorfizmu

Możemy wyróżnić dwa podstawowe rodzaje polimorfizmu w zależności od etapu, na którym dochodzi do zastosowania mechanizmów jego obsługi:

  • statyczny, zwany też polimorfizmem czasu kompilacji (ang. compile-time polymorphism);
  • dynamiczny, zwany też polimorfizmem czasu uruchamiania lub polimorfizmem uruchomieniowym (ang. runtime polymorphism).

Polimorfizm statyczny realizowany jest w trakcie kompilacji, a dynamiczny podczas uruchamiania programu. Powszechnym przykładem zastosowania polimorfizmu statycznego jest przeciążanie funkcji i operatorów (w zależności od liczby argumentów), a polimorfizmu dynamicznego niektóre formy dziedziczenia i tzw. funkcje wirtualne w obiektowych językach programowania.

Zazwyczaj w językach statycznie typizowanych już podczas procesu kompilacji można wykryć, które wersje przeciążonych funkcji lub metod będą właściwymi do obsługi przekazywanego zestawu argumentów o określonych typach, a w językach typizowanych dynamicznie wiedza o właściwościach argumentów dostępna będzie dopiero po rozpoczęciu działania programu.

Języki dynamiczne mogą w czasie uruchamiania implementować te same mechanizmy polimorficzne, które w językach nie pozwalających na zmiany kodu w trakcie działania programu i/lub ze statycznym typizowaniem będą realizowane podczas kompilacji.

Przykładem powyższego może być tu przeciążanie metod. W języku C++ będzie ono realizowane w czasie kompilowania programu, a w Javie w trakcie jego uruchamiania, chyba że w podczas ich definiowania użyto modyfikatora static, private lub final.

W Clojure nie mamy do czynienia z przeciążaniem funkcji, ale z przeciążaniem ich argumentowości. Poza tym kryterium wyboru konkretnego ciała jest liczba argumentów, a nie ich typy. Przeciążanie tego rodzaju będzie techniką polimorfizmu statycznego, ponieważ właściwy obiekt uruchomieniowy określony zostanie w czasie kompilacji (nawet, jeżeli na poziomie języka Clojure funkcja wieloargumentowościowa jest jednym obiektem).

Możemy więc zauważyć, że współczesne języki programowania wysokiego poziomu mogą implementować polimorfizm na wiele sposobów i czasem to, czy będziemy mieli do czynienia z wariantem statycznym czy z dynamicznym zależy od konkretnego języka, a czasem nawet od zastosowanych w kodzie konstrukcji.

Poza rodzajami polimorfizmu ze względu na etap zastosowania pewnych mechanizmów (w trakcie kompilacji lub w czasie uruchamiania programu) możemy też klasyfikować wielopostaciowe konstrukcje w zależności od osiąganego z ich pomocą celu czy wykorzystywanych instrumentów języka.

Polimorfizm doraźny

Najczęściej wykorzystywanym w Clojure rodzajem polimorfizmu jest tzw. polimorfizm doraźny (ang. ad hoc polymorphism), czyli taki, gdzie możemy stwarzać jednolite interfejsy obsługi pewnych operacji, które mogą być potem stosowane względem danych o różnej charakterystyce (np. o różnych typach).

Ten rodzaj polimorfizmu bazuje zwykle na decyzji odnośnie tego który wariant operacji zostanie zrealizowany, gdy odwołamy się do abstrakcyjnej, polimorficznej funkcji.

Przykładami polimorfizmu doraźnego będą multimetody, protokoły, a także przeciążanienadpisywanie funkcji.

Polimorfizm inkluzyjny

W interakcjach z systemami typów platformy gospodarza znajdziemy też mechanizmy polimorfizmu inkluzyjnego (ang. inclusive polymorphism), zwanego też polimorfizmem podtypowym (ang. subtype polymorphism), który bazuje na hierarchii typów. Dzięki niemu jesteśmy w stanie tworzyć operacje obsługujące wartości pewnych typów i automatycznie wszystkich ich podtypów.

Polimorfizm inkluzyjny jest powszechnie wykorzystywany w obiektowych językach programowania, ponieważ mamy tam do czynienia z dziedziczeniem. Na przykład metoda dodaj klasy Liczba może przyjmować argument typu Liczba i dokonywać sumowania go z wartością jakiegoś pola. Jeżeli stworzymy klasę pochodną Całkowita, to odziedziczy ona metodę i gdy wywołamy dodaj na obiekcie klasy Liczba, przekazując jako argument instancję klasy Całkowita, sumowanie również zostanie przeprowadzone, bo domniemywa się, że operacja potrafi je obsłużyć. Gdyby nie potrafiła, w gestii programisty leży nadpisanie metody w klasie pochodnej.

Najprostszym przykładem polimorfizmu inkluzyjnego w Clojure jest koercja typu, możemy też zaliczyć do niego niektóre zastosowania protokołów.

Polimorfizm parametryczny

W Clojure mamy też mieli do czynienia z polimorfizmem parametrycznym (ang. parametric polymorphism), w którym możemy tworzyć operacje przystosowane do obsługi danych dowolnych typów, a potem wywoływać je, podając konkretny typ, albo pozostawiając zadanie jego odgadnięcia językowi.

Istotną różnicą względem polimorfizmu doraźnego jest to, że mamy tam do czynienia z jedną operacją, a nie jej wieloma wariantami i mechanizmem decyzyjnym.

Przykładem polimorfizmu parametrycznego może być znany z C++ system szablonów czy klas i metod generycznych w Javie. W Clojure polimorfizm tego rodzaju zaobserwujemy np. w funkcji tożsamościowej, funkcji stałej czy konstrukcjach map, reduce i podobnych – operują one na wartościach lub kolekcjach wartości dowolnych typów. Możemy też samodzielnie implementować ten rodzaj polimorfizmu, korzystając z funkcji wyższego rzędu.

Polimorfizm typów danych

W językach programowania wykorzystujących systemy typów, w których mamy do czynienia z możliwością tworzenia relacji między typami (wyróżnianiem podtypów i nadtypów) często spotkamy się z polimorficznymi mechanizmami wykorzystującymi ten sposób oznaczania danych. Za polimorfizm bazujący na typach możemy uznać wtedy dziedziczenie klas (jeżeli dochodzi do nadpisywania metod) czy obsługę argumentów, które nie są deklarowanych typów, lecz typów pozostających z nimi w relacji.

Proste operacje polimorficzne

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 typu

Konwersja typu (ang. type conversion) to operacja przekształcania wartości jednego typu do wartości innego typu danych. Podczas konwersji powstaje nowa wartość na bazie odpowiednio zmienionych danych struktury źródłowej.

Przykładem konwersji może być zmiana łańcucha znakowego reprezentującego liczbę całkowitą w wartość numeryczną typu całkowitego lub zmiana wartości logicznej w odpowiadający jej symboliczny napis. Z konwersją będziemy też mieli do czynienia, gdy zmienimy liczbę całkowitą w jej dłuższy lub krótszy odpowiednik, tworząc nowy obiekt – np. w konwersji wartości typu Integer do wartości typu Long czy Short.

W Clojure do konwersji typu służą odpowiednie funkcje – zazwyczaj te same, które używane są też do tworzenia nowych wartości określonych typów. Możemy między innymi dokonywać konwersji:

  • (byte        wartość) – do bajtu,
  • (short       wartość) – do liczby całkowitej krótkiej,
  • (int         wartość) – do liczby całkowitej,
  • (long        wartość) – do liczby całkowitej długiej,
  • (float       wartość) – do liczby zmiennoprzecinkowej,
  • (double      wartość) – do liczby zmiennoprzecinkowej podwójnej precyzji,
  • (bigdec      wartość) – do liczby dziesiętnej nieograniczonej,
  • (bigint      wartość) – do liczby całkowitej nieograniczonej (typ Clojure),
  • (biginteger  wartość) – do liczby całkowitej nieograniczonej (typ Javy),
  • (num         wartość) – do wartości numerycznej reprezentowanej obiektem,
  • (rationalize wartość) – do liczby ułamkowej,
  • (booleans    tablica) – do tablicy wartości logicznych,
  • (bytes       tablica) – do tablicy bajtów,
  • (chars       tablica) – do tablicy znaków,
  • (shorts      tablica) – do tablicy liczb całkowitych krótkich,
  • (ints        tablica) – do tablicy liczb całkowitych,
  • (longs       tablica) – do tablicy liczb całkowitych długich,
  • (floats      tablica) – do tablicy liczb zmiennoprzecinkowych,
  • (doubles     tablica) – do tablicy liczb zmiennoprzecinkowych podwójnej precyzji.
Przykłady konwersji typów
1
2
3
4
5
6
7
8
9
(str    123)  ; => "123"
(int   123M)  ; => 123
(long  123M)  ; => 123
(str  false)  ; => "false"
(int     \a)  ; => 97
(char    97)  ; => \a

(Integer/parseInt "1234")  ; => 1234
(String/valueOf     1234)  ; => "1234"

W przypadku kolekcji języka Clojure mamy do czynienia z interfejsem java.util.Collection, którego konstruktor jest w stanie dokonywać konwersji. Niektóre funkcje czynią z tego użytek.

1
2
3
4
5
6
(java.util.ArrayList. [1 2 3])  ; => [1 2 3]
(java.util.HashMap. {"a" 1})    ; => {"b" 2, "a" 1}
(into []  '(1 2 3))             ; => [1 2 3]
(into #{} '(1 2 3))             ; => #{1 2 3}
(vec      '(1 2 3))             ; => [1 2 3]
(set      '(1 2 3))             ; => #{1 2 3}

Rzutowanie typu

Rzutowanie typu (ang. type casting) to operacja zmiany oznaczenia typu danych z pozostawieniem wartości w postaci identycznej z oryginalną. Polega na potraktowaniu wartości określonego typu danych tak, jakby była wartością innego typu, zazwyczaj w celu przeprowadzenia na niej operacji, której na oryginalnym typie nie można byłoby wykonać.

Rzutowanie bywa często używane do inicjowania nowo tworzonego obiektu wartością innego obiektu lub do przeprowadzenia operacji arytmetycznej, która wymaga użycia wartości o określonym typie.

Uwaga: Często spotkamy się z zastosowaniem terminu “rzutowanie” dla oznaczenia konwersji typów. Wynika to z nomenklatury przyjętej w niektórych środowiskach (np. w Javie). Warto zauważyć, że w niektórych konwersjach typów będzie dochodziło również do rzutowania.

Przykładem rzutowania może być potraktowanie jednobajtowej wartości jak znaku reprezentowanego jej kodem (np. w języku C), albo liczby całkowitej typu Integer jak liczby typu Long, aby w efekcie obliczeń uzyskać wynik typu Long.

W obiektowych systemach typów z rzutowaniem będzie często związany warunek mówiący, że typ obiektu, który ma być rzutowany, powinien być związany relacją dziedziczenia z obiektem, do którego jest rzutowany. Nie powstaje wtedy nowy obiekt, lecz obecny jest traktowany tak, jakby był instancją klasy bazowej lub pochodnej. Na przykład w Javie:

Przykłady rzutowania typów referencyjnych w Javie
1
2
3
String s = "napis";    // obiekt typu String
Object o = value;      // automatyczne rzutowanie do nadtypu Object
String x = (String) o; // jawne rzutowanie do podtypu String

Powyższe przykłady obrazują tzw. rzutowanie referencyjne, ponieważ zmienne s, ox nie zawierają obiektów, ale odniesienia do nich. Przypomina to rzutowanie typów wskaźnikowych w języku C – zmienne wskaźnikowe zajmują tyle samo miejsca, niezależnie od tego, na dane jakich typów wskazują, wystarczy więc odpowiednio je oznaczyć, korzystając z operatora rzutowania:

Przykłady rzutowania typów w języku C
1
2
3
char *c = "abc";
void *p = c;
char *x = (char *) p;

W Clojure praktycznie nie będziemy mieli do czynienia z bezpośrednim rzutowaniem typów. Na przykład podczas wykonywania konwersji typu używana w tym celu funkcja może skorzystać z operatora rzutowania Javy (bądź innej platformy gospodarza), ale nie będzie to operacja, którą programista wyrazi wprost. Możemy dowiadywać się, które konstrukcje języka Clojure będą korzystały z rzutowania, jednak bez kontekstu trudno będzie nam szybko odróżnić tę operację od konwersji.

Przykłady rzutowania i konwersji typów
1
2
3
4
5
6
7
8
9
10
11
12
;; rzutowanie dokonywane wewnętrznie przez funkcję int:
(int "1234")
; >> java.lang.ClassCastException:
; >> java.lang.String cannot be cast to java.lang.Character

;; konwersja do java.lang.String (brak rzutowania):
(str 1234)
; => "1234"

;; konwersja wykorzystująca rzutowanie do java.lang.Integer:
(int 1234M)
; => 1234
  • Pierwsze wyrażenie wywołuje metodę Javy odpowiedzialną za jawną konwersję typu, która wewnętrznie będzie rzutowaniem. Niestety, rzutowanie łańcuchów znakowych do typu całkowitego nie jest możliwe i zgłaszany jest wyjątek.

  • Wyrażenie drugie to konwersja. Chociaż nazwa funkcji sugeruje jakobyśmy mieli do czynienia z rzutowaniem (przez analogię do obsługi typów numerycznych), w istocie jest to budowanie nowego obiektu i konwersja liczby całkowitej do łańcucha znakowego.

  • Przedostatni przykład to zmiana liczby typu Long w krótszą (typu Integer). Nawet jeżeli w JVM dojdzie do rzutowania, to rezultatem wywołania funkcji int w Clojure będzie stworzenie nowej wartości (nowego obiektu typu java.lang.Integer). Będzie to więc rzutowanie z inicjowaniem, czyli konwersja.

Z rzutowaniem będziemy mieli najczęściej do czynienia wtedy, gdy dokonają go wywoływane z poziomu konstrukcji języka Clojure operatory platformy gospodarza. Na przykład funkcja int wewnętrznie rzutuje podaną wartość do typu Integer i na tej podstawie tworzy nowy obiekt. Efektywnie mamy do czynienia z konwersją, ale na poziomie JVM wykorzystywane może być właśnie rzutowanie.

Rzutowanie używane bywa również w odniesieniu do typów podstawowych. Dzięki temu możemy tworzyć wydajne pętle czy przeprowadzać szybkie rachunki arytmetyczne.

Przykłady rzutowania do typów podstawowych
1
2
3
4
(+ (double 2) 5)
(let  [a  (long 3)] a)
(fn   [a] (int a))
(loop [a  (int 0)] (when (< a 10000) (recur (inc a))))

Funkcje longdouble, służące do konwersji i rzutowania typów, zwrócą wartości typów podstawowych (nieopakowanych) pod warunkiem, że konstrukcja, do której trafią te wartości, będzie potrafiła z nich skorzystać. Do ewentualnych optymalizacji dojdzie na etapie kompilacji programu.

Zobacz także:

Koercja typu

Koercja typu (ang. type coercion) przypomina konwersję i jest operacją, która polega na przekształcaniu wartości argumentu przekazanego do wywołania funkcji do wartości innego, oczekiwanego typu. Realizowana jest najczęściej z użyciem rzutowania lub konwersji typu, a w Clojure również z wykorzystaniem tzw. sugerowania typu.

Koercja jest stosowana w przypadkach, w których użycie oryginalnej wartości argumentu spowodowałoby wystąpienie błędu. Czasem też używana jest w celu zwiększenia wydajności obliczeń.

W Clojure z reguły powinno się unikać koercji, ponieważ kompilator sam potrafi dokonywać potrzebnych optymalizacji. Zdarzają się jednak sytuacje, w których warto przekształcić wartości przekazywanych argumentów, aby przeprowadzać operacje arytmetyczne lub wywoływać metody obiektów systemu gospodarza bez korzystania z mechanizmu refleksji.

Aby uruchamiać podprogramy (m.in. funkcje) Clojure korzysta z interfejsu clojure.lang.IFn. Jeżeli jakiś obiekt go implementuje, może zostać wywołany, co będzie polegało na uruchomieniu przypisanego mu podprogramu. W istocie wiąże się to z wywołaniem metody invoke danego obiektu:

1
2
(        str 123)  ; => "123"
(.invoke str 123)  ; => "123"

Podczas wywoływania funkcji przekazywane są do niej argumenty, a typem każdej referencji, z użyciem której jest to dokonywane, wewnętrznie będzie Object (w przypadku JVM). Jest to tylko mechanizm transportowania wartości do funkcji – referencyjnej koercji, a nie konwersji. Gdy w ciele funkcji będziemy obsługiwali wartości parametrów, ich oryginalne typy będą zachowane:

1
2
3
(defn fun [a] (type a))
(fun "test")
; => java.lang.String

Spójrzmy co się stanie, gdy w funkcji wywołamy metodę Javy, która powinna działać dla jakiegoś obiektu o z góry ustalonym typie:

1
2
3
4
5
6
7
8
(set! *warn-on-reflection* true)

(defn fun [a] (.length a))
; >> Reflection warning, /tmp/form-init4959455601282597355.clj:1:15
; >> - reference to field length can't be resolved.

(fun "test")
; => 4

Widzimy, że podczas kompilacji nie było możliwe ustalenie, czy przyjmowany obiekt będzie wyposażony w metodę length – poinformował nas o tym komunikat ostrzeżenia. Sprawdzanie zostanie więc odłożone do czasu uruchamiania programu, co w niektórych przypadkach może mieć ujemny wpływ na wydajność obliczeń. Warto wtedy skorzystać z wyrażonej wprost koercji.

W praktyce koercja w Clojure będzie polegała na konwersji parametru funkcji w jej ciele lub na zastosowaniu tzw. sugerowania typów w odniesieniu do argumentów i/lub wartości zwracanych.

Przykłady koercji
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
(set! *warn-on-reflection* true)

;; bez koercji
(defn sumuj [min max]
  (loop [n min acc 0]
    (if (> n max) acc (recur (inc n) (+ acc n)))))

;; koercja bazująca na obiekcie funkcyjnym
(defn sumuj-fun [f min max]
  (let [min (f min) max (f max)]
    (loop [n min acc (f 0)]
      (if (> n max) acc (recur (inc n) (+ acc n))))))

;; koercja lokalna do określonego typu
(defn sumuj-long [min max]
  (let [min (long min) max (long max)]
    (loop [n min acc (long 0)]
      (if (> n max) acc (recur (inc n) (+ acc n))))))

;; koercja bazująca na sugerowaniu typów
(defn sumuj-hinting ^long [^long min ^long max]
  (loop [n min acc 0]
    (if (> n max) acc (recur (inc n) (+ acc n)))))

;; koercja bazująca na sugerowaniu typów
;; jako makro (możliwość podania typu)
(defmacro sumuj-steroids [t min max]
  (let [ttag  {:tag t}
        fmin  (gensym 'min)
        fmax  (gensym 'max)
        fmina (with-meta fmin ttag)
        fmaxa (with-meta fmax ttag)
        fname (with-meta (gensym 'sum) ttag)]
    `((fn ~fname [~fmina ~fmaxa]
        (loop [n# ~fmin acc# 0]
          (if (> n# ~fmax) acc# (recur (inc n#) (+ acc# n#)))))
      ~min ~max)))

(def n 10000000)

(time (sumuj               0 n))  ; >> "Elapsed time: 2550.26655 msecs"
(time (sumuj-fun long      0 n))  ; >> "Elapsed time: 2508.790539 msecs"
(time (sumuj-long          0 n))  ; >> "Elapsed time: 884.280539 msecs"
(time (sumuj-hinting       0 n))  ; >> "Elapsed time: 860.636767 msecs"
(time (sumuj-steroids long 0 n))  ; >> "Elapsed time: 891.377236 msecs"

Widzimy, że wersja sumatora bez koercji (funkcja sumuj) jest niemal trzykrotnie powolniejsza, niż wersje z koercją, za wyjątkiem funkcji sumuj-fun, w której dokonujemy koercji z użyciem przekazywanego obiektu funkcyjnego, który ma ją obsłużyć (long). Okazuje się jednak, że tego typu konstrukcje nie są optymalizowane w czasie kompilacji i do czynienia mamy z przekształcaniem typu w czasie pracy programu (czyli korzystaniem z java.lang.Long).

W przypadku trzech ostatnich implementacji obserwujemy zysk wydajnościowy. Różnią się one czasami realizacji, lecz wynika to z narzutu na tworzenie innych obiektów; na przykład makro sumuj-steroids potrzebuje trochę czasu na przygotowanie symboli. Warto mieć na uwadze, że podane czasy obejmują kompilację i ewaluację, ponieważ mamy do czynienia z REPL.

Istnieje kilka prostych zasad, o których warto pamiętać, gdy zamierzamy tworzyć funkcje, w których jawnie bądź automatycznie dochodzi do koercji:

  • Jeżeli koercji dokonujemy w ciele funkcji, warto korzystać z powiązań leksykalnych (np. formuły let), aby jednokrotnie dokonać konwersji, a następnie używać wartości odpowiedniego typu.

  • Warto korzystać z wartości pochodzącej z konwersji w najbardziej zagnieżdżonym wyrażeniu (pętli), czyli tam, gdzie rzeczywiście jest to potrzebne (reszta konstrukcji powinna mieć dostęp do oryginalnej wartości parametru).

  • Tam, gdzie to możliwe, warto używać tzw. sugerowania typów zamiast konwersji – jest to czytelny i wydajny sposób koercji.

Sugerowanie typu

Sugerowanie typu (ang. type hinting), zwane też podpowiadaniem typu, to mechanizm języka Clojure, który pozwala oznaczać niektóre wyrażenia (w tym argumenty i wartości zwracane przez funkcje) identyfikatorami typów danych.

Użycie:

  • ^typ        S-wyrażenie,
  • ^{:tag typ} S-wyrażenie.

Podpowiedzi dotyczące typów to metadane umieszczane przed symbolami lub innymi S-wyrażeniami, które tworzą powiązania bądź zwracane wartości. Możemy je zastosować:

Ze względu na rodzinę typów, do których dokonywane jest rzutowanie, będziemy mieli do czynienia z dwoma rodzajami sugerowania:

Gdy przed S-wyrażeniem umieścimy znacznik sugerowania typu, w fazie kompilacji dojdzie do uruchomienia procesu podążania za rezultatami wartościowania reprezentowanej nim formuły i w miarę możliwości oznaczania ich pierwotną sugestią odnośnie typu. Dla każdej oznaczonej konstrukcji będą zastosowane optymalizacje polegające na użyciu konkretnych, podanych typów zamiast typów uogólnionych (np. Object). Zwiększa to wydajność, ponieważ wewnętrzne wywołania metod pochodzących z platformy gospodarza nie muszą korzystać z mechanizmu refleksji. Poza tym w przypadku typów podstawowych sugerowanie typów umożliwia operowanie bezpośrednio na wartościach, a więc bez narzutu w postaci obsługi obiektów.

Przykłady sugerowania typów w Clojure
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
(set! *warn-on-reflection* true)

;; zmienna globalna
(def ^String x "a")

;; wektor parametryczny funkcji
(defn fun [^String a] a)
(fn       [^String a] a)

;; wartość zwracana funkcji
(defn fun ^String [a] a)
(fn       ^String [a] a)

;; wektor powiązaniowy
(let [^String a "a"] a)

;; wywołania funkcji
(.equals ^String (str "a") ^String (str "a"))
(.equals ^String (fun "a") ^String (fun "a"))

;; porównanie wydajności wywołania metody na obiekcie
;;
(defn długość        [acc         x] (+' acc (.length x)))
(defn długość-string [acc ^String x] (+' acc (.length x)))
(def  łańcuchy (repeat 100000 "xxxxxxxxxxx"))

(time (reduce długość 0 łańcuchy))
; >> "Elapsed time: 1495.502125 msecs"
; => 1100000

(time (reduce długość-string 0 łańcuchy))
; >> "Elapsed time: 34.815388 msecs"
; => 1100000

W przykładach powyżej mieliśmy do czynienia z sugerowaniem typów obiektowych, tzn. takich, których dane reprezentowane są z użyciem obiektowego systemu typów gospodarza.

Gdyby jednak zaszła potrzeba bezpośredniego skorzystania z wartości typu podstawowego, który został opakowany, to – poza rzutowaniem – możemy w Clojure skorzystać z sugestii typów podstawowych (ang. primitive type hints). Użycie jej sprawi, że już na etapie kompilacji dojdzie do optymalizacji zastosowanych konstrukcji. Jeżeli nie będzie to możliwe, zgłoszony zostanie wyjątek java.lang.ClassCastException, ponieważ proces wypakowywania typu podstawowego z typu obiektowego realizowany jest wewnętrznie z użyciem rzutowania.

Obsługa sugerowania typów, poza typami obiektowymi obejmuje:

  • podstawowe typy proste:
    •    long – liczba całkowita dłuższa,
    •  double – liczba zmiennoprzecinkowa podwójnej precyzji;
  • podstawowe typy złożone:
    •    ints – tablica liczb całkowitych,
    •   longs – tablica liczb całkowitych dłuższych,
    •  floats – tablica liczb zmiennoprzecinkowych,
    • doubles – tablica liczb zmiennoprzecinkowych podwójnej precyzji.

Uwaga: Nie możemy dokonywać sugerowania typów podstawowych w odniesieniu do lokalnych powiązań. Próby takie spowodują zgłoszenie wyjątku java.lang.UnsupportedOperationException. Zamiast tego należy stosować rzutowanie.

Przykłady sugerowania rozpakowanych typów podstawowych w Clojure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(set! *warn-on-reflection* true)

;; zmienna globalna
(def ^long x 5)

;; wektor parametryczny funkcji
(defn fun [^double a] a)
(fn       [^double a] a)

;; wartość zwracana funkcji
(defn fun ^double [a] a)
(fn       ^double [a] a)

;; wywołania funkcji
^double (identity 5)
(= ^long   (+   2) ^long   (+   2))
(+ ^double (fun 2) ^double (fun 1))

Uwaga: W przypadku argumentów funkcji, które wyposażono w sugerowanie typów podstawowych, a koercja wartości do wskazanego typu nie będzie możliwa, zgłoszony zostanie wyjątek java.lang.ClassCastException.

Rekordy

Rekord (ang. record), nazywany też danymi zespolonymi (ang. compound data) to struktura danych służąca do przechowywania kolekcji nazwanych elementów, zazwyczaj o z góry określonej liczbie, często o określonych typach (w językach mocno typizowanych). Elementy wchodzące w skład rekordów nazywamy polami (ang. fields).

Przykładem informacji, które nadają się do przechowywania w postaci rekordów mogą być dane teleadresowe i/lub osobowe czy wszelkiego rodzaju kartoteki (np. spisy książek, płyt, wydarzeń bądź innych rzeczy lub procesów o wspólnych cechach). Rekordem wyrazimy wtedy pojedynczą daną, na którą składają się pewne pola, np. dane kontaktowe konkretnej osoby.

Rekordowy typ danych (ang. record data type) to z kolei definiowany przez programistę typ, na bazie którego będzie można tworzyć rekordy. Określamy w nim przede wszystkim jakie pola będą wchodziły w skład tworzonych na jego bazie rekordów.

Z użyciem odpowiednich konstrukcji języka programowania można definiować rekordowe typy danych, a następnie posługiwać się wartościami tych typów. Na przykład w języku C służy do tego konstrukcja struct, a w Clojure możemy skorzystać z makra defrecord.

Rekordy w Clojure są obiektami systemu gospodarza, a tworzone typy rekordowe klasami, które implementują interfejsy clojure.lang.IObj (obsługa metadanych) oraz clojure.lang.IPersistentMap (mapa). Nie należy jednak dać się zmylić: interfejs mapy nie oznacza, że rekord jest mapą – po prostu możemy odwoływać się do jego pól tak, jak do elementów mapy.

Uwaga: Rekordy są obiektami stworzonymi na bazie klas systemu gospodarza, a deklarowane podczas tworzenia typów rekordowych pola są polami tych klas. W przeciwieństwie do map nie będziemy mieli do czynienia ze współdzieleniem struktur rekordów w momencie tworzenia obiektów pochodnych – w pamięci powstawały będą zupełnie nowe instancje źródłowej struktury danych. Zyskujemy jednak na szybkości odwoływana się do poszczególnych pól.

Obsługa rekordowego typu danych w Clojure jest przykładem mechanizmu polimorficznego z dwóch powodów:

  1. Mimo, że jego obiekty są osobnym typem, możemy traktować je jak mapy i korzystać z funkcji obsługujących tę strukturę danych (polimorfizm podtypowy).

  2. Definiując typy rekordowe, możemy korzystać z tzw. protokołów, dzięki którym jesteśmy w stanie sprawić, że będą one obsługiwane przez stworzone warianty polimorficznych funkcji, gdy pierwszym przekazywanym argumentem wywołań będzie dany typ rekordowy (polimorfizm doraźny). Możliwe jest również dodawanie nowych funkcji obsługi do istniejących typów rekordowych.

Definiowanie typów rekordowych, defrecord

Makro defrecord pozwala definiować typy rekordowe. Jego użycie sprawia, że zostanie dynamicznie wygenerowana kompilowana do kodu bajtowego klasa definiująca rekordowy typ danych, wraz z zawierającym ją pakietem 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 zmienną specjalną *compile-path*.

W instancjach tworzonego z użyciem makra defrecord 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 rekordów powstałych na bazie zdefiniowanego typu będzie można 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, które mogą zawierać sugestie typów. Po nazwach pól możemy podać opcje (obecnie nieobsługiwane) i tzw. specyfikacje (ang. skr. specs).

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.

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 dodane będą również następujące metody:

  • hashCode – służąca do wyliczania skrótu struktury danych;
  • equals – służąca do porównywania zawartości map i rekordów;
  • = – służąca do porównywania zawartości oraz typów;
  • konstruktor przyjmujący wartości pól i wartości pól rozszerzeniowych;
  • konstruktor przyjmujący wartości pól, metadane i mapę pól rozszerzeniowych.

Poza tym stworzone będą funkcje wywołujące wspomniane konstruktory:

  • (->nazwa    wartość…),
  • (map->nazwa mapa);

gdzie nazwa jest nazwą stworzonego typu rekordowego, wartość wartością pola, a mapa mapą zawierającą nazwy pól (wyrażone słowami kluczowymi) i przypisane im wartości.

Pierwsza z funkcji pozwala tworzyć rekordy przez podanie wartości pól wyrażonych pozycyjnie, a druga działa tak samo, lecz przyjmuje asocjacje zawarte w mapie.

Uwaga: Nie należy tworzyć rekordów przez bezpośrednie wywoływanie konstruktorów obiektu. Lepiej skorzystać z funkcji języka Clojure, które służą do tego celu: ->typ i map->typ.

W efekcie pracy makra zdefiniowana zostanie klasa o podanych polach i metodach. Wartości pól można będzie uzyskiwać w taki sam sposób, w jaki robi się to korzystając z map: z użyciem odpowiednich funkcji lub makr czytnika.

Przykład użycia makra defrecord
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
;; definiowanie typu rekordowego
;;
(defrecord Osoba [imię nazwisko e-mail])
; => user.Osoba

;; tworzenie rekordu typu Osoba przez wywołanie konstruktora
;;
(def Paweł (Osoba. "Paweł" "Wilk" "pw-na-gnu.org"))

;; tworzenie rekordu typu Osoba przez wywołanie funkcji ->
;;
(def Paweł (->Osoba "Paweł" "Wilk" "pw-na-gnu.org"))

;; sprawdzanie typów
;;
(type Osoba)       ; => java.lang.Class
(type Paweł)       ; => user.Osoba

;; wywoływanie akcesorów pól
;;
(.imię     Paweł)  ; => "Paweł"
(.nazwisko Paweł)  ; => "Wilk"

;; traktowanie rekordu jak mapy
;;
(:imię     Paweł)  ; => "Paweł"
(:nazwisko Paweł)  ; => "Wilk"

(conj Paweł {:płeć :m})

; => #user.Osoba{:e-mail   "pw-na-gnu.org"
; =>             :imię     "Paweł"
; =>             :nazwisko "Wilk"
; =>             :płeć     :m}

Tworzenie rekordów, ->typ

Tworzyć rekordy możemy (i powinniśmy!) nie tylko bezpośrednio, odwołując się do konstruktora, 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 rekordu. Wartością zwracaną jest rekord.

Przykład użycia funkcji ->typ
1
2
3
4
5
6
7
(defrecord Osoba [imię nazwisko e-mail])

(->Osoba "Paweł" "Wilk" "pw-na-gnu.org")

; => #user.Osoba{:e-mail   "pw-na-gnu.org"
; =>             :imię     "Paweł"
; =>             :nazwisko "Wilk"}

Tworzenie rekordów z map, map->typ

Tworzyć rekordy na bazie map możemy z użyciem generowanej podczas definiowania nowego typu funkcji map->typ (gdzie typ jest nazwą typu).

Użycie:

  • (map->typ mapa).

Argumentami wywołania funkcji powinny być wartości kolejnych pól definiowanych przez typ rekordu wyrażone mapą, której kluczami są słowa kluczowe określające nazwy pól, a wartościami ich wartości.

Jeżeli podamy pole o nazwie, która nie pasuje do nazwy żadnego ze zdefiniowanych pól, zostanie ono dodane do wynikowej struktury jako pole rozszerzeniowe. Jeżeli nie podamy pola, które jest zdefiniowane danym typem rekordowym, jego wartością będzie nil.

Wartością zwracaną przez funkcję jest rekord.

Przykład użycia funkcji map->typ
1
2
3
4
5
6
7
8
(defrecord Osoba [imię nazwisko e-mail])

(map->Osoba {:nazwisko "Wilk" :imię "Paweł" :płeć :m})

; => #user.Osoba{:e-mail   nil
; =>             :imię     "Paweł"
; =>             :nazwisko "Wilk"
; =>             :płeć     :m}

Rekordowe S-wyrażenia

Tworzyć rekordy możemy również z użyciem rekordowego S-wyrażenia (ang. record S-expression), które jest makrem czytnika i przypomina mapowe S-wyrażenie. Od tego ostatniego różni się tym, że rozpoczynane jest symbolem kratki (#) i pełną nazwą typu rekordowego (włączając przestrzeń nazw), umieszczonymi przed otwierającym nawiasem klamrowym.

Użycie:

  • #przestrzeń-nazw.typ-rekordowy{pole-wartość…},

gdzie pole-wartość to:

  • :pole wartość.

Nazwy pól powinny być wyrażone słowami kluczowymi.

Jeżeli podamy pole o nazwie, która nie pasuje do nazwy żadnego ze zdefiniowanych pól, zostanie ono dodane do wynikowej struktury jako pole rozszerzeniowe. Jeżeli nie podamy pola, które jest zdefiniowane danym typem rekordowym, jego wartością będzie nil.

Przykład użycia funkcji map->typ
1
2
3
4
5
6
7
8
(defrecord Osoba [imię nazwisko e-mail])

#user.Osoba{:imię "Paweł" :nazwisko "Wilk" :płeć :m}

; => #user.Osoba{:e-mail   nil
; =>             :imię     "Paweł"
; =>             :nazwisko "Wilk"
; =>             :płeć     :m}

Własne typy obiektowe

Nowe typy obiektowe możemy tworzyć z użyciem makra deftype. Powstające w ten sposób typy danych są implementowane z użyciem systemu klas platformy gospodarza.

Tworzenie typów przypomina generowanie typów rekordowych: również tworzone są klasy wyposażone w odpowiednie konstruktory, ale nie jest narzucany interfejs dostępu do danych w postaci mapy (klasy nie implementują interfejsu clojure.lang.IPersistentMap). Nie ma nawet wymogu, aby deklarować jakiekolwiek pola.

Obsługa obiektowych typów danych w Clojure jest przykładem mechanizmu polimorficznego, ponieważ podczas ich definiowania możemy korzystać z tzw. protokołów i implementować konkretne warianty funkcji polimorficznych, których pierwszym przekazywanym argumentem będzie dany typ (polimorfizm doraźny). Możliwe jest również dodawanie nowych funkcji obsługi do istniejących typów.

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 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 zmienną specjalną *compile-path*.

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ści true (lub użycie ^:volatile-mutable) sprawi, że pole zostanie oznaczone jako mutowalny obiekt ulotny (modyfikator volatile Javy);

  • :unsynchronized-mutable – ustawienie wartości true (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ą stworzonego 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.

Przykład użycia makra deftype
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(deftype Osoba [imię nazwisko wiek])
; => user.Osoba

;; tworzenie rekordu typu Osoba przez wywołanie konstruktora
;;
(def Paweł (Osoba. "Paweł" "Wilk" 18))

;; tworzenie rekordu typu Osoba przez wywołanie funkcji ->
;;
(def Paweł (->Osoba "Paweł" "Wilk" 18))

;; sprawdzanie typów
;;
(type Osoba)       ; => java.lang.Class
(type Paweł)       ; => user.Osoba

;; wywoływanie akcesorów pól
;;
(.imię     Paweł)  ; => "Paweł"
(.nazwisko Paweł)  ; => "Wilk"

Tworzenie obiektów, ->typ

Tworzyć obiekty zdefiniowanych samodzielnie typów obiektowych 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.

Przykład użycia funkcji ->typ
1
2
3
4
(deftype Osoba [imię nazwisko wiek])

(->Osoba "Paweł" "Wilk" 18)
; => #object[user.Osoba 0x53dda21f "[email protected]"]

Uprzedmiotowianie typów

[TBW]

Polimorfizm operacji

Polimorficzne mechanizmy obsługi funkcji umożliwiają tworzenie zgeneralizowanych operacji, których konkretne warianty będą zależały od wielu czynników, np. liczby bądź typów przekazywanych argumentów, czy nawet rezultatów obliczeń prowadzonych na danych wejściowych lub globalnych stanów.

Przeciążanie argumentowości

Przeciążanie argumentowości (ang. arity overloading) to mechanizm polimorfizmu doraźnego, polegający na statycznym lub dynamicznym wyborze jednej z wielu operacji widocznych pod jedną nazwą i implementowanych jako jeden obiekt w zależności od liczby bądź typów przekazywanych argumentów.

W Clojure przeciążanie argumentowości bazuje na liczbie przekazywanych argumentów i może być realizowane w odniesieniu do funkcji oraz makr.

Przykład przeciążania argumentowości funkcji nazwanej
1
2
3
4
5
6
7
8
(defn fun
  ([]         "brak argumentów")
  ([a]        (str "jeden argument: " a))
  ([a & args] (println "wiele argumentów:" a (apply print-str args))))

(fun)        ; => "brak argumentów"
(fun 123)    ; => "jeden argument: 123"
(fun 1 2 3)  ; => "wiele argumentów: 1 2 3"

Zobacz także:

Nadpisywanie funkcji

Nadpisywanie funkcji (ang. function overriding) to mechanizm polimorfizmu doraźnego, w którym dochodzi do podmiany operacji na inną w pewnych kontekstach przy zachowaniu sygnatury jej wywołania oraz nazwy. Przykładem kontekstu w języku obiektowym może być użycie podtypu i wywołanie zdefiniowanej w klasie potomnej innej wersji tej samej metody.

W językach funkcyjnych nie mamy do czynienia z typowym nadpisywaniem, ale istnieją operacje, które efektywnie realizują cel tego procesu. Ich przykładami będą redefinicje lub przesłonięcia funkcji.

W języku Clojure mechanizmy, które wyrażają operację nadpisywania funkcji to:

Przykłady nadpisywania funkcji
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
(defn            funkcja-1  [] "pierwotna 1")
(defn ^:dynamic *funkcja-2* [] "pierwotna 2")

(funkcja-1)    ; => "pierwotna 1"
(*funkcja-2*)  ; => "pierwotna 2"

;; aktualizowanie funkcji globalnych
;;
(defn funkcja-1 [] "zaktualizowana 1")
(funkcja-1)       ; => "zaktualizowana 1"

(alter-var-root (var funkcja-1)
                (constantly (fn [] "bezpiecznie zaktualizowana 1")))
(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"

;; przesłanianie leksykalne funkcji
;;
(letfn [(funkcja-1 [] "przesłonięta leksykalnie 1")]
  (funkcja-1))    ; => "przesłonięta leksykalnie 1"
(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"

(let [funkcja-1 (fn [] "przesłonięta leksykalnie 1")]
  (funkcja-1))    ; => "przesłonięta leksykalnie 1"
(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"

;; przesłanianie funkcji dynamicznych
;;
(binding [*funkcja-2* (fn [] "dynamicznie przesłonięta 2")]
  (*funkcja-2*))  ; => "dynamicznie przesłonięta 2"
(*funkcja-2*)     ; => "pierwotna 2"

;; redefinicja funkcji
;;
(with-redefs [funkcja-1 (fn [] "redefiniowana 1")]
  (funkcja-1))    ; => "redefiniowana 1"
(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"

W przypadku użycia defnalter-var-root mamy do czynienia z globalną zmianą powiązania głównego obiektu typu Var wskazującego na funkcję. Nowa funkcja, na którą wskazuje powiązanie, będzie współdzielona między wątkami.

W przypadku let oraz letfn będziemy mieli do czynienia z przesłonięciem symbolu powiązanego ze zmienną globalną przechowującą odniesienie do funkcji. Zmiana będzie izolowana w bieżącym wątku, a dodatkowo ograniczona zasięgiem leksykalnym.

Jeżeli chodzi o użycie binding, to będziemy mieli przesłonięcie powiązania obiektu typu Var z funkcją ograniczone zasięgiem dynamicznymizolowane w bieżącym wątku. Pozostałe wątki będą korzystały z powiązania głównego.

Ostatni przykład, polegający na wywołaniu with-redefs, to globalna zmiana powiązania głównego obiektu typu Var. Nowa wartość będzie współdzielona między wątkami do zakończenia przetwarzania wyrażeń podanych jak ostatni argument. Po tym czasie przywrócone będzie poprzednie powiązanie główne.

Możemy również dokonywać operacji typowo obiektowych, nadpisując metody zdefiniowane w klasach obiektowego systemu typów:

Multimetody

Multimetody (ang. multimethods), zwane też wielometodami lub dyspozycją wielokierunkową (ang. multiple dispatch) są mechanizmem dynamicznego polimorfizmu doraźnego, który polega na tym, że konkretny wariant operacji (dostosowany do obsługi danych o pewnej charakterystyce) wybierany jest w zależności od określonych przez programistę właściwości wartości przekazywanych jako argumenty wywołania. Tymi ostatnimi mogą być typy (w przypadku Clojure obiektowe lub doraźne), ale także inne cechy danych, które da się wyrazić pojedynczymi wartościami po przeprowadzeniu na nich jakichś obliczeń.

W Clojure obsługa multimetod polega na definiowaniu funkcji dyspozycyjnych (ang. dispatch functions) i związanych z nimi zestawów funkcji realizujących operację w różnych wariantach. Te ostatnie nazywane są metodami (ang. methods), lecz nie należy mylić tego pojęcia z definicją metody pochodzącą z programowania zorientowanego obiektowo.

Wartość zwracaną przez funkcję dyspozycyjną nazywamy wartością dyspozycyjną (ang. dispatch value). Będzie ona używana w celu wyboru konkretnej metody z zestawu metod o takich samych nazwach i argumentowościach, lecz różniących się przypisanymi im wartościami dyspozycyjnymi, umieszczanymi w definicjach (przed wektorami parametrycznymi).

Korzystanie z multimetod polega na wywoływaniu ich w taki sam sposób, w jaki czyni się to w odniesieniu do funkcji. Automatycznie wywołana zostanie funkcja dyspozycyjna, która na podstawie podanych argumentów obliczy wartość dyspozycyjną – do niej z kolei zostanie dopasowana metoda, którą zdefiniowano wcześniej i oznaczono taką samą wartością lub nadtypem (w przypadku typu jako wartości dyspozycyjnej). Metoda zostanie wywołana z argumentami takimi samymi, jak przekazane przy wywoływaniu multimetody.

.--------------.          .------------------------.     ,-------------------.
| wywołanie    |          | automatyczne wywołanie | --> | (fn [x]           |
| multimetody  |          | funkcji dyspozycyjnej  |     |   (case (…)       |
|              | ------>  |                        |     |     … :liczba     |
| (zlicz [:a]) |          | rezultat: :wektorek    | <-- |     … :wektorek)) |
`--------------'          `------------------------'     `-------------------'
                                      v
                          .------------------------.
                          | dopasowanie metody     |
                          | do wartości :wektorek  |
                          `------------------------'
                                      v
.----------------------.  .------------------------.  .----------------------.
| (zlicz :default [x]) |  | (zlicz :wektorek [x])  |  | (zlicz :liczba [x])  |
`----------------------'  `------------------------'  `----------------------'
                                      v
                          .-----------------------.
                          |   wywołanie metody    |
                          `-----------------------'

Dopasowywanie będzie polegało na porównywaniu wartości dyspozycyjnej wyliczonej przez funkcję dyspozycyjną z wartościami przypisanymi do metod. Porównywanie nie będzie odbywało się z użyciem operatora =, ale z wykorzystaniem funkcji isa?, która działa w następujący sposób:

  • sprawdza, czy wartość pierwszego argumentu jest równa wartości drugiego;
  • jeżeli pierwszy argument jest typem obiektowym, sprawdza czy jest podtypem drugiego argumentu (uwzględniając klasy nadrzędne i wszystkie interfejsy);
  • jeżeli pierwszy argument jest słowem kluczowym z dookreśloną przestrzenią nazw, traktuje go jak hierarchiczny typ doraźny i dokonuje sprawdzenia relacji, jak dla typów obiektowych;
  • jeżeli argumenty są wektorami, porównuje elementy o tych samych pozycjach, stosując wyżej wymienione sposoby.

Gdy wartością któregokolwiek sprawdzenia jest true, funkcja isa? kończy pracę i zwraca ją. W ten sposób realizowane jest dopasowywanie wartości dyspozycyjnych.

Definiowanie metod, defmethod

Definiowanie metod, które będą wywoływane na podstawie dopasowania wartości dyspozycyjnej, możliwe jest z użyciem makra defmethod.

Użycie:

  • (defmethod nazwa wartość-dyspozycyjna & argument…).

Pierwszym obowiązkowym argumentem powinna być wyrażona niezacytowanym symbolem nazwa (taka sama, jak nazwa zdefiniowanej multimetody), a drugim wartość dyspozycyjna, którą oznaczony będzie definiowany wariant operacji. Pozostałe argumenty są opcjonalne i zostaną użyte jako argumenty tworzonej metody.

Rezultatem pracy makra jest zdefiniowanie metody przeznaczonej do dyspozycyjnego wywoływania w zależności od dopasowania do podanej wartości dyspozycyjnej. Wartością zwracaną jest obiekt typu clojure.lang.MultiFn.

Przykłady użycia makra defmethod
1
2
3
4
5
6
7
8
(defmethod punktacja [::użytkownik ::komnata] [u k]
  (u (:p-users (k @rooms))))

(defmethod punktacja [::drużyna ::komnata] [d k]
  (d (:p-teams (k @rooms))))

(defmethod zlicz java.lang.String [s]
  (count s))

Definiowanie multimetod, defmulti

Multimetody można definiować z użyciem makra defmulti.

Użycie:

  • (defmulti nazwa łańcuch-dok? metadane? dyspozytor & opcje).

Pierwszym, obowiązkowym argumentem powinna być nazwa multimetody. Po niej można umieścić opcjonalny łańcuch dokumentujący wyrażony literałem zawierającym z obu stron znaki cudzysłowu, a także opcjonalne mapowe S-wyrażenie zawierające metadane. Kolejnym obowiązkowym argumentem jest funkcja dyspozycyjna wyrażona niezacytowanym symbolem lub inną konstrukcją, która reprezentuje funkcyjny obiekt. Reszta argumentów to opcje, które powinny być wyrażone mapą, której kluczami są słowa kluczowe:

  • :default – określa domyślną wartość dyspozycyjną (równa :default, gdy nie podano);
  • :hierarchy – określa wyrażoną typem referencyjnym (np. zmienną globalną) hierarchię używaną do dopasowywania typów doraźnych (domyślnie używana jest hierarchia globalna).

Przekazywana funkcja dyspozycyjna powinna przyjmować tyle argumentów, co każda z metod implementujących różne warianty tej samej operacji, a zwracać wartość, która będzie dopasowywana do wartości dyspozycyjnych umieszczonych sygnaturach tych metod.

W efekcie wywołania makra zdefiniowana zostanie publiczna multimetoda, czyli polimorficzna funkcja wywołująca konkretne implementacje obsługiwanej operacji.

Przykład użycia makra defmulti
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
;; definiowanie multimetody
;;
(defmulti zlicz
  "Zlicza elementy policzalnych struktur danych."
  (fn                      ; wieloczłonowa funkcja dyspozycyjna:
    ([] :default)          ; · wariant dla braku argumentów
    ([a] (type a))         ; · wariant dla jednego argumentu
    ([a & args] :multi)))  ; · wariant dla wielu argumentów

;; definiowanie metody domyślnej dla zerowej liczby argumentów
;;
(defmethod zlicz :default [] 0)

;; definiowanie metody dla więcej niż jednego argumentu
;;
(defmethod zlicz :multi [& args] (apply + (map zlicz args)))

;; definiowanie metod dla jednego argumentu
;;
(defmethod zlicz Number               [a] a)
(defmethod zlicz java.lang.Character  [a] 1)
(defmethod zlicz java.util.Collection [a] (count a))
(defmethod zlicz java.lang.String     [a] (count a))

;; wywoływanie multimetody
;;
(zlicz)                          ; => 0
(zlicz 2)                        ; => 2
(zlicz 2 2 \a)                   ; => 5
(zlicz [:a :b :c [:a]] 7 "abc")  ; => 14

Ujednoznacznianie wyboru metod, prefer-method

Może zdarzyć się tak, że w definicji metody zamiast pojedynczej wartości dyspozycyjnej umieszczony będzie zestaw wartości wyrażony np. wektorem, ponieważ funkcja dyspozycyjna zwraca wektor.

Jeżeli do innej metody również zostanie przypisana taka sama wartość dyspozycyjna (jako element wektora lub pojedynczo), to gdy funkcja dyspozycyjna zwróci właśnie ją na pozycji pasującej do obu wywołań, będziemy mieli do czynienia z konfliktem wynikającym z niejednoznacznego dopasowania.

Przykład niejednoznacznego dopasowania wartości dyspozycyjnej
1
[TBW]

Obsługa takich przypadków możliwa jest z użyciem funkcji prefer-method.

[TBW]

Grupowanie wartości dyspozycyjnych

Jeżeli uważnie przyjrzymy się przykładowi użycia makra defmulti podanemu wcześniej, znajdziemy w nim powtórzoną definicję metody zlicz dla różnych wartości dyspozycyjnych:

1
2
(defmethod zlicz java.util.Collection [a] (count a))
(defmethod zlicz java.lang.String     [a] (count a))

Zastosowane podejście sprawia, że w przyszłości bardzo prawdopodobne stanie się, iż znowu dojdzie do powielenia kodu, gdy zechcemy dodać obsługę kolejnych typów danych, których wartości można przekazać do wywołania count.

Problem ten można by rozwiązać stosując odpowiednie warunki w funkcji dyspozycyjnej, jednak wtedy zawierałaby ona statyczne dane (np. mapę) wmieszane w kod jej ciała, co nie byłoby ani zbyt zwięzłe, ani elastyczne – dodawanie nowych przypadków obsługi wiązałoby się z aktualizowaniem struktury zawartej w funkcji, czyli z modyfikowaniem tej ostatniej.

Moglibyśmy zamiast mapy skorzystać z odniesienia do jakiegoś globalnego typu referencyjnego, np. zmiennej globalnej, jednak wtedy też nie byłoby to zbyt eleganckie, bo rozwijając kod musielibyśmy pamiętać, że gdzieś w funkcji przekazywanej do wywołania defmulti powinniśmy szukać danych.

Możemy jednak skorzystać z doraźnego systemu typów, aby z pomocą ustalonej hierarchii zgrupować typy obiektowe, czyniąc je potomkami jednego, znacznikowego nadtypu. Jest to możliwe, ponieważ:

  • makro defmulti pozwala przekazać własną hierarchię;
  • doraźny system typów pozwala tworzyć nadtypy względem typów obiektowych;
  • podczas rozdysponowywania wywołań wykorzystywane są relacje między typami.

Spójrzmy, jak możemy to zaimplementować:

Przykład zastosowana typów doraźnych użycia makra defmulti
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
;; tworzenie hierarchii
;;
(def ^:private ^:dynamic *zlicz-h*
  (-> (make-hierarchy)
      (derive java.lang.Character  ::pojedyncze)
      (derive java.lang.Number     ::identyczne)
      (derive java.lang.String     ::policzalne)
      (derive java.util.Collection ::policzalne)))

;; definiowanie multimetody
;;
(defmulti zlicz
  (fn
    ([] ::niema-args)
    ([a] (type a))
    ([a & args] ::wiele-args))
  :default ::niema-args
  :hierarchy (var *zlicz-h*))

;; definiowanie metod
;;
(defmethod zlicz nil          [a] 0)
(defmethod zlicz ::niema-args [ ] 0)
(defmethod zlicz ::ignorowane [_] 0)
(defmethod zlicz ::pojedyncze [a] 1)
(defmethod zlicz ::identyczne [a] a)
(defmethod zlicz ::policzalne [a] (count a))
(defmethod zlicz ::wiele-args [& args] (apply + (map zlicz args)))

;; wywoływanie multimetody
;;
(zlicz)               ; => 0
(zlicz nil)           ; => 0
(zlicz 2 2 "aa" [1])  ; => 7
(zlicz (seq "aa"))    ; => 2

;; dodawanie obsługi nowych typów
;; w izolowanym wątku dynamicznego zasięgu
;;
(binding
    [*zlicz-h* (-> *zlicz-h*
                   (derive clojure.lang.StringSeq      ::policzalne)
                   (derive clojure.lang.IPersistentMap ::policzalne))]
  (zlicz {:a 1, :b 2}))
; => 2

;; dodawanie obsługi nowych typów
;; we wszystkich wątkach zasięgu nieograniczonego
;; (aktualizacja powiązania głównego zmiennej specjalnej)
;;
(alter-var-root
 (var *zlicz-h*)
 #(-> %1
      (derive clojure.lang.StringSeq      ::policzalne)
      (derive clojure.lang.IPersistentMap ::policzalne)
      (derive clojure.lang.Keyword        ::pojedyncze)))

(zlicz {:a 1, :b 2})  ; => 2
(zlicz :a :b)         ; => 2

Protokoły

Protokół (ang. protocol) to pojęcie wywodzące się z obiektowego paradygmatu programowania. W ogólnym rozumieniu oznacza ono zbiór zasad określający sposób komunikacji między instancjami różnych klas (obiektami różnych typów). Wymiana informacji polega na wywoływaniu przypisanych do obiektów metod, których nazwy i argumentowości zostały wcześniej określone.

Możemy powiedzieć, że dzięki protokołom możliwe jest wprowadzanie pewnych kontraktów, które określają, jakie operacje będzie można wykonywać na obiektach nowo tworzonych typów danych. Mówimy wtedy, że dany typ implementuje konkretny protokół (lub kilka protokołów). Oczywiście kontrakty te nie zawierają szczegółów implementacyjnych (np. konkretnych funkcji), lecz tylko ich zapowiedzi. Należy je więc definiować podczas lub przed utworzeniem typów, które oznaczono jako implementujące dany protokół lub protokoły.

Protokoły są mechanizmem polimorfizmu doraźnego i pozwalają deklarować możliwe do wykonania operacje na danych, ale bez wskazywania konkretnych typów tych ostatnich. Dopiero późniejsze definicje typów (np. klasy w Javie, czy rekordy bądź definicje typów własnych w Clojure) mogą włączać protokoły do użytku i definiować określone nimi funkcje.

Zorientowane obiektowo języki programowania wyposażone są często w konstrukcje pozwalające tworzyć protokoły, aby potem implementować zadeklarowane nimi metody w poszczególnych klasach. W Javie odpowiednikiem protokołów są tzw. interfejsy (ang. interfaces) i w ten właśnie sposób są reprezentowane protokoły w Clojure, gdy platformą gospodarza jest JVM.

W praktyce protokół to zbiór deklaracji powiązanych logicznie metod lub funkcji, który potem staje się wzorcem dla tworzenia nowych typów danych lub rozszerzania istniejących. Jeżeli jakiś typ danych implementuje protokół, to znaczy, że na wartościach tego typu możemy dokonywać określonych protokołem operacji.

Protokoły przypominają stanowiska w miejscu pracy. Można przypisać je do różnych pracowników, a z każdym związany jest jakiś zestaw obowiązków. Znając stanowisko jesteśmy więc w stanie dowiedzieć się, czy dany pracownik jest w stanie pomóc nam w rozwiązaniu konkretnego problemu. Doświadczeni pracownicy mogą mieć kompetencje w wielu dziedzinach i efektywnie piastować więcej, niż jedno stanowisko (gdy np. ich kolega wybierze się na urlop). Analogicznie: pojedynczy typ danych może implementować wiele protokołów, z których każdy mówi o zdolności do przeprowadzania na tym typie pewnych zestawów operacji.

Po co wprowadzać abstrakcyjne byty nazywane protokołami i deklarować coś, co i tak zostanie zrealizowane? Istnieje kilka powodów – dzięki protokołom:

  • możemy określić, jak korzystać z danych konkretnych typów w pewnych okolicznościach, tzn. jakie operacje można na nich wykonywać;

  • możemy wymóc definiowanie pewnych zestawów operacji dla danych istniejących lub nowo tworzonych typów;

  • możemy rozpoznawać czy dane określonych typów implementują protokoły i różnie traktować je w programie, np. badać, czy typ obsługiwanej przez nasz program wartości implementuje interfejs pozwalający zapisywać lub odczytywać metadane.

Na przykład protokół Policzalne może zawierać zapowiedź abstrakcyjnej operacji zlicz, której zadaniem jest obliczanie liczby elementów. Inaczej będziemy zliczali litery łańcuchów znakowych, a inaczej elementy tablic bądź wektorów, jednak dane wymienionych typów mogą być obsłużone w podobny sposób pod względem celu. Protokół pozwala wskazać, że tak jest. Jeżeli jakiś typ danych implementuje protokół Policzalne, to mamy pewność, że można wywołać metodę zlicz na jego obiekcie (w językach zorientowanych obiektowo) lub wywołać funkcję zlicz, przekazując jej wartość tego typu (w językach funkcyjnych).

W Clojure protokół to nazwany zestaw metod o określonych sygnaturach i nazwach, które mogą być potem dopasowywane do różnych obiektowych typów danych systemu gospodarza. Tworzenie protokołów odbywa się w fazie uruchamiania programu – jest to więc mechanizm polimorfizmu dynamicznego.

Wraz z rekordamidefinicjami typów protokoły stanowią jedno z rozwiązań problemu wyrazu. Można je w tym kontekście nazwać przypadkami użycia – zawierają deklaracje operacji, które powinny być zdefiniowane w wariantach przeznaczonych do obsługi różnych typów danych.

Na przykład wcześniej wspomniana funkcja zlicz (zadeklarowana w hipotetycznym protokole Policzalne) zostanie inaczej zaimplementowana dla łańcuchów znakowych (typ java.lang.String), a inaczej dla wektorów (typ clojure.lang.PersistentVector). W przypadku tych pierwszych będą zliczane znaki, a w przypadku drugich elementy, chociaż dla obu wywołamy tę samą polimorficzną funkcję.

Powyżej widać istotną różnicę w stosunku do języków bazujących na obiektowym paradygmacie programowania. Nowe operacje nie są tu metodami, które wywołujemy na obiektach, lecz niezależnymi, publicznymi funkcjami. Decyzja o wybraniu konkretnego wariantu danej funkcji (przypisanego do danego typu danych) jest podejmowana w czasie wywoływania wersji polimorficznej, a podstawą tej decyzji jest typ pierwszego przekazywanego argumentu.

Korzystanie z protokołów polega na ich tworzeniu, a następnie implementowaniu zadeklarowanych w nim metod w odniesieniu do nowych lub istniejących typów danych. W przypadku nowych typów wykorzystywanymi konstrukcjami będą defrecord lub deftype, natomiast w przypadku istniejących (w tym wbudowanych typów obiektowych) extend, extend-typeextend-protocol.

Definiowanie protokołów, defprotocol

Z użyciem makra defprotocol możemy tworzyć nowe protokoły, czyli nazwane zestawy operacji wykorzystywanych podczas definiowania i rozszerzania obiektowych typów danych.

Protokoły będą stworzone w oparciu o mechanizm platformy gospodarza, np. w przypadku JVM jako interfejsy Javy.

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 (nie obsługiwane) i deklaracje polimorficznych funkcji wyrażone sygnaturami zbudowanymi na bazie listowych S-wyrażeń:

  • (nazwa [this argument…] łańcuch-dok?).

Naczelnym elementem listy powinna być nazwa polimorficznej funkcji, a kolejnym wektorowe S-wyrażenie grupujące przyjmowane przez nią argumenty. Pierwszym z nich będzie zawsze obiekt instancji bieżącej (odpowiednik this z Javy). Podczas wywoływania funkcji (zdefiniowanych potem na bazie protokołu dla danego typu danych) będzie on przekazywany automatycznie i nie należy podawać go wprost.

W odniesieniu do argumentów i wartości zwracanych przez metody możliwe jest zastosowanie sugerowania typów.

Każda deklarowana funkcja będzie identyfikowana z użyciem podanej nazwy z dookreśloną przestrzenią nazw, która będzie taka, jak bieżąca przestrzeń ustawiona w momencie wywoływania makra defprotocol.

Efektem pracy makra będzie utworzenie:

  • interfejsu systemu gospodarza (np. user.Nazwa),
  • mapy protokolarnej reprezentowanej mapą (np. user/Nazwa),
  • polimorficznych funkcji (np. user/funkcja).

Nazwa interfejsu będzie taka sama jak nazwa obiektu reprezentującego protokół, a umieszczony on zostanie w pakiecie o nazwie takiej, jak bieżąca przestrzeń nazw.

Poza obiektem interfejsu umieszczonym w pakiecie stworzona zostanie też zmienna globalna internalizowana w bieżącej przestrzeni nazw. Jej powiązanie główne będzie wskazywało na mapę (wartość typu clojure.lang.PersistentArrayMap) opisującą protokół.

Polimorficzne funkcje będą również identyfikowane zmiennymi globalnymi zinternalizowanymi w bieżącej przestrzeni nazw. Gdy dojdzie do wywołania którejś z nich, to właściwy wariant operacji (będący funkcją realizującą pewien algorytm dla danych konkretnego rodzaju) zostanie wybrany na podstawie obiektowego typu danych pierwszego przekazywanego im argumentu.

Właściwe definicje abstrakcyjnych funkcji zadeklarowanych w protokołach możemy kojarzyć z tworzonymi typami danych z użyciem makr deftype, defrecord czy reify. Jesteśmy też w stanie rozszerzać istniejące typy o protokoły (i dodawać przeznaczone dla nich implementacje operacji), korzystając z extend, albo z łatwiejszych w użyciu odpowiedników: extend-protocolextend-type.

Przykład użycia makra defprotocol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
;; definicja protokołu
;;
(defprotocol Policzalne
  "Zawiera operacje dla policzalnych typów danych."

  (zlicz
    [this] [this more]
    "Zlicza elementy podanej struktury.")

  (policzalne?
    [this]
    "Dla policzalnych typów danych zwraca logiczną prawdę."))

;; sprawdzanie typów utworzonych obiektów
;;
(type user/Policzalne)        ; => clojure.lang.PersistentArrayMap
(type user.Policzalne)        ; => java.lang.Class
(interface? user.Policzalne)  ; => true

;; wyświetlanie mapy opisującej protokół
;;
user/Policzalne
; => Policzalne
; => {:doc "Zawiera operacje dla policzalnych typów danych."
; =>  :method-builders {
; =>    #'user/policzalne? #object[user$eval106$fn__10621 0x424 "[email protected]"]
; =>    #'user/zlicz       #object[user$eval106$fn__10634 0x728 "[email protected]"]}
; =>  :method-map   {:policzalne? :policzalne? :zlicz :zlicz}
; =>  :on           user.Policzalne
; =>  :on-interface user.Policzalne
; =>  :sigs {
; =>    :policzalne? {
; =>      :arglists ([this])
; =>      :doc      "Dla policzalnych typów danych zwraca logiczną prawdę."
; =>      :name policzalne?}
; =>    :zlicz {
; =>      :arglists ([this] [this & args])
; =>      :doc "Zlicza elementy podanej struktury."
; =>      :name zlicz}}
; =>  :var #'user/Policzalne}

;; definiowanie rekordowego typu danych implementującego protokół Policzalne
;;
(defrecord Osoba [imię nazwisko wiek]
  Policzalne                           ; implementacja protokołu:
  (zlicz [this] (:wiek this))          ; · implementacja metody określonej protokołem
  (policzalne? [this] true))           ; · implementacja metody określonej protokołem

;; tworzenie rekordu (implementującego protokół Policzalne)
;;
(def Paweł (->Osoba "Paweł"  "Wilk" 99))

;; sprawdzanie działania funkcji polimorficznych
;;
(policzalne? Paweł)  ; => true
(zlicz Paweł)        ; => 99

Rozszerzanie typów o protokoły, extend

[TBW]

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

Komentarze