stats

Poczytaj mi Clojure, cz. 21

Polimorfizm

Grafika

Polimorfizm to zbiór mechanizmów, dzięki którym ta sama konstrukcja języka programowania może być używana do przetwarzania danych różnych rodzajów. Dzięki niemu możemy korzystać z 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ść, jest zbiorem 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 do pewnych klas operacji, które można przeprowadzać na danych różnych rodzajów.

Operowanie na różnorodnych danych z użyciem tych samych sposobów (np. funkcji) pozwala tworzyć 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) bądź 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 również od innych cech charakterystycznych. Owe cechy mogą być specyficzne dla języka bądź wskazywane przez programistę. Nie muszą być koniecznie 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ę zależnie 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 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żeli celem lektury tego rozdziału jest znalezienie praktycznego sposobu na rozwiązanie jednego z poniższych problemów, można od razu przenieść się do odpowiednich sekcji:

  • Gdy tworzona 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ść), użyjemy koercji typu.

  • Gdy chcemy zmienić dane jednego typu w dane innego, możemy skorzystać z konwersji typu.

  • Gdy szukamy sposobu na ponowne definiowanie funkcji, która już istnieje, aby zastąpić ją nową wersją, pomocne będzie nadpisywanie funkcji.

  • Gdy chcemy utworzyć warianty tej samej funkcji, które będą różniły się liczbą przyjmowanych argumentów, przyda się przeciążanie argumentowości.

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

  • Gdy nasza 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, wybierzemy multimetody. One również mogą być rozszerzane o nowe warianty operacji.

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

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

Problem wyrazu

Niektóre mechanizmy polimorficzne związane z obsługą różnych typów danych 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 tych rodzajach 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 w taki sposób, że do typu możemy dodawać nowe przypadki oraz nowe funkcje operujące na tym typie, bez rekompilacji istniejącego kodu i z zachowaniem bezpieczeństwa statycznego typizowania (np. bez rzutowania).

[…]

To, czy język potrafi rozwiązać Problem Wyrazu, jest dobitnym wskaźnikiem jego ekspresyjności.

Czym są wspomniane przypadki? Ogólnie rzecz ujmując, możemy nazwać je przypadkami zastosowań, a bardziej technicznie 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 skojarzeń nowych operacji z danymi 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 wcześniej napisanego kodu i bez korzystania z rzutowania typów.

Gdy uda nam się rozwiązać problem wyrazu, będziemy mogli pisać na przykład takie programy, w których operacje wyrażane funkcjami można w przyszłości rozszerzać o obsługę rodzajów danych nieznanych w momencie ich tworzenia, w dodatku bez konieczności zmieniania wcześniej napisanych funkcji.

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 poradzimy sobie z tym na przykład przez domieszkowanie modułów (chociaż przez purystów może to uznane za zmienianie istniejącego kodu), a w obiektowych językach typizowanych statycznie przez użycie tzw. klas abstrakcyjnych czy mechanizmu szablonów. Jednak w tych drugich przypadkach będziemy musieli z góry określić, że dana klasa powinna w przyszłości mieć zdolność do bycia rozszerzaną.

W dalszej części pokazany będzie nieco bardziej elegancki sposób radzenia sobie z tą kwestią w językach zorientowanych obiektowo, z użyciem tzw. podwójnego rozdzielania metod.

Spójrzmy, jak wyglądałaby podobna tabela dla języka zorientowanego funkcyjnie, czyli dla Clojure, gdybyśmy na moment zapomnieli o omawianych dalej polimorficznych mechanizmach:

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 przykładzie dla języka 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 implementowanie tzw. algebraicznego typu danych (ang. algebraic data type, skr. ADT). Jest to typ złożony (ang. composite type), który pozwala grupować elementy innych (dowolnych) typów. Przykładami mogą być 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, ani tym bardziej, że rozwiążemy problem wyrazu.

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 obiektowo zorientowanej platformie gospodarza?

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

  1. 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, nieznanych w momencie tworzenia obsługi operacji.

  2. Brak wymogu powielania istniejącego kodu czy jego zmiany.

  3. 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 w jakiś sposób 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(conj  [1 2] 3)        ; => [1 2 3]
