Polimorfizm to zbiór mechanizmów, dzięki którym te same konstrukcje języków programowania mogą być używane w odniesieniu do danych różnych rodzajów. Dzięki niemu możemy przetwarzać informacje z użyciem wyabstrahowanych, generycznych operacji, zyskując na czasie i czytelności kodu. Polimorfizm w Clojure to przede wszystkim obsługa wieloargumentowości, multimetod, protokołów i rekordów, a także korzystanie z interfejsów Javy.
Polimorfizm
Polimorfizm (ang. polymorphism, z gr. polis – wiele i morfi – postać, kształt), w dosłownym tłumaczeniu wielopostaciowość, to zbiór mechanizmów języka programowania, dzięki którym te same konstrukcje mogą być używane w odniesieniu do danych o różnych charakterystykach, na przykład o różnych typach.
Używając bardziej ogólnych terminów, polimorfizmem możemy nazwać możliwość tworzenia uogólnionych interfejsów dostępu do pewnych klas operacji, które można przeprowadzać na danych różnych rodzajów.
Dzięki operowaniu na różnych danych z użyciem tych samych interfejsów możemy stwarzać warstwy abstrakcji, które upraszczają wyrażanie rozwiązań problemów. Tworzenie i modernizowanie programów zajmuje wtedy mniej czasu, a kod jest czytelniejszy.
Prostym przykładem polimorfizmu może być obecna w wielu językach programowania
konstrukcja print
. Pozwala ona wysyłać na standardowe wyjście łańcuchy
znakowe, które reprezentują wartości podawanych argumentów, chociaż te ostatnie
mogą być danymi różnych typów: pojedynczymi znakami i łańcuchami znakowymi,
liczbami całkowitymi bądź zmiennoprzecinkowymi itp.
Inny przykład to wektor odniesień do obiektów umieszczonych w pamięci. Każdy z jego elementów może mieć inny typ, ale struktura danych umożliwia grupowanie ich wszystkich, ponieważ rodzaje obiektów, z których się składa, stanowią ich nadtyp (tzw. polimorfizm inkluzyjny) lub zawierają dodatkowe pola znacznikowe, określające typy przechowywanych danych (tzw. rekordy wariantowe, przykład tzw. polimorfizmu doraźnego).
Zdefiniowaliśmy polimorfizm jako mechanizm uniezależniania operacji od typów danych, ale też od innych cech charakterystycznych. Owe cechy mogą być specyficzne dla języka lub wskazywane przez programistę i niekoniecznie związane z typami w rozumieniu ich klasycznej definicji. Na przykład dwa wektory przechowujące tylko liczby typu całkowitego mogą różnić się wyłącznie długością, a wtedy polimorfizmem będzie operacja ich przetwarzania, która doprowadzi do wywołania różnych wariantów funkcji w zależności od liczby elementów. Idąc tym tropem, polimorfizmem nazwiemy też zautomatyzowane wywoływanie funkcji obsługujących tę samą operację w zależności od charakteru wartości przekazanych jako argumenty (np. mieszczeniu się w określonym zakresie czy dopasowaniu do jakiegoś wzorca).
Dzięki polimorfizmowi możliwe jest tzw. programowanie generyczne (ang. generic programming), zwane też programowaniem uogólnionym – styl tworzenia oprogramowania, w którym algorytmy mogą operować na danych o właściwościach (a w szczególności typach) nieznanych lub znanych tylko częściowo podczas ich implementowania.
W kolejnych sekcjach wyjaśnione będą teoretyczne podstawy polimorfizmu i przedstawiona zostanie klasyfikacja jego różnych rodzajów. Jeżeli celem lektury tego rozdziału jest znalezienie praktycznego sposobu na rozwiązanie jakiegoś problemu, można przenieść się do odpowiednich sekcji:
Jeżeli tworzona przez ciebie funkcja ma przyjmować argumenty, których wartości powinny być przekształcane do wartości innych typów (ze względu na kompatybilność lub wydajność), przeczytaj o koercji typu.
Jeżeli chcesz zmienić dane jednego typu w dane innego, zapoznaj się z pojęciem konwersji typu.
Jeżeli szukasz sposobu na ponowne definiowanie funkcji, która już istnieje i zastąpienie jej nową wersją, zajrzyj do części poświęconej nadpisywaniu funkcji.
Jeżeli chcesz utworzyć warianty tej samej funkcji, które będą różniły się liczbą przyjmowanych argumentów, zajrzyj do opisu przeciążania argumentowości.
Jeżeli twoja funkcja ma mieć różne warianty w zależności od typu wartości przekazywanej jako pierwszy argument, przeczytaj o protokołach. Pozwolą one też na przypisywanie nowych wariantów funkcji do nowo tworzonych typów danych, nieznanych podczas deklarowania operacji.
Jeżeli twoja funkcja ma mieć różne warianty w zależności od typów lub innych właściwości wartości przekazywanych jako jej dowolne argumenty, zainteresuj się multimetodami. One również mogą być rozszerzane o nowe warianty operacji.
Jeżeli chcesz utworzyć rekordowy typ danych, przypominający w użyciu mapę, którego dodatkowe operacje mogą być opcjonalnie opisane protokołami, skorzystaj z rekordów.
Jeżeli chcesz utworzyć obiektowy typ danych, który nie będzie wyposażony w interfejs mapy, lecz również pozwoli na implementowanie protokołów, spróbuj obiektowych typów własnych.
Problem wyrazu
Mechanizmy polimorficzne związane z obsługą operacji na danych różnych typów mogą być sposobem na poradzenie sobie z tzw. problemem wyrazu (ang. expression problem), który został sformułowany przez Philipa Waldera z Bell Labs w roku 1998. Wcześniej nazywany był on też problemem ekspresywności (ang. expressiveness problem).
Termin „ekspresywność” odnosi się tu do tzw. siły wyrazu (ang. expressive power) języków programowania. Oznacza ona zdolność do wyrażania algorytmów w przejrzysty i zwięzły sposób. W praktyce języki o dużej sile wyrazu wyposażone będą w konstrukcje składniowe i mechanizmy pozwalające tak abstrahować problemy, że ich reprezentacje w kodzie źródłowym będą przejrzyste oraz czytelne, tzn. zrozumiałe, jednoznaczne i bez zbędnych powtórzeń.
Języki, które pozwalają dodawać nowe konstrukcje składniowe będą miały większą siłę wyrazu od tych, gdzie polegamy wyłącznie na syntaktyce zaproponowanej przez ich twórców. Jednak składnia to nie wszystko, równie ważne są też zakorzenione w paradygmacie sposoby abstrahowania rodzajów danych i operacji, które można na nich wykonywać.
W niepublikowanym oficjalnie opracowaniu badacz streścił problem wyrazu następująco:
Problem Wyrazu jest nową nazwą starego problemu. Celem jest definiowanie typu danych z użyciem przypadków, gdzie do typu możemy dodawać nowe przypadki wraz z nowymi funkcjami obsługi bez rekompilacji istniejącego kodu i z zachowaniem bezpieczeństwa statycznego typizowania (np. bez rzutowania).
Za typ danych możemy uznać na przykład wyrażenia, zaczynając od jednego przypadku (stałych) i jednej funkcji (ewaluatorów), a następnie dodać jeszcze jeden konstrukt (plus) i jeszcze jedną funkcję (konwersję do łańcucha znakowego).
To, czy język potrafi rozwiązać Problem Wyrazu, jest dobitnym wskaźnikiem jego zdolności wyrażania.
Czym są wspomniane przypadki? Ogólnie rzecz ujmując, możemy nazwać je przypadkami zastosowań, a bardziej szczegółowo i implementacyjnie interfejsami lub protokołami dostępu do danych.
Program, nawet w języku dynamicznie typizowanym, składa się z danych określonych typów i z operacji, które na danych tych typów możemy przeprowadzać. Kwestią problematyczną jest znalezienie sposobu na to, aby dodawać obsługę nowych przypadków użycia, czyli nowych operacji na danych istniejących typów lub nowych typów danych, na których dałoby się przeprowadzać już zdefiniowane operacje. Powinno być to możliwe bez konieczności zmiany i ponownego kompilowania istniejącego kodu i bez korzystania z rzutowania typów czy sprawdzania ich w podprogramach dodawanych operacji (np. w funkcjach bądź metodach).
Aby zademonstrować problem, Walder zaproponował tabelę podobną do poniższej:
definicje typów: | ||
---|---|---|
class Liczba |
class Łańcuch |
|
warianty operacji: |
def plus(a, b); … end |
def plus(a, b); … end |
def równe?(a, b); … end |
def równe?(a, b); … end |
|
end |
end |
Widzimy, że w językach zorientowanych obiektowo (tu przykłady w Rubym) łatwo możemy dodawać nowe typy danych. Wystarczy utworzyć nową klasę. Trudniej dodawać operacje (wyrażane metodami), ponieważ wymaga to do dopisania czegoś do istniejących już klas. W języku Ruby rozwiążemy to np. przez domieszkowanie modułów, a w obiektowych językach typizowanych statycznie przez użycie tzw. klas abstrakcyjnych czy mechanizmu szablonów.
Spójrzmy, jak wyglądałaby podobna tabela dla języka zorientowanego funkcyjnie, czyli dla Clojure:
warianty obsługi typów: | ||||
---|---|---|---|---|
definicje operacji: |
||||
(defn plus [a b] (cond |
(integer? a) … |
(string? a) … |
)) |
|
(defn równe? [a b] (cond |
(integer? a) … |
(string? a) … |
)) |
W Clojure z łatwością dodamy obsługę nowych operacji (w postaci funkcji), ale trudniej będzie nam dodawać obsługę nowych typów. Pojawienie się nowego rodzaju danych, który należy obsługiwać nieco inaczej, spowoduje konieczność modyfikowania istniejących funkcji.
W językach funkcyjnych, szczególnie z silnym typizowaniem, ogólnym sposobem na zgeneralizowaną obsługę danych różnych typów jest zaimplementowanie tzw. algebraicznego typu danych (ang. algebraic data type). Jest to typ złożony (ang. composite type), który pozwala grupować elementy innych (dowolnych) typów. Przykładami mogą być tu tzw. typy produktowe (krotki, rekordy) czy typy sumaryczne (rekordy z wariantami). Operacje przeprowadzane na typie algebraicznym mogą być uszczegóławiane przez tworzenie nowych funkcji, chociaż typ danych nie zmienia się. Wadą takiego podejścia będzie jednak konieczność tworzenia osobnych, dodatkowych funkcji, o których trzeba pamiętać, gdy operuje się na strukturach konkretnych rodzajów. Użycie typu algebraicznego nie oznacza automatycznie, że będziemy mieli do czynienia z abstrakcyjnymi, polimorficznymi operacjami.
Wróćmy jednak do wcześniejszej kwestii: w jaki sposób możemy sobie poradzić z problemem wyrazu w Clojure – języku dynamicznie typizowanym, którego system typów bazuje na obiektowej platformie gospodarza?
Poszukujemy rozwiązania, które spełnia trzy podstawowe warunki:
możliwość dodawania nowych typów (rodzajów) danych i nowych operacji ich obsługi, a także łatwego rozszerzania istniejących operacji tak, aby działały dla nowych typów danych;
brak wymogu powielania istniejącego kodu czy jego zmiany;
otwarta możliwość rozszerzania programu o nowe typy i operacje (z użyciem rozszerzeń pochodzących z różnych źródeł, których można używać jednocześnie).
Chociaż Clojure jest językiem dynamicznie typizowanym, nie oznacza to, że informacje o typach nie są przydatne. Typów potrzebujemy choćby po to, aby wybierać takie warianty generycznych operacji, które nadają się do operowania na strukturach danych o pewnych właściwościach. Na przykład inaczej zrealizujemy algorytm dodawania do siebie dwóch łańcuchów znakowych, a inaczej wartości numerycznych.
Zamiast różnych funkcji przeznaczonych dla różnych typów chcielibyśmy mieć
jedną, generyczną funkcję, która potrafi wykryć z jaką strukturą ma do czynienia
i zaaplikować odpowiednią dla niej operację (możliwe, że realizowaną jako inna
funkcja, chociaż nie jest to warunkiem koniecznym). Przykładem takiej
wbudowanej, polimorficznej funkcji jest conj
, która posługuje się
innym algorytmem, gdy jako argument podamy wektor, innym, gdy będzie to lista,
a jeszcze innym w przypadku podania mapy:
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 rekordowych i protokoł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łasnych i protokołach (działa podobnie do rekordów, ale nie wymusza interfejsu w postaci asocjacyjnej struktury danych);
korzystając z multimetod – elastycznego mechanizmu dynamicznego polimorfizmu, w którym decyzja o wywołaniu konkretnej implementacji operacji zależy od rezultatów obliczeń przeprowadzonych na dowolnych argumentach przekazanych do funkcji;
implementując własne operacje polimorficzne, podobne do multimetod:
w postaci funkcji wyższego rzędu, które będzie można łatwo nadpisywać (zachowując odwołania do oryginałów) i uzależniać wynik od typu argumentów lub innych właściwości;
w postaci funkcji, które będą korzystały ze współdzielonej, globalnej zmiennej, zawierającej mapę odwołań do konkretnych implementacji w zależności od typu argumentów lub innych właściwości.
Rodzaje polimorfizmu
Możemy wyróżnić dwa podstawowe rodzaje polimorfizmu w zależności od etapu, na którym dochodzi do zastosowania mechanizmów jego obsługi:
statyczny, zwany też polimorfizmem czasu kompilacji (ang. compile-time polymorphism);
dynamiczny, zwany też polimorfizmem czasu uruchamiania lub polimorfizmem uruchomieniowym (ang. runtime polymorphism).
Polimorfizm statyczny realizowany jest w trakcie kompilacji, a dynamiczny podczas uruchamiania programu. Powszechnym przykładem zastosowania polimorfizmu statycznego jest przeciążanie funkcji i operatorów (w zależności od liczby argumentów), a polimorfizmu dynamicznego niektóre formy dziedziczenia i tzw. funkcje wirtualne w obiektowych językach programowania.
Zazwyczaj w językach statycznie typizowanych już podczas procesu kompilacji można wykryć, które wersje przeciążonych funkcji lub metod będą właściwymi do obsługi przekazywanego zestawu argumentów o określonych typach, a w językach typizowanych dynamicznie wiedza o właściwościach argumentów dostępna będzie dopiero po rozpoczęciu działania programu.
Języki dynamiczne mogą w czasie uruchamiania implementować te same mechanizmy polimorficzne, które w językach nie pozwalających na zmiany kodu w trakcie działania programu i/lub ze statycznym typizowaniem będą realizowane podczas kompilacji.
Przykładem powyższego może być tu przeciążanie metod. W języku C++ będzie ono
realizowane w czasie kompilowania programu, a w Javie w trakcie jego
uruchamiania, chyba że w podczas ich definiowania użyto modyfikatora static
,
private
lub final
.
W Clojure nie mamy do czynienia z przeciążaniem funkcji, ale z przeciążaniem ich argumentowości. Poza tym kryterium wyboru konkretnego ciała jest liczba argumentów, a nie ich typy. Przeciążanie tego rodzaju będzie techniką polimorfizmu statycznego, ponieważ właściwy obiekt uruchomieniowy określony zostanie w czasie kompilacji (nawet, jeżeli na poziomie języka Clojure funkcja wieloargumentowościowa jest jednym obiektem).
Możemy więc zauważyć, że współczesne języki programowania wysokiego poziomu mogą implementować polimorfizm na wiele sposobów i czasem to, czy będziemy mieli do czynienia z wariantem statycznym czy z dynamicznym zależy od konkretnego języka, a czasem nawet od zastosowanych w kodzie konstrukcji.
Poza rodzajami polimorfizmu ze względu na etap zastosowania pewnych mechanizmów (w trakcie kompilacji lub w czasie uruchamiania programu) możemy też klasyfikować wielopostaciowe konstrukcje w zależności od osiąganego z ich pomocą celu czy wykorzystywanych instrumentów języka.
Polimorfizm doraźny
Najczęściej wykorzystywanym w Clojure rodzajem polimorfizmu jest tzw. polimorfizm doraźny (ang. ad hoc polymorphism), czyli taki, gdzie możemy stwarzać jednolite interfejsy obsługi pewnych operacji, które mogą być potem stosowane względem danych o różnej charakterystyce (np. o różnych typach).
Ten rodzaj polimorfizmu bazuje zwykle na decyzji odnośnie tego który wariant operacji zostanie zrealizowany, gdy odwołamy się do abstrakcyjnej, polimorficznej funkcji.
Przykładami polimorfizmu doraźnego będą multimetody, protokoły, a także przeciążanie i nadpisywanie funkcji.
Polimorfizm inkluzyjny
W interakcjach z systemami typów platformy gospodarza znajdziemy też mechanizmy polimorfizmu inkluzyjnego (ang. inclusive polymorphism), zwanego też polimorfizmem podtypowym (ang. subtype polymorphism), który bazuje na hierarchii typów. Dzięki niemu jesteśmy w stanie tworzyć operacje obsługujące wartości pewnych typów i automatycznie wszystkich ich podtypów.
Polimorfizm inkluzyjny jest powszechnie wykorzystywany w obiektowych językach
programowania, ponieważ mamy tam do czynienia z dziedziczeniem. Na przykład
metoda dodaj
klasy Liczba
może przyjmować argument typu Liczba
i dokonywać
sumowania go z wartością jakiegoś pola. Jeżeli stworzymy klasę pochodną
Całkowita
, to odziedziczy ona metodę i gdy wywołamy dodaj
na obiekcie klasy
Liczba
, przekazując jako argument instancję klasy Całkowita
, sumowanie
również zostanie przeprowadzone, bo domniemywa się, że operacja potrafi je
obsłużyć. Gdyby nie potrafiła, w gestii programisty leży nadpisanie metody
w klasie pochodnej.
Najprostszym przykładem polimorfizmu inkluzyjnego w Clojure jest koercja typu, możemy też zaliczyć do niego niektóre zastosowania protokołów.
Polimorfizm parametryczny
W Clojure mamy też do czynienia z polimorfizmem parametrycznym (ang. parametric polymorphism), w którym możemy tworzyć operacje przystosowane do obsługi danych dowolnych typów, a potem wywoływać je, podając konkretny typ, albo pozostawiając zadanie jego odgadnięcia językowi.
Istotną różnicą względem polimorfizmu doraźnego jest to, że mamy tam do czynienia z jedną operacją, a nie jej wieloma wariantami i mechanizmem decyzyjnym.
Przykładem polimorfizmu parametrycznego może być znany z C++ system szablonów czy
klas i metod generycznych w Javie. W Clojure polimorfizm tego rodzaju zaobserwujemy
np. w funkcji tożsamościowej, funkcji stałej czy
konstrukcjach map
, reduce
i podobnych – operują one na
wartościach lub kolekcjach wartości dowolnych typów. Możemy też samodzielnie
implementować ten rodzaj polimorfizmu, korzystając z funkcji wyższego rzędu.
Polimorfizm typów danych
W językach programowania wykorzystujących systemy typów, w których mamy do czynienia z możliwością tworzenia relacji między typami (wyróżnianiem podtypów i nadtypów) często spotkamy się z polimorficznymi mechanizmami wykorzystującymi ten sposób oznaczania danych. Za polimorfizm bazujący na typach możemy uznać wtedy dziedziczenie klas (jeżeli dochodzi do nadpisywania metod) czy obsługę argumentów, które nie są deklarowanych typów, lecz typów pozostających z nimi w relacji.
Proste operacje polimorficzne
Pewne operacje na typach danych możemy uznać za proste mechanizmy polimorficzne, gdyż pozwalają traktować wartości danego typu tak, jakby były wartościami innego. Te operacje to:
- konwersja – tworzenie wartości nowego typu na bazie wartości innego typu;
- rzutowanie – traktowanie wartości danego typu tak, jakby była wartością innego typu;
- koercja – konwersja typu wartości przekazywanej jako argument funkcji.
Warto na wstępie zaznaczyć, że w praktyce niektóre z wymienionych terminów używane bywają zamiennie z uwagi na nieprecyzyjne konwencje nazewnictwa i różnice w szczegółach działania kompilatorów czy maszyn wirtualnych.
Konwersja typu
Konwersja typu (ang. type conversion) to operacja przekształcania wartości jednego typu do wartości innego typu danych. Podczas konwersji powstaje nowa wartość na bazie odpowiednio zmienionych danych struktury źródłowej.
Przykładem konwersji może być zmiana łańcucha znakowego reprezentującego liczbę
całkowitą w wartość numeryczną typu całkowitego lub zmiana wartości logicznej
w odpowiadający jej symboliczny napis. Z konwersją będziemy też mieli do
czynienia, gdy zmienimy liczbę całkowitą w jej dłuższy lub krótszy odpowiednik,
tworząc nowy obiekt – np. w konwersji wartości typu Integer
do wartości typu
Long
czy Short
.
W Clojure do konwersji typu służą odpowiednie funkcje – zazwyczaj te same, które używane są też do tworzenia nowych wartości określonych typów. Możemy między innymi dokonywać konwersji:
(byte wartość)
– do bajtu,(short wartość)
– do liczby całkowitej krótkiej,(int wartość)
– do liczby całkowitej,(long wartość)
– do liczby całkowitej długiej,(float wartość)
– do liczby zmiennoprzecinkowej,(double wartość)
– do liczby zmiennoprzecinkowej podwójnej precyzji,(bigdec wartość)
– do liczby dziesiętnej nieograniczonej,(bigint wartość)
– do liczby całkowitej nieograniczonej (typ Clojure),(biginteger wartość)
– do liczby całkowitej nieograniczonej (typ Javy),(num wartość)
– do wartości numerycznej reprezentowanej obiektem,(rationalize wartość)
– do liczby ułamkowej,(booleans tablica)
– do tablicy wartości logicznych,(bytes tablica)
– do tablicy bajtów,(chars tablica)
– do tablicy znaków,(shorts tablica)
– do tablicy liczb całkowitych krótkich,(ints tablica)
– do tablicy liczb całkowitych,(longs tablica)
– do tablicy liczb całkowitych długich,(floats tablica)
– do tablicy liczb zmiennoprzecinkowych,(doubles tablica)
– do tablicy liczb zmiennoprzecinkowych podwójnej precyzji.
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.
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:
Powyższe przykłady obrazują tzw. rzutowanie referencyjne, ponieważ zmienne
s
, o
i x
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:
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.
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ą (typuInteger
). Nawet jeżeli w JVM dojdzie do rzutowania, to rezultatem wywołania funkcjiint
w Clojure będzie stworzenie nowej wartości (nowego obiektu typujava.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.
Funkcje long
i double
, 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:
- „Rzutowanie typów numerycznych”, rozdział IV.
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:
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:
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:
Widzimy, że podczas kompilacji nie było możliwe ustalenie, czy przyjmowany
obiekt będzie wyposażony w metodę length
– poinformował nas o tym komunikat
ostrzeżenia. Sprawdzanie zostanie więc odłożone do czasu uruchamiania programu,
co w niektórych przypadkach może mieć ujemny wpływ na wydajność obliczeń.
Warto wtedy skorzystać z wyrażonej wprost koercji.
W praktyce koercja w Clojure będzie polegała na konwersji parametru funkcji w jej ciele lub na zastosowaniu tzw. sugerowania typów w odniesieniu do argumentów i/lub wartości zwracanych.
Widzimy, że wersja sumatora bez koercji (funkcja sumuj
) jest niemal
trzykrotnie powolniejsza, niż wersje z koercją, za wyjątkiem funkcji
sumuj-fun
, w której dokonujemy koercji z użyciem przekazywanego obiektu
funkcyjnego, który ma ją obsłużyć (long
). Okazuje się jednak, że tego typu
konstrukcje nie są optymalizowane w czasie kompilacji i do czynienia mamy
z przekształcaniem typu w czasie pracy programu (czyli korzystaniem
z java.lang.Long
).
W przypadku trzech ostatnich implementacji obserwujemy zysk wydajnościowy.
Różnią się one czasami realizacji, lecz wynika to z narzutu na tworzenie innych
obiektów; na przykład makro sumuj-steroids
potrzebuje trochę czasu na
przygotowanie symboli. Warto mieć na uwadze, że podane czasy obejmują kompilację
i ewaluację, ponieważ mamy do czynienia z REPL.
Istnieje kilka prostych zasad, o których warto pamiętać, gdy zamierzamy tworzyć funkcje, w których jawnie bądź automatycznie dochodzi do koercji:
Jeżeli koercji dokonujemy w ciele funkcji, warto korzystać z powiązań leksykalnych (np. 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 (pętli), czyli tam, gdzie rzeczywiście jest to potrzebne (reszta konstrukcji powinna mieć dostęp do oryginalnej wartości parametru).
Tam, gdzie to możliwe, warto używać tzw. sugerowania typów zamiast konwersji – jest to czytelny i wydajny sposób koercji.
Sugerowanie typu
Sugerowanie typu (ang. type hinting), zwane też podpowiadaniem typu, to mechanizm języka Clojure, który pozwala oznaczać niektóre wyrażenia (w tym argumenty i wartości zwracane przez funkcje) identyfikatorami typów danych.
Użycie:
^typ S-wyrażenie
,^{:tag typ} S-wyrażenie
.
Podpowiedzi dotyczące typów to metadane umieszczane przed symbolami lub innymi S-wyrażeniami, które tworzą powiązania bądź zwracane wartości. Możemy je zastosować:
- w nazwie zmiennej globalnej przed jej symbolem,
- w wektorze parametrycznym przed argumentami funkcji,
- w definicjach funkcji przed wektorem parametrycznym,
- w wektorze powiązań przed nazwami symboli,
- w formach wywołania funkcji przed ich listowymi S-wyrażeniami,
- w przypadku innych form implementujących interfejs
IMeta
.
Ze względu na rodzinę typów, do których dokonywane jest rzutowanie, będziemy mieli do czynienia z dwoma rodzajami sugerowania:
- sugerowanie typów podstawowych,
- sugerowanie typów obiektowych.
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.
W przykładach powyżej mieliśmy do czynienia z sugerowaniem typów obiektowych, tzn. takich, których dane reprezentowane są z użyciem obiektowego systemu typów gospodarza.
Gdyby jednak zaszła potrzeba bezpośredniego skorzystania z wartości typu
podstawowego, który został opakowany, to – poza rzutowaniem – możemy w Clojure
skorzystać z sugestii typów podstawowych (ang. primitive type hints). Użycie
jej sprawi, że już na etapie kompilacji dojdzie do optymalizacji zastosowanych
konstrukcji. Jeżeli nie będzie to możliwe, zgłoszony zostanie wyjątek
java.lang.ClassCastException
, ponieważ proces wypakowywania typu podstawowego
z typu obiektowego realizowany jest wewnętrznie z użyciem rzutowania.
Obsługa sugerowania typów, poza typami obiektowymi obejmuje:
- podstawowe typy proste:
long
– liczba całkowita dłuższa,double
– liczba zmiennoprzecinkowa podwójnej precyzji;
- podstawowe typy złożone:
ints
– tablica liczb całkowitych,longs
– tablica liczb całkowitych dłuższych,floats
– tablica liczb zmiennoprzecinkowych,doubles
– tablica liczb zmiennoprzecinkowych podwójnej precyzji.
Uwaga: Nie możemy dokonywać sugerowania typów podstawowych w odniesieniu do lokalnych powiązań. Próby takie spowodują zgłoszenie wyjątku
java.lang.UnsupportedOperationException
. Zamiast tego należy stosować rzutowanie.
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:
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).
Definiując typy rekordowe, możemy korzystać z tzw. protokołów, dzięki którym jesteśmy w stanie sprawić, że będą one obsługiwane przez stworzone warianty polimorficznych funkcji, gdy pierwszym przekazywanym argumentem wywołań będzie dany typ rekordowy (polimorfizm doraźny). Możliwe jest również dodawanie nowych funkcji obsługi do istniejących typów rekordowych.
Definiowanie typów rekordowych, defrecord
Makro defrecord
pozwala definiować typy rekordowe. Jego użycie sprawia, że
zostanie dynamicznie wygenerowana kompilowana do kodu bajtowego klasa
definiująca rekordowy typ danych, wraz z zawierającym ją pakietem o nazwie
takiej samej, jak nazwa bieżącej przestrzeni nazw.
W fazie pełnej kompilacji programu (jeżeli do niej dojdzie) nazwa klasy
z dołączoną z przodu kropką i nazwą pakietu utworzą nazwę pliku, do której
dodane będzie rozszerzenie class
. Do pliku wpisana będzie definicja klasy
i umieszczony on zostanie w katalogu o nazwie ścieżkowej określonej zmienną
specjalną *compile-path*
.
W instancjach tworzonego z użyciem makra defrecord
typu danych zawarte będą
określone przez programistę pola. Będzie też można korzystać z metod
zadeklarowanych w implementowanych protokołach (Clojure) oraz
interfejsach (Javy), a zdefiniowanych w wywołaniu makra.
Do rekordów powstałych na bazie zdefiniowanego typu będzie można dynamicznie dodawać nowe pola wraz z wartościami, nawet jeżeli nie zostały one zadeklarowane w momencie tworzenia typu. Tego rodzaju pola nazywamy polami rozszerzeniowymi (ang. extension fields).
Użycie:
(defrecord nazwa pole… & opcja… & specyfikacja…)
.
Pierwszym argumentem makra powinna być nazwa typu rekordowego, a kolejnymi (opcjonalnymi) nazwy pól, które mogą zawierać sugestie typów. Po nazwach pól możemy podać opcje (obecnie nieobsługiwane) i tzw. specyfikacje (ang. skr. specs).
Uwaga: Nazwami pól nie mogą być
__meta
oraz__extmap
– są to symbole zarezerwowane, które służą do wewnętrznej obsługi metadanych i pól rozszerzeniowych.
Każda specyfikacja powinna składać się z nazwy protokołu lub interfejsu platformy gospodarza, po której może (ale nie musi) pojawić się jedna lub więcej definicji metod w postaci:
(nazwa-metody [this & argument…] ciało)
.
Możemy i powinniśmy zdefiniować metody, które zostały zadeklarowane
w protokołach lub interfejsach. Dodatkowo możemy umieszczać przeciążone wersje
metod klasy Object
.
Pierwszym przyjmowanym argumentem w przypadku definiowania metod zadeklarowanych
w interfejsach powinien być obiekt instancji bieżącej (odpowiednik this
z Javy). Podczas ich bezpośredniego wywoływania (z wykorzystaniem mechanizmów
odwoływania się do obiektów platformy gospodarza) będzie on przekazywany
automatycznie i nie należy podawać go wprost.
W odniesieniu do argumentów i wartości zwracanych przez wszystkie metody możliwe jest zastosowanie sugerowania typów.
W ciałach metod jesteśmy w stanie odwoływać się do definiowanej klasy (i w związku z tym do jej klasowych metod) przez podawanie jej symbolicznej nazwy.
Uwaga: Definicje metod nie zamykają w ciałach powiązań z leksykalnego otoczenia ich definicji. Mamy dostęp wyłącznie do zadeklarowanych pól.
Do nowo powstałego typu dodane będą również następujące metody:
hashCode
– służąca do wyliczania skrótu struktury danych;equals
– służąca do porównywania zawartości map i rekordów;=
– służąca do porównywania zawartości oraz typów;- konstruktor przyjmujący wartości pól i wartości pól rozszerzeniowych;
- konstruktor przyjmujący wartości pól, metadane i mapę pól rozszerzeniowych.
Poza tym stworzone będą funkcje wywołujące wspomniane konstruktory:
(->nazwa wartość…)
,(map->nazwa mapa)
;
gdzie nazwa
jest nazwą stworzonego typu rekordowego, wartość
wartością pola,
a mapa
mapą zawierającą nazwy pól (wyrażone słowami kluczowymi) i przypisane
im wartości.
Pierwsza z funkcji pozwala tworzyć rekordy przez podanie wartości pól wyrażonych pozycyjnie, a druga działa tak samo, lecz przyjmuje asocjacje zawarte w mapie.
Uwaga: Nie należy tworzyć rekordów przez bezpośrednie wywoływanie konstruktorów obiektu. Lepiej skorzystać z funkcji języka Clojure, które służą do tego celu:
->typ
imap->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.
Tworzenie rekordów, 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 Użycie: Argumentami wywołania funkcji powinny być wartości kolejnych pól definiowanych
przez typ rekordu. Wartością zwracaną jest rekord. Tworzenie rekordów z map, Tworzyć rekordy na bazie map możemy z użyciem generowanej podczas definiowania
nowego typu funkcji Użycie: 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 Wartością zwracaną przez funkcję jest rekord. 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 ( Użycie: gdzie 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 Nowe typy obiektowe możemy tworzyć z użyciem makra 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
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. 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 Użycie: gdzie: Pole, opcja i specyfikacja mogą być podane wiele razy, natomiast opcja i specyfikacja
mogą pojawić się wiele razy. Dokładny opis użycia makra 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 (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. Zobacz także: 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: W przypadku użycia W przypadku Jeżeli chodzi o użycie Ostatni przykład, polegający na wywołaniu Możemy również dokonywać operacji typowo obiektowych, nadpisując metody
zdefiniowane w klasach obiektowego systemu typów: 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. Dopasowywanie będzie polegało na porównywaniu wartości dyspozycyjnej wyliczonej
przez funkcję dyspozycyjną z wartościami przypisanymi do metod. Porównywanie nie
będzie odbywało się z użyciem operatora 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 Definiowanie metod, które będą wywoływane na podstawie dopasowania wartości
dyspozycyjnej, możliwe jest z użyciem makra Użycie: 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 Multimetody można definiować z użyciem makra Użycie: Pierwszym, obowiązkowym argumentem powinna być nazwa multimetody. Po niej można
umieścić opcjonalny łańcuch dokumentujący wyrażony literałem zawierającym z obu
stron znaki cudzysłowu, a także opcjonalne mapowe S-wyrażenie
zawierające metadane. Kolejnym obowiązkowym argumentem jest funkcja
dyspozycyjna wyrażona niezacytowanym symbolem lub inną konstrukcją, która
reprezentuje funkcyjny obiekt. Reszta argumentów to opcje, które powinny być wyrażone
mapą, której kluczami są słowa kluczowe: 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. Może zdarzyć się tak, że w definicji metody zamiast pojedynczej wartości
dyspozycyjnej umieszczony będzie zestaw wartości wyrażony np. [wektorem]wektorowe
S-wyrażenia, ponieważ funkcja dyspozycyjna zwraca wektor. Jeżeli do innej metody również zostanie przypisana taka sama wartość dyspozycyjna
(jako element wektora lub pojedynczo), to gdy funkcja dyspozycyjna zwróci właśnie ją
na pozycji pasującej do obu wywołań, będziemy mieli do czynienia z konfliktem
wynikającym z niejednoznacznego dopasowania. Obsługa takich przypadków możliwa jest z użyciem funkcji [TBW] Jeżeli uważnie przyjrzymy się przykładowi użycia makra 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 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 Możemy jednak skorzystać z doraźnego systemu typów, aby
z pomocą ustalonej hierarchii zgrupować typy obiektowe, czyniąc je potomkami
jednego, znacznikowego nadtypu. Jest to możliwe, ponieważ: Spójrzmy, jak możemy to zaimplementować: Protokół (ang. protocol) to pojęcie wywodzące się z obiektowego
paradygmatu programowania. W ogólnym rozumieniu oznacza ono zbiór zasad
określający sposób komunikacji między instancjami różnych klas
(obiektami różnych typów). Wymiana informacji polega na wywoływaniu
przypisanych do obiektów metod, których nazwy i argumentowości zostały
wcześniej określone. Możemy powiedzieć, że dzięki protokołom możliwe jest wprowadzanie pewnych
kontraktów, które określają, jakie operacje będzie można wykonywać na
obiektach nowo tworzonych typów danych. Mówimy wtedy, że dany typ
implementuje konkretny protokół (lub kilka protokołów). Oczywiście
kontrakty te nie zawierają szczegółów implementacyjnych (np. konkretnych
funkcji), lecz tylko ich zapowiedzi. Należy je więc definiować podczas
lub przed utworzeniem typów, które oznaczono jako implementujące dany protokół
lub protokoły. Protokoły są mechanizmem polimorfizmu doraźnego i pozwalają deklarować
możliwe do wykonania operacje na danych, ale bez wskazywania konkretnych
typów tych ostatnich. Dopiero późniejsze definicje typów
(np. klasy w Javie, czy rekordy bądź
definicje typów własnych w Clojure) mogą włączać protokoły
do użytku i definiować określone nimi funkcje. Zorientowane obiektowo języki programowania wyposażone są często w konstrukcje
pozwalające tworzyć protokoły, aby potem implementować zadeklarowane nimi metody
w poszczególnych klasach. W Javie odpowiednikiem protokołów są
tzw. interfejsy (ang. interfaces) i w ten właśnie sposób są reprezentowane
protokoły w Clojure, gdy platformą gospodarza jest JVM. W praktyce protokół to zbiór deklaracji powiązanych logicznie metod lub
funkcji, który potem staje się wzorcem dla tworzenia nowych typów danych lub
rozszerzania istniejących. Jeżeli jakiś typ danych implementuje protokół, to
znaczy, że na wartościach tego typu możemy dokonywać określonych protokołem
operacji. Protokoły przypominają stanowiska w miejscu pracy. Można przypisać je do różnych
pracowników, a z każdym związany jest jakiś zestaw obowiązków. Znając stanowisko
jesteśmy więc w stanie dowiedzieć się, czy dany pracownik jest w stanie pomóc
nam w rozwiązaniu konkretnego problemu. Doświadczeni pracownicy mogą mieć
kompetencje w wielu dziedzinach i efektywnie piastować więcej, niż jedno
stanowisko (gdy np. ich kolega wybierze się na urlop). Analogicznie: pojedynczy
typ danych może implementować wiele protokołów, z których każdy mówi o zdolności
do przeprowadzania na tym typie pewnych zestawów operacji. Po co wprowadzać abstrakcyjne byty nazywane protokołami i deklarować coś, co
i tak zostanie zrealizowane? Istnieje kilka powodów – dzięki protokołom: możemy określić, jak korzystać z danych konkretnych typów w pewnych
okolicznościach, tzn. jakie operacje można na nich wykonywać; możemy wymóc definiowanie pewnych zestawów operacji dla danych
istniejących lub nowo tworzonych typów; możemy rozpoznawać czy dane określonych typów implementują protokoły
i różnie traktować je w programie, np. badać, czy typ obsługiwanej przez nasz
program wartości implementuje interfejs pozwalający zapisywać lub odczytywać
metadane. Na przykład protokół 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 rekordami i definicjami 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 Powyżej widać istotną różnicę w stosunku do języków bazujących na obiektowym
paradygmacie programowania. Nowe operacje nie są tu metodami, które wywołujemy
na obiektach, lecz niezależnymi, publicznymi funkcjami. Decyzja o wybraniu
konkretnego wariantu danej funkcji (przypisanego do danego typu danych) jest
podejmowana w czasie wywoływania wersji polimorficznej, a podstawą tej decyzji
jest typ pierwszego przekazywanego argumentu. Korzystanie z protokołów polega na ich tworzeniu, a następnie implementowaniu
zadeklarowanych w nim metod w odniesieniu do nowych lub istniejących typów
danych. W przypadku nowych typów wykorzystywanymi konstrukcjami będą
Z użyciem makra 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: 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ń: 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 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 Efektem pracy makra będzie utworzenie: 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
Polimorficzne funkcje będą dodatkowo identyfikowane [zmiennymi globalnymi]zmienne
globalne zinternalizowanymi w bieżącej przestrzeni nazw. Makro 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 [TBW]->typ
->typ
(gdzie typ
jest nazwą typu).
(->typ pole…)
.map->typ
map->typ
(gdzie typ
jest nazwą typu).
(map->typ mapa)
.nil
.Rekordowe S-wyrażenia
#
) i pełną nazwą typu rekordowego
(włączając przestrzeń nazw), umieszczonymi przed otwierającym nawiasem
klamrowym.
#przestrzeń-nazw.typ-rekordowy{pole-wartość…}
,pole-wartość
to:
:pole wartość
.nil
.Własne typy obiektowe
deftype
.
Powstające w ten sposób typy danych są implementowane z użyciem systemu klas
platformy gospodarza.clojure.lang.IPersistentMap
). Nie ma nawet wymogu, aby deklarować jakiekolwiek
pola.Definiowanie typów,
deftype
deftype
.
deftype nazwa pole… & opcja… & specyfikacja…
;
nazwa
jest symbolem nazywającym klasę,pole
jest symbolem nazywającym pole klasy,opcja
jest opcją,specyfikacja
jest specyfikacją.deftype
można znaleźć w rozdziale poświęconym
systemom typów.Polimorfizm operacji
Przeciążanie argumentowości
Nadpisywanie funkcji
defn
i alter-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.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.binding
, to będziemy mieli przesłonięcie
powiązania obiektu typu Var
z funkcją ograniczone
zasięgiem dynamicznym i izolowane w bieżącym wątku.
Pozostałe wątki będą korzystały z powiązania głównego.with-redefs
, to
globalna zmiana powiązania głównego obiektu typu Var
. Nowa wartość będzie
współdzielona między wątkami do zakończenia przetwarzania wyrażeń podanych
jak ostatni argument. Po tym czasie przywrócone będzie poprzednie powiązanie
główne.Multimetody
.--------------. .------------------------. ,-------------------.
| wywołanie | | automatyczne wywołanie | --> | (fn [x] |
| multimetody | | funkcji dyspozycyjnej | | (case (…) |
| | ------> | | | … :liczba |
| (zlicz [:a]) | | rezultat: :wektorek | <-- | … :wektorek)) |
`--------------' `------------------------' `-------------------'
v
.------------------------.
| dopasowanie metody |
| do wartości :wektorek |
`------------------------'
v
.----------------------. .------------------------. .----------------------.
| (zlicz :default [x]) | | (zlicz :wektorek [x]) | | (zlicz :liczba [x]) |
`----------------------' `------------------------' `----------------------'
v
.-----------------------.
| wywołanie metody |
`-----------------------'
=
, ale z wykorzystaniem funkcji
isa?
, która działa w następujący sposób:
true
, funkcja isa?
kończy pracę
i zwraca ją. W ten sposób realizowane jest dopasowywanie wartości dyspozycyjnych. Definiowanie metod,
defmethod
defmethod
.
(defmethod nazwa wartość-dyspozycyjna & argument…)
.clojure.lang.MultiFn
.Definiowanie multimetod,
defmulti
defmulti
.
(defmulti nazwa łańcuch-dok? metadane? dyspozytor & opcje)
.
:default
– określa domyślną wartość dyspozycyjną (równa :default
, gdy nie
podano);:hierarchy
– określa wyrażoną typem referencyjnym
(np. zmienną globalną) hierarchię używaną do dopasowywania
typów doraźnych (domyślnie używana jest hierarchia globalna).Ujednoznacznianie wyboru metod,
prefer-method
prefer-method
.Grupowanie wartości dyspozycyjnych
defmulti
podanemu wcześniej, znajdziemy w nim powtórzoną definicję metody zlicz
dla
różnych wartości dyspozycyjnych:count
.defmulti
powinniśmy szukać
danych.
defmulti
pozwala przekazać własną hierarchię;Protokoły
Policzalne
może zawierać zapowiedź abstrakcyjnej operacji
zlicz
, której zadaniem jest obliczanie liczby elementów. Inaczej będziemy
zliczali litery łańcuchów znakowych, a inaczej elementy tablic bądź wektorów,
jednak dane wymienionych typów mogą być obsłużone w podobny sposób pod względem
celu. Protokół pozwala wskazać, że tak jest. Jeżeli jakiś typ danych
implementuje protokół Policzalne
, to mamy pewność, że można wywołać metodę
zlicz
na jego obiekcie (w językach zorientowanych obiektowo) lub wywołać
funkcję zlicz
, przekazując jej wartość tego typu (w językach funkcyjnych).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ę.defrecord
lub deftype
, natomiast w przypadku
istniejących (w tym wbudowanych typów obiektowych)
extend
, extend-type
i extend-protocol
.Definiowanie protokołów,
defprotocol
defprotocol
możemy tworzyć nowe protokoły, czyli nazwane
zestawy operacji wykorzystywanych podczas definiowania i rozszerzania
obiektowych typów danych.
(defprotocol nazwa łańcuch-dok? & opcja… & sygnatura…)
.
(nazwa [this argument…] łańcuch-dok?)
.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.defprotocol
.
user.Nazwa
),user/Nazwa
),user/funkcja
).clojure.lang.PersistentArrayMap
) opisującą protokół.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.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-protocol
i extend-type
.Rozszerzanie typów o protokoły,
extend