2(conj '(1 2) 3)        ; => (1 2 3)
3(conj  {:a 2} {:b 4})  ; => {:a 2 :b 4}
(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 jest 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 funkcji 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 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ą:

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. Wyobraźmy sobie klasę w języku Ruby i klasę pochodną utworzoną na jej bazie. Pierwsza będzie reprezentowała wartość numeryczną podanej liczby, a druga jej formę tekstową. W przypadku typów numerycznych jest to niecodzienny zabieg, lecz używamy go tu, aby ukazać pewne mechanizmy rozdzielania wywołań.

Dziedziczenie w Rubym
 1class Liczba
 2  attr_reader :value
 3  def initialize(v) @value = v.to_f end
 4  def dodaj_liczbe(other) self.class.new(@value + other.value) end
 5end
 6
 7class Tekstowa < Liczba
 8  def initialize(txt) @value = txt.to_s end
 9  def dodaj_liczbe(other)
10    self.class.new(self.class.superclass.new(@value).value + other.value)
11  end
12end
13
14x = Liczba.new(12)
15y = Tekstowa.new("100")
16
17y.dodaj_liczbe(x).value
18# => 112
class Liczba attr_reader :value def initialize(v) @value = v.to_f end def dodaj_liczbe(other) self.class.new(@value + other.value) end end class Tekstowa &lt; Liczba def initialize(txt) @value = txt.to_s end def dodaj_liczbe(other) self.class.new(self.class.superclass.new(@value).value + other.value) end end x = Liczba.new(12) y = Tekstowa.new(&#34;100&#34;) y.dodaj_liczbe(x).value # =&gt; 112

Implementowana w klasie Tekstowa metoda dodaj_liczbe() pozwala nam podać drugi obiekt, który powinien mieć pole value, aby dodawać jego wartość do wartości liczbowej pochodzącej z bieżącego obiektu i produkować nową instancję zawierającą sumę.

Możemy jednak zauważyć, że nie rozwiązuje to problemu wyrazu, ponieważ w Rubym wybór implementacji wywoływanej metody zależy tylko od klasy, dla której metoda o podanej nazwie jest wywoływana, a więc mamy do czynienia z tzw. pojedynczą dyspozycją (ang. single dispatch). Łatwo dodamy nowe podtypy, jednak będą one użyteczne w kontekście napisanego wcześniej kodu pod warunkiem, że argumentem metody dodaj_liczbe() również będzie obiekt tej samej klasy bądź jej przodka.

Bez problemu wprowadzimy obsługę tekstowej reprezentacji liczby i stworzymy funkcję sumującą, ale będzie ona dobrze działać wyłącznie dla obiektów klasy Tekstowa i instancji jej potencjalnych potomków. Próba wywołania metody dodaj_liczbe() na instancji Liczba z argumentem klasy Tekstowa zakończy się niepowodzeniem.

Żeby poradzić sobie z powyższym możemy zbudować mechanizm podwójnej dyspozycji (ang. double dispatch), w którym wybór implementacji metody sumującej zależy nie tylko od klasy, ale również od typu przekazywanego argumentu. Będziemy potrzebowali dwóch metod: sumującej i przekształcającej argument do wartości, którą metoda sumująca może zaakceptować.

Podwójna dyspozycja w Rubym
 1class Liczba
 2  attr_reader :value
 3  def initialize(v) @value = v.to_f end
 4
 5  def dodaj(other)
 6    return self.class.new(@value + other.value) if other.is_a? Liczba
 7    a, b = other.coerce(self)
 8    a.dodaj(b)
 9  end
10end
11
12class Tekstowa < Liczba
13  def initialize(txt) super(txt.to_f); @txt = txt.to_s end
14  def to_s            = @txt
15
16  def coerce(other)
17    case other
18    when Liczba   then [self.class.new(other.value.to_s),  self]
19    when Numeric  then [self.class.new(other.to_s),       self]
20    else raise TypeError, "Tekstowa nie współgra z #{other.class}"
21    end
22  end
23
24  def dodaj(other) self.class.new(super(other).value.to_s) end
25end
26
27x = Liczba.new(12)
28y = Tekstowa.new("100")
29
30x.dodaj(y).value  # => 112.0
31y.dodaj(x).value  # => 112.0
class Liczba attr_reader :value def initialize(v) @value = v.to_f end def dodaj(other) return self.class.new(@value + other.value) if other.is_a? Liczba a, b = other.coerce(self) a.dodaj(b) end end class Tekstowa &lt; Liczba def initialize(txt) super(txt.to_f); @txt = txt.to_s end def to_s = @txt def coerce(other) case other when Liczba then [self.class.new(other.value.to_s), self] when Numeric then [self.class.new(other.to_s), self] else raise TypeError, &#34;Tekstowa nie współgra z #{other.class}&#34; end end def dodaj(other) self.class.new(super(other).value.to_s) end end x = Liczba.new(12) y = Tekstowa.new(&#34;100&#34;) x.dodaj(y).value # =&gt; 112.0 y.dodaj(x).value # =&gt; 112.0

W powyższym przykładzie rozwiązujemy problem wyrazu tworząc mechanizm dynamicznie parametryzowanej koercji, podobnie jak jest to realizowane we wbudowanych klasach numerycznych Ruby’ego.

Poza metodą dodaj() pojawia się metoda coerce(), której zadaniem jest konwersja wartości argumentu do postaci kompatybilnej z dodaj(). Zwraca ona dwuelementową tablicę, której pierwszy element to bieżący obiekt, a drugim jest nowopowstały obiekt tego samego typu co bieżący, zainicjowany wartością przekazaną jako argument. W ten sposób w klasach pochodnych rozdzielamy wywołania zarówno po klasie, dla której metoda jest wzywana, jak i po klasie przekazywanego do metody argumentu, dzięki wywołaniu coerce() obecnemu w bazowej implementacji metody dodaj().

Upraszczając: klasa Liczba nic „nie wie” o podklasie Tekstowa, ale ta ostatnia może wpływać na to, jaki kształt będzie miał argument przekazywany do metody dodaj() klasy Liczba, żeby dało się go sumować z wartością liczbową z pola value.

W Clojure najprostszym przykładem polimorfizmu inkluzyjnego jest koercja typu. Możemy też zaliczyć do niego niektóre zastosowania protokołów.

Polimorfizm parametryczny

W języku Clojure mamy również do czynienia z polimorfizmem parametrycznym (ang. parametric polymorphism), w którym możemy tworzyć funkcje obsługujące parametry dowolnych typów.

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. Często będziemy mieć wtedy funkcję, która pobiera argument dowolnego typu bądź kolekcję takich argumentów, a dodatkowo inną funkcję (tzw. operator) używaną do obsługi właśnie takiego typu danych.

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 rozdzielania wywołań metod) czy obsługę argumentów, które nie są deklarowanych typów, lecz typów pozostających z nimi w relacji.

Ukazany wcześniej przykład dwojakiej dyspozycji w Rubym, wykorzystującej polimorfizm podtypowy, jest wyraźnym przykładem polimorfizmu typów danych, jednak operacje rozdzielania bazujące na typach są związane nie tylko z obiektowo zorientowanym paradygmatem programowania.

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 jak 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 i 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 obiektu wyrażającego liczbę,
  • (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 zmiennoprzecinkowych podwójnej precyzji.
Przykłady konwersji typów
1(str    123)  ; => "123"
2(int   123M)  ; => 123
3(long  123M)  ; => 123
4(str  false)  ; => "false"
5(int     \a)  ; => 97
6(char    97)  ; => \a
7
8(Integer/parseInt "1234")  ; => 1234
9(String/valueOf     1234)  ; => "1234"
(str 123) ; =&gt; &#34;123&#34; (int 123M) ; =&gt; 123 (long 123M) ; =&gt; 123 (str false) ; =&gt; &#34;false&#34; (int \a) ; =&gt; 97 (char 97) ; =&gt; \a (Integer/parseInt &#34;1234&#34;) ; =&gt; 1234 (String/valueOf 1234) ; =&gt; &#34;1234&#34;

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(java.util.ArrayList. [1 2 3])  ; => [1 2 3]
2(java.util.HashMap. {"a" 1})    ; => {"b" 2, "a" 1}
3(into []  '(1 2 3))             ; => [1 2 3]
4(into #{} '(1 2 3))             ; => #{1 2 3}
5(vec      '(1 2 3))             ; => [1 2 3]
6(set      '(1 2 3))             ; => #{1 2 3}
(java.util.ArrayList. [1 2 3]) ; =&gt; [1 2 3] (java.util.HashMap. {&#34;a&#34; 1}) ; =&gt; {&#34;b&#34; 2, &#34;a&#34; 1} (into [] &#39;(1 2 3)) ; =&gt; [1 2 3] (into #{} &#39;(1 2 3)) ; =&gt; #{1 2 3} (vec &#39;(1 2 3)) ; =&gt; [1 2 3] (set &#39;(1 2 3)) ; =&gt; #{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
1String s = "napis";     /* obiekt typu String                        */
2Object o = value;       /* automatyczne rzutowanie do nadtypu Object */
3String x = (String) o;  /* jawne rzutowanie do podtypu String        */
String s = &#34;napis&#34;; /* 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
1char *c = "abc";
2void *p = c;
3char *x = (char *) p;
char *c = &#34;abc&#34;; 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;; rzutowanie dokonywane wewnętrznie przez funkcję int:
 2(int "1234")
 3; >> java.lang.ClassCastException:
 4; >> java.lang.String cannot be cast to java.lang.Character
 5
 6;; konwersja do java.lang.String (brak rzutowania):
 7(str 1234)
 8; => "1234"
 9
10;; konwersja wykorzystująca rzutowanie do java.lang.Integer:
11(int 1234M)
12; => 1234
;; rzutowanie dokonywane wewnętrznie przez funkcję int: (int &#34;1234&#34;) ; &gt;&gt; java.lang.ClassCastException: ; &gt;&gt; java.lang.String cannot be cast to java.lang.Character ;; konwersja do java.lang.String (brak rzutowania): (str 1234) ; =&gt; &#34;1234&#34; ;; konwersja wykorzystująca rzutowanie do java.lang.Integer: (int 1234M) ; =&gt; 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, 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(+ (double 2) 5)
2(let  [a  (long 3)] a)
3(fn   [a] (int a))
4(loop [a  (int 0)] (when (< a 10000) (recur (inc a))))
(+ (double 2) 5) (let [a (long 3)] a) (fn [a] (int a)) (loop [a (int 0)] (when (&lt; 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(        str 123)  ; => "123"
2(.invoke str 123)  ; => "123"
( str 123) ; =&gt; &#34;123&#34; (.invoke str 123) ; =&gt; &#34;123&#34;

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(defn fun [a] (type a))
2(fun "test")
3; => java.lang.String
(defn fun [a] (type a)) (fun &#34;test&#34;) ; =&gt; 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(set! *warn-on-reflection* true)
2
3(defn fun [a] (.length a))
4; >> Reflection warning, /tmp/form-init4959455601282597355.clj:1:15
5; >> - reference to field length can't be resolved.
6
7(fun "test")
8; => 4
(set! *warn-on-reflection* true) (defn fun [a] (.length a)) ; &gt;&gt; Reflection warning, /tmp/form-init4959455601282597355.clj:1:15 ; &gt;&gt; - reference to field length can&#39;t be resolved. (fun &#34;test&#34;) ; =&gt; 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 z ostrzeżeniem. Sprawdzanie zostanie więc odłożone do czasu uruchamiania programu, co 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(set! *warn-on-reflection* true)
 2
 3;; bez koercji
 4(defn sumuj [min max]
 5  (loop [n min acc 0]
 6    (if (> n max) acc (recur (inc n) (+ acc n)))))
 7
 8;; koercja bazująca na obiekcie funkcyjnym
 9(defn sumuj-fun [f min max]
10  (let [min (f min) max (f max)]
11    (loop [n min acc (f 0)]
12      (if (> n max) acc (recur (inc n) (+ acc n))))))
13
14;; koercja lokalna do określonego typu
15(defn sumuj-long [min max]
16  (let [min (long min) max (long max)]
17    (loop [n min acc (long 0)]
18      (if (> n max) acc (recur (inc n) (+ acc n))))))
19
20;; koercja bazująca na sugerowaniu typów
21(defn sumuj-hinting ^long [^long min ^long max]
22  (loop [n min acc 0]
23    (if (> n max) acc (recur (inc n) (+ acc n)))))
24
25;; koercja bazująca na sugerowaniu typów
26;; jako makro (możliwość podania typu)
27(defmacro sumuj-steroids [t min max]
28  (let [ttag  {:tag t}
29        fmin  (gensym 'min)
30        fmax  (gensym 'max)
31        fmina (with-meta fmin ttag)
32        fmaxa (with-meta fmax ttag)
33        fname (with-meta (gensym 'sum) ttag)]
34    `((fn ~fname [~fmina ~fmaxa]
35        (loop [n# ~fmin acc# 0]
36          (if (> n# ~fmax) acc# (recur (inc n#) (+ acc# n#)))))
37      ~min ~max)))
38
39(def n 10000000)
40
41(time (sumuj               0 n))  ; >> "Elapsed time: 2550.26655 msecs"
42(time (sumuj-fun long      0 n))  ; >> "Elapsed time: 2508.790539 msecs"
43(time (sumuj-long          0 n))  ; >> "Elapsed time: 884.280539 msecs"
44(time (sumuj-hinting       0 n))  ; >> "Elapsed time: 860.636767 msecs"
45(time (sumuj-steroids long 0 n))  ; >> "Elapsed time: 891.377236 msecs"
(set! *warn-on-reflection* true) ;; bez koercji (defn sumuj [min max] (loop [n min acc 0] (if (&gt; 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 (&gt; 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 (&gt; 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 (&gt; 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 &#39;min) fmax (gensym &#39;max) fmina (with-meta fmin ttag) fmaxa (with-meta fmax ttag) fname (with-meta (gensym &#39;sum) ttag)] `((fn ~fname [~fmina ~fmaxa] (loop [n# ~fmin acc# 0] (if (&gt; n# ~fmax) acc# (recur (inc n#) (+ acc# n#))))) ~min ~max))) (def n 10000000) (time (sumuj 0 n)) ; &gt;&gt; &#34;Elapsed time: 2550.26655 msecs&#34; (time (sumuj-fun long 0 n)) ; &gt;&gt; &#34;Elapsed time: 2508.790539 msecs&#34; (time (sumuj-long 0 n)) ; &gt;&gt; &#34;Elapsed time: 884.280539 msecs&#34; (time (sumuj-hinting 0 n)) ; &gt;&gt; &#34;Elapsed time: 860.636767 msecs&#34; (time (sumuj-steroids long 0 n)) ; &gt;&gt; &#34;Elapsed time: 891.377236 msecs&#34;

Widzimy, że wersja sumatora bez koercji z funkcji sumuj jest niemal trzykrotnie wolniejsza, 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, wtedy warto korzystać z powiązań leksykalnych (np. formy 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, czyli tam, gdzie rzeczywiście jest to potrzebne (reszta konstrukcji powinna mieć dostęp do oryginalnej wartości parametru).

  • Tam, gdzie to możliwe, zaleca się 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, jest mechanizmem 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 formy 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(set! *warn-on-reflection* true)
 2
 3;; zmienna globalna
 4(def ^String x "a")
 5
 6;; wektor parametryczny funkcji
 7(defn fun [^String a] a)
 8(fn       [^String a] a)
 9
10;; wartość zwracana funkcji
11(defn fun ^String [a] a)
12(fn       ^String [a] a)
13
14;; wektor powiązań
15(let [^String a "a"] a)
16
17;; wywołania funkcji
18(.equals ^String (str "a") ^String (str "a"))
19(.equals ^String (fun "a") ^String (fun "a"))
20
21;; porównanie wydajności wywołania metody na obiekcie
22;;
23(defn długość        [acc         x] (+' acc (.length x)))
24(defn długość-string [acc ^String x] (+' acc (.length x)))
25(def  łańcuchy (repeat 100000 "xxxxxxxxxxx"))
26
27(time (reduce długość 0 łańcuchy))
28; >> "Elapsed time: 1495.502125 msecs"
29; => 1100000
30
31(time (reduce długość-string 0 łańcuchy))
32; >> "Elapsed time: 34.815388 msecs"
33; => 1100000
(set! *warn-on-reflection* true) ;; zmienna globalna (def ^String x &#34;a&#34;) ;; 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ązań (let [^String a &#34;a&#34;] a) ;; wywołania funkcji (.equals ^String (str &#34;a&#34;) ^String (str &#34;a&#34;)) (.equals ^String (fun &#34;a&#34;) ^String (fun &#34;a&#34;)) ;; porównanie wydajności wywołania metody na obiekcie ;; (defn długość [acc x] (+&#39; acc (.length x))) (defn długość-string [acc ^String x] (+&#39; acc (.length x))) (def łańcuchy (repeat 100000 &#34;xxxxxxxxxxx&#34;)) (time (reduce długość 0 łańcuchy)) ; &gt;&gt; &#34;Elapsed time: 1495.502125 msecs&#34; ; =&gt; 1100000 (time (reduce długość-string 0 łańcuchy)) ; &gt;&gt; &#34;Elapsed time: 34.815388 msecs&#34; ; =&gt; 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, wtedy – 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 wartości typu podstawowego z obiektu realizowany jest wewnętrznie z użyciem rzutowania.

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

  • 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ń. Zamiast tego należy stosować rzutowanie. Próby podpowiadania typów spowodują zgłoszenia wyjątku java.lang.UnsupportedOperationException.

Przykłady sugerowania rozpakowanych typów podstawowych w Clojure
 1(set! *warn-on-reflection* true)
 2
 3;; zmienna globalna
 4(def ^long x 5)
 5
 6;; wektor parametryczny funkcji
 7(defn fun [^double a] a)
 8(fn       [^double a] a)
 9
10;; wartość zwracana funkcji
11(defn fun ^double [a] a)
12(fn       ^double [a] a)
13
14;; wywołania funkcji
15^double (identity 5)
16(= ^long   (+   2) ^long   (+   2))
17(+ ^double (fun 2) ^double (fun 1))
(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, 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;; definiowanie typu rekordowego
 2;;
 3(defrecord Osoba [imię nazwisko e-mail])
 4; => user.Osoba
 5
 6;; tworzenie rekordu typu Osoba przez wywołanie konstruktora
 7;;
 8(def Paweł (Osoba. "Paweł" "Wilk" "pw-na-gnu.org"))
 9
10;; tworzenie rekordu typu Osoba przez wywołanie funkcji ->
11;;
12(def Paweł (->Osoba "Paweł" "Wilk" "pw-na-gnu.org"))
13
14;; sprawdzanie typów
15;;
16(type Osoba)       ; => java.lang.Class
17(type Paweł)       ; => user.Osoba
18
19;; wywoływanie akcesorów pól
20;;
21(.imię     Paweł)  ; => "Paweł"
22(.nazwisko Paweł)  ; => "Wilk"
23
24;; traktowanie rekordu jak mapy
25;;
26(:imię     Paweł)  ; => "Paweł"
27(:nazwisko Paweł)  ; => "Wilk"
28
29(conj Paweł {:płeć :m})
30
31; => #user.Osoba{:e-mail   "pw-na-gnu.org"
32; =>             :imię     "Paweł"
33; =>             :nazwisko "Wilk"
34; =>             :płeć     :m}
;; definiowanie typu rekordowego ;; (defrecord Osoba [imię nazwisko e-mail]) ; =&gt; user.Osoba ;; tworzenie rekordu typu Osoba przez wywołanie konstruktora ;; (def Paweł (Osoba. &#34;Paweł&#34; &#34;Wilk&#34; &#34;pw-na-gnu.org&#34;)) ;; tworzenie rekordu typu Osoba przez wywołanie funkcji -&gt; ;; (def Paweł (-&gt;Osoba &#34;Paweł&#34; &#34;Wilk&#34; &#34;pw-na-gnu.org&#34;)) ;; sprawdzanie typów ;; (type Osoba) ; =&gt; java.lang.Class (type Paweł) ; =&gt; user.Osoba ;; wywoływanie akcesorów pól ;; (.imię Paweł) ; =&gt; &#34;Paweł&#34; (.nazwisko Paweł) ; =&gt; &#34;Wilk&#34; ;; traktowanie rekordu jak mapy ;; (:imię Paweł) ; =&gt; &#34;Paweł&#34; (:nazwisko Paweł) ; =&gt; &#34;Wilk&#34; (conj Paweł {:płeć :m}) ; =&gt; #user.Osoba{:e-mail &#34;pw-na-gnu.org&#34; ; =&gt; :imię &#34;Paweł&#34; ; =&gt; :nazwisko &#34;Wilk&#34; ; =&gt; :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(defrecord Osoba [imię nazwisko e-mail])
2
3(->Osoba "Paweł" "Wilk" "pw-na-gnu.org")
4
5; => #user.Osoba{:e-mail   "pw-na-gnu.org"
6; =>             :imię     "Paweł"
7; =>             :nazwisko "Wilk"}
(defrecord Osoba [imię nazwisko e-mail]) (-&gt;Osoba &#34;Paweł&#34; &#34;Wilk&#34; &#34;pw-na-gnu.org&#34;) ; =&gt; #user.Osoba{:e-mail &#34;pw-na-gnu.org&#34; ; =&gt; :imię &#34;Paweł&#34; ; =&gt; :nazwisko &#34;Wilk&#34;}

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(defrecord Osoba [imię nazwisko e-mail])
2
3(map->Osoba {:nazwisko "Wilk" :imię "Paweł" :płeć :m})
4
5; => #user.Osoba{:e-mail   nil
6; =>             :imię     "Paweł"
7; =>             :nazwisko "Wilk"
8; =>             :płeć     :m}
(defrecord Osoba [imię nazwisko e-mail]) (map-&gt;Osoba {:nazwisko &#34;Wilk&#34; :imię &#34;Paweł&#34; :płeć :m}) ; =&gt; #user.Osoba{:e-mail nil ; =&gt; :imię &#34;Paweł&#34; ; =&gt; :nazwisko &#34;Wilk&#34; ; =&gt; :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(defrecord Osoba [imię nazwisko e-mail])
2
3#user.Osoba{:imię "Paweł" :nazwisko "Wilk" :płeć :m}
4
5; => #user.Osoba{:e-mail   nil
6; =>             :imię     "Paweł"
7; =>             :nazwisko "Wilk"
8; =>             :płeć     :m}
(defrecord Osoba [imię nazwisko e-mail]) #user.Osoba{:imię &#34;Paweł&#34; :nazwisko &#34;Wilk&#34; :płeć :m} ; =&gt; #user.Osoba{:e-mail nil ; =&gt; :imię &#34;Paweł&#34; ; =&gt; :nazwisko &#34;Wilk&#34; ; =&gt; :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

W Clojure możemy tworzyć nowe typy obiektowe, czyli de facto nowe, nazwane klasy systemu gospodarza. Klasy Javy odpowiadające nowym typom tworzone są dynamicznie, a definicje zawierać mogą zadeklarowane przez programistę pola i (opcjonalnie) metody. Stwarzane w Clojure klasy mogą implementować protokoły oraz interfejsy Javy.

Aby wytworzyć nowy typ obiektowy, możemy skorzystać z makra deftype.

Użycie:

  • deftype nazwa pole… & opcja… & specyfikacja…;

gdzie:

  • nazwa jest symbolem nazywającym klasę,
  • pole jest symbolem nazywającym pole klasy,
  • opcja jest opcją,
  • specyfikacja jest specyfikacją.

Pole, opcja i specyfikacja mogą być podane wiele razy, natomiast opcja i specyfikacja mogą pojawić się wiele razy.

Dokładny opis użycia makra deftype można znaleźć w rozdziale poświęconym systemom typów.

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(defn fun
2  ([]         "brak argumentów")
3  ([a]        (str "jeden argument: " a))
4  ([a & args] (println "wiele argumentów:" a (apply print-str args))))
5
6(fun)        ; => "brak argumentów"
7(fun 123)    ; => "jeden argument: 123"
8(fun 1 2 3)  ; => "wiele argumentów: 1 2 3"
(defn fun ([] &#34;brak argumentów&#34;) ([a] (str &#34;jeden argument: &#34; a)) ([a &amp; args] (println &#34;wiele argumentów:&#34; a (apply print-str args)))) (fun) ; =&gt; &#34;brak argumentów&#34; (fun 123) ; =&gt; &#34;jeden argument: 123&#34; (fun 1 2 3) ; =&gt; &#34;wiele argumentów: 1 2 3&#34;

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(defn            funkcja-1  [] "pierwotna 1")
 2(defn ^:dynamic *funkcja-2* [] "pierwotna 2")
 3
 4(funkcja-1)    ; => "pierwotna 1"
 5(*funkcja-2*)  ; => "pierwotna 2"
 6
 7;; aktualizowanie funkcji globalnych
 8;;
 9(defn funkcja-1 [] "zaktualizowana 1")
10(funkcja-1)       ; => "zaktualizowana 1"
11
12(alter-var-root (var funkcja-1)
13                (constantly (fn [] "bezpiecznie zaktualizowana 1")))
14(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"
15
16;; przesłanianie leksykalne funkcji
17;;
18(letfn [(funkcja-1 [] "przesłonięta leksykalnie 1")]
19  (funkcja-1))    ; => "przesłonięta leksykalnie 1"
20(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"
21
22(let [funkcja-1 (fn [] "przesłonięta leksykalnie 1")]
23  (funkcja-1))    ; => "przesłonięta leksykalnie 1"
24(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"
25
26;; przesłanianie funkcji dynamicznych
27;;
28(binding [*funkcja-2* (fn [] "dynamicznie przesłonięta 2")]
29  (*funkcja-2*))  ; => "dynamicznie przesłonięta 2"
30(*funkcja-2*)     ; => "pierwotna 2"
31
32;; redefinicja funkcji
33;;
34(with-redefs [funkcja-1 (fn [] "redefiniowana 1")]
35  (funkcja-1))    ; => "redefiniowana 1"
36(funkcja-1)       ; => "bezpiecznie zaktualizowana 1"
(defn funkcja-1 [] &#34;pierwotna 1&#34;) (defn ^:dynamic *funkcja-2* [] &#34;pierwotna 2&#34;) (funkcja-1) ; =&gt; &#34;pierwotna 1&#34; (*funkcja-2*) ; =&gt; &#34;pierwotna 2&#34; ;; aktualizowanie funkcji globalnych ;; (defn funkcja-1 [] &#34;zaktualizowana 1&#34;) (funkcja-1) ; =&gt; &#34;zaktualizowana 1&#34; (alter-var-root (var funkcja-1) (constantly (fn [] &#34;bezpiecznie zaktualizowana 1&#34;))) (funkcja-1) ; =&gt; &#34;bezpiecznie zaktualizowana 1&#34; ;; przesłanianie leksykalne funkcji ;; (letfn [(funkcja-1 [] &#34;przesłonięta leksykalnie 1&#34;)] (funkcja-1)) ; =&gt; &#34;przesłonięta leksykalnie 1&#34; (funkcja-1) ; =&gt; &#34;bezpiecznie zaktualizowana 1&#34; (let [funkcja-1 (fn [] &#34;przesłonięta leksykalnie 1&#34;)] (funkcja-1)) ; =&gt; &#34;przesłonięta leksykalnie 1&#34; (funkcja-1) ; =&gt; &#34;bezpiecznie zaktualizowana 1&#34; ;; przesłanianie funkcji dynamicznych ;; (binding [*funkcja-2* (fn [] &#34;dynamicznie przesłonięta 2&#34;)] (*funkcja-2*)) ; =&gt; &#34;dynamicznie przesłonięta 2&#34; (*funkcja-2*) ; =&gt; &#34;pierwotna 2&#34; ;; redefinicja funkcji ;; (with-redefs [funkcja-1 (fn [] &#34;redefiniowana 1&#34;)] (funkcja-1)) ; =&gt; &#34;redefiniowana 1&#34; (funkcja-1) ; =&gt; &#34;bezpiecznie zaktualizowana 1&#34;

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.

Gdy użyjemy binding, dojdzie do przesłonięcia powiązania obiektu typu Var z funkcją. Powiązanie to będzie 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, bazuje na globalnej zmianie powiązania głównego obiektu typu Var. Nowa wartość będzie współdzielona między wątkami do momentu zakończenia przetwarzania wyrażeń podanych jako ostatni argument. Po zakończeniu ewaluacji 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, dyspozycją wieloraką (ang. multiple dispatch) lub wielodyspozycją 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 polimorficznej funkcji. 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ą, typem lub nadtypem (w przypadku typu jako wartości dyspozycyjnej). Metoda zostanie wywołana z argumentami takimi samymi, jak przekazane przy wywoływaniu multimetody.

Wybór wariantu multimetody z użyciem funkcji dyspozycyjnej

Proces wyboru wariantu multimetody na podstawie argumentu z użyciem funkcji dyspozycyjnej

Wybór będzie polegał na porównywaniu wartości dyspozycyjnej wyliczonej przez funkcję dyspozycyjną z wartościami przypisanymi do konkretnych wariantów 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(defmethod punktacja [::użytkownik ::komnata] [u k]
2  (u (:p-users (k @rooms))))
3
4(defmethod punktacja [::drużyna ::komnata] [d k]
5  (d (:p-teams (k @rooms))))
6
7(defmethod zlicz java.lang.String [s]
8  (count s))
(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 wyrażane mapą, której kluczami są słowa kluczowe:

  • :default – określa domyślną wartość dyspozycyjną
    (będzie ustawiona na :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;; definiowanie multimetody
 2
 3(defmulti zlicz
 4  "Zlicza elementy policzalnych struktur danych."
 5  (fn                      ; wieloczłonowa funkcja dyspozycyjna:
 6    ([] :default)          ; · wariant dla braku argumentów
 7    ([a] (type a))         ; · wariant dla jednego argumentu
 8    ([a & args] :multi)))  ; · wariant dla wielu argumentów
 9
10;; definiowanie metody domyślnej dla zerowej liczby argumentów
11
12(defmethod zlicz :default [] 0)
13
14;; definiowanie metody dla więcej niż jednego argumentu
15
16(defmethod zlicz :multi [& args] (apply + (map zlicz args)))
17
18;; definiowanie metod dla jednego argumentu
19
20(defmethod zlicz Number               [a] a)
21(defmethod zlicz java.lang.Character  [a] 1)
22(defmethod zlicz java.util.Collection [a] (count a))
23(defmethod zlicz java.lang.String     [a] (count a))
24
25;; wywoływanie multimetody
26
27(zlicz)                          ; => 0
28(zlicz 2)                        ; => 2
29(zlicz 2 2 \a)                   ; => 5
30(zlicz [:a :b :c [:a]] 7 "abc")  ; => 14
;; definiowanie multimetody (defmulti zlicz &#34;Zlicza elementy policzalnych struktur danych.&#34; (fn ; wieloczłonowa funkcja dyspozycyjna: ([] :default) ; · wariant dla braku argumentów ([a] (type a)) ; · wariant dla jednego argumentu ([a &amp; 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 [&amp; 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) ; =&gt; 0 (zlicz 2) ; =&gt; 2 (zlicz 2 2 \a) ; =&gt; 5 (zlicz [:a :b :c [:a]] 7 &#34;abc&#34;) ; =&gt; 14

Ujednoznacznianie wyboru, 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), a 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;; Tworzymy hybrydowy typ danych, który wyraża liczbę,
 2;; ale równocześnie implementuje interfejs Collection.
 3
 4(def liczbokolekcja
 5  (proxy [java.lang.Number java.util.Collection] []
 6    (intValue    []  0)
 7    (longValue   []  0)
 8    (floatValue  []  0)
 9    (doubleValue []  0)
10    (size        []  1)
11    (isEmpty     []  false)
12    (iterator    []  (.iterator (list 0)))
13    (toArray     []  (object-array [0]))
14    (toArray     [a] (object-array [0]))))
;; Tworzymy hybrydowy typ danych, który wyraża liczbę, ;; ale równocześnie implementuje interfejs Collection. (def liczbokolekcja (proxy [java.lang.Number java.util.Collection] [] (intValue [] 0) (longValue [] 0) (floatValue [] 0) (doubleValue [] 0) (size [] 1) (isEmpty [] false) (iterator [] (.iterator (list 0))) (toArray [] (object-array [0])) (toArray [a] (object-array [0]))))

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(defmethod zlicz java.util.Collection [a] (count a))
2(defmethod zlicz java.lang.String     [a] (count a))
(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 kontrolujących jej zachowanie.

Możemy jednak skorzystać z doraźnego systemu typów, aby z pomocą ustalonej hierarchii zgrupować m.in. 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ć znacznikowe nadtypy dla typów obiektowych;
  • podczas rozdysponowywania wywołań wykorzystywane są relacje między typami.

Oto jak możemy to zaimplementować:

Przykład zastosowana typów doraźnych użycia makra defmulti
 1;; tworzenie hierarchii
 2;;
 3(def ^:private ^:dynamic *zlicz-h*
 4  (-> (make-hierarchy)
 5      (derive java.lang.Character  ::pojedyncze)
 6      (derive java.lang.Number     ::identyczne)
 7      (derive java.lang.String     ::policzalne)
 8      (derive java.util.Collection ::policzalne)))
 9
10;; definiowanie multimetody
11
12(defmulti zlicz
13  (fn
14    ([] ::niema-args)
15    ([a] (type a))
16    ([a & args] ::wiele-args))
17  :default ::niema-args
18  :hierarchy (var *zlicz-h*))
19
20;; definiowanie metod
21
22(defmethod zlicz nil          [a] 0)
23(defmethod zlicz ::niema-args [ ] 0)
24(defmethod zlicz ::ignorowane [_] 0)
25(defmethod zlicz ::pojedyncze [a] 1)
26(defmethod zlicz ::identyczne [a] a)
27(defmethod zlicz ::policzalne [a] (count a))
28(defmethod zlicz ::wiele-args [& args] (apply + (map zlicz args)))
29
30;; wywoływanie multimetody
31
32(zlicz)               ; => 0
33(zlicz nil)           ; => 0
34(zlicz 2 2 "aa" [1])  ; => 7
35(zlicz (seq "aa"))    ; => 2
36
37;; dodawanie obsługi nowych typów
38;; w izolowanym wątku dynamicznego zasięgu
39
40(binding
41    [*zlicz-h* (-> *zlicz-h*
42                   (derive clojure.lang.StringSeq      ::policzalne)
43                   (derive clojure.lang.IPersistentMap ::policzalne))]
44  (zlicz {:a 1, :b 2}))
45; => 2
46
47;; dodawanie obsługi nowych typów
48;; we wszystkich wątkach zasięgu nieograniczonego
49;; (aktualizacja powiązania głównego zmiennej specjalnej)
50
51(alter-var-root
52 (var *zlicz-h*)
53 #(-> %1
54      (derive clojure.lang.StringSeq      ::policzalne)
55      (derive clojure.lang.IPersistentMap ::policzalne)
56      (derive clojure.lang.Keyword        ::pojedyncze)))
57
58(zlicz {:a 1, :b 2})  ; => 2
59(zlicz :a :b)         ; => 2
;; tworzenie hierarchii ;; (def ^:private ^:dynamic *zlicz-h* (-&gt; (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 &amp; 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 [&amp; args] (apply + (map zlicz args))) ;; wywoływanie multimetody (zlicz) ; =&gt; 0 (zlicz nil) ; =&gt; 0 (zlicz 2 2 &#34;aa&#34; [1]) ; =&gt; 7 (zlicz (seq &#34;aa&#34;)) ; =&gt; 2 ;; dodawanie obsługi nowych typów ;; w izolowanym wątku dynamicznego zasięgu (binding [*zlicz-h* (-&gt; *zlicz-h* (derive clojure.lang.StringSeq ::policzalne) (derive clojure.lang.IPersistentMap ::policzalne))] (zlicz {:a 1, :b 2})) ; =&gt; 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*) #(-&gt; %1 (derive clojure.lang.StringSeq ::policzalne) (derive clojure.lang.IPersistentMap ::policzalne) (derive clojure.lang.Keyword ::pojedyncze))) (zlicz {:a 1, :b 2}) ; =&gt; 2 (zlicz :a :b) ; =&gt; 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). Wymiana informacji polega na wywoływaniu przypisanych do obiektów metod, których nazwy i argumentowości zostały wcześniej określone (zapowiedziane protokołem).

Mamy więc mechanizm, który umożliwia dodawanie do klas pewnych adnotacji, dzięki którym inne części programu „wiedzą” jakich operacji spodziewać się po tych klasach. W jaki sposób te dodatkowe informacje mogą być pomocne?

Możemy powiedzieć, że dzięki protokołom możliwe jest wprowadzanie pewnych kontraktów, które określają, jakie metody będzie można wywoływać na obiektach, jeżeli ich klasy oznaczona jako bazujące na podanych protokołach. Powiemy wtedy, że dany typ implementuje konkretny protokół (lub wiele protokołów). Oczywiście takie kontrakty nie zawierają szczegółów implementacyjnych (np. konkretnych funkcji), lecz tylko ich zapowiedzi. Należy je więc definiować podczas lub przed definiowaniem klas, które oznaczono jako implementujące dany protokół lub protokoły.

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 piastowane w miejscu pracy. Do pracowników (klas) mogą być przypisane różne role (protokoły), a z każdą z nich związany będzie pewien konkretny zestaw obowiązków (zadeklarowane metody). Znając czyjeś stanowisko jesteśmy w stanie dowiedzieć się, czy ten pracownik może nam pomóc w rozwiązaniu konkretnego problemu. Doświadczeni pracownicy mogą mieć kompetencje w wielu dziedzinach (wiele protokołów opisujących klasę) i efektywnie piastować więcej, niż jedno stanowisko (gdy np. ich kolega wybierze się na urlop). Analogicznie: pojedynczy typ danych (definiowany klasą) może implementować wiele protokołów, z których każdy mówi o zdolności do przeprowadzania na tym typie innych zestawów operacji.

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 wewnętrznie reprezentowane są protokoły w Clojure, gdy platformą gospodarza jest JVM.

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.

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 osiąganego celu. Protokół pozwala wskazać, że tak jest. Jeżeli jakiś typ danych implementuje protokół Policzalne, 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ę.

Widać tu 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 i wzbogacone o odpowiednie struktury kontrolne specyficzne dla Clojure.

Użycie:

  • (defprotocol nazwa łańcuch-dok? & opcja… & sygnatura…).

Podawana jako pierwszy argument nazwa protokołu powinna być niezacytowanym symbolem, który stanie się nazwą tworzonego interfejsu (w przypadku platformy JVM). Po nazwie można umieścić opcjonalny łańcuch dokumentujący wyrażony łańcuchem znakowym w postaci literału ujętego w znaki cudzysłowu.

Kolejne argumenty makra to opcje i deklaracje polimorficznych funkcji wyrażone sygnaturami zbudowanymi na bazie listowych S-wyrażeń:

  • (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ą dodatkowo identyfikowane zmiennymi globalnymi zinternalizowanymi w bieżącej przestrzeni nazw.

Makro defprotocol wymaga, aby deklarowane metody zawsze specyfikowały pierwszy argument, który będzie służył do przekazywania wartości, na których funkcje będą operowały. Dodatkowo typ danych tego pierwszego argumentu zostanie użyty, aby zdecydować, który wariant polimorficznej funkcji powinien być wywołany. Możemy więc mieć na przykład tak samo nazwaną funkcję obsługi dla liczb i dla łańcuchów znakowych, a to, która implementacja zostanie wybrana, zależy od pierwszego argumentu.

W związku z powyższym możemy więc powiedzieć, że mamy tu do czynienia z tzw. polimorfizmem jednodyspozycyjnym (ang. single-dispatch polymorphism), działającym na bazie typów (ang. type-driven) w sposób dynamiczny. Jednodyspozycyjność oznacza, że mamy do czynienia z pojedynczym obiektem, który używany jest do podjęcia decyzji o przekazaniu wywołania do konkretnej implementacji (typ pierwszego argumentu funkcji), natomiast dynamiczność, że decyzja ta zapada w trakcie działania programu, a nie podczas kompilacji.

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;; definicja protokołu
 2;;
 3(defprotocol Policzalne
 4  "Zawiera operacje dla policzalnych typów danych."
 5  
 6  (zlicz
 7    [this] [this more]
 8    "Zlicza elementy podanej struktury.")
 9  
10  (policzalne?
11    [this]
12    "Dla policzalnych typów danych zwraca logiczną prawdę."))
13
14;; sprawdzanie typów utworzonych obiektów
15;;
16(type user/Policzalne)        ; => clojure.lang.PersistentArrayMap
17(type user.Policzalne)        ; => java.lang.Class
18(interface? user.Policzalne)  ; => true
19
20;; wyświetlanie mapy opisującej protokół
21;;
22user/Policzalne
23; => Policzalne
24; => {:doc "Zawiera operacje dla policzalnych typów danych."
25; =>  :method-builders {
26; =>    #'user/policzalne? #object[user$eval106$fn__10621 0x424 "user$eval106$fn__10621@424"]
27; =>    #'user/zlicz       #object[user$eval106$fn__10634 0x728 "user$eval106$fn__10634@728"]}
28; =>  :method-map   {:policzalne? :policzalne? :zlicz :zlicz}
29; =>  :on           user.Policzalne
30; =>  :on-interface user.Policzalne
31; =>  :sigs {
32; =>    :policzalne? {
33; =>      :arglists ([this])
34; =>      :doc      "Dla policzalnych typów danych zwraca logiczną prawdę."
35; =>      :name policzalne?}
36; =>    :zlicz {
37; =>      :arglists ([this] [this & args])
38; =>      :doc "Zlicza elementy podanej struktury."
39; =>      :name zlicz}}
40; =>  :var #'user/Policzalne}
41
42;; definiowanie rekordowego typu danych implementującego protokół Policzalne
43;;
44(defrecord Osoba [imię nazwisko wiek]
45  Policzalne                           ; implementacja protokołu:
46  (zlicz [this] (:wiek this))          ; · implementacja metody określonej protokołem
47  (policzalne? [this] true))           ; · implementacja metody określonej protokołem
48
49;; tworzenie rekordu (implementującego protokół Policzalne)
50;;
51(def Paweł (->Osoba "Paweł"  "Wilk" 99))
52
53;; sprawdzanie działania funkcji polimorficznych
54;;
55(policzalne? Paweł)  ; => true
56(zlicz Paweł)        ; => 99
;; definicja protokołu ;; (defprotocol Policzalne &#34;Zawiera operacje dla policzalnych typów danych.&#34; (zlicz [this] [this more] &#34;Zlicza elementy podanej struktury.&#34;) (policzalne? [this] &#34;Dla policzalnych typów danych zwraca logiczną prawdę.&#34;)) ;; sprawdzanie typów utworzonych obiektów ;; (type user/Policzalne) ; =&gt; clojure.lang.PersistentArrayMap (type user.Policzalne) ; =&gt; java.lang.Class (interface? user.Policzalne) ; =&gt; true ;; wyświetlanie mapy opisującej protokół ;; user/Policzalne ; =&gt; Policzalne ; =&gt; {:doc &#34;Zawiera operacje dla policzalnych typów danych.&#34; ; =&gt; :method-builders { ; =&gt; #&#39;user/policzalne? #object[user$eval106$fn__10621 0x424 &#34;user$eval106$fn__10621@424&#34;] ; =&gt; #&#39;user/zlicz #object[user$eval106$fn__10634 0x728 &#34;user$eval106$fn__10634@728&#34;]} ; =&gt; :method-map {:policzalne? :policzalne? :zlicz :zlicz} ; =&gt; :on user.Policzalne ; =&gt; :on-interface user.Policzalne ; =&gt; :sigs { ; =&gt; :policzalne? { ; =&gt; :arglists ([this]) ; =&gt; :doc &#34;Dla policzalnych typów danych zwraca logiczną prawdę.&#34; ; =&gt; :name policzalne?} ; =&gt; :zlicz { ; =&gt; :arglists ([this] [this &amp; args]) ; =&gt; :doc &#34;Zlicza elementy podanej struktury.&#34; ; =&gt; :name zlicz}} ; =&gt; :var #&#39;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ł (-&gt;Osoba &#34;Paweł&#34; &#34;Wilk&#34; 99)) ;; sprawdzanie działania funkcji polimorficznych ;; (policzalne? Paweł) ; =&gt; true (zlicz Paweł) ; =&gt; 99

Rozszerzanie typów o protokoły, extend

[TBW]

Jesteś w sekcji Poczytaj mi Clojure
Tematyka:

Taksonomie: