Komparatory to funkcje, które służą do porównywania wartości elementów kolekcji. Zastosowanie znajdują przede wszystkim w operacjach sortowania. W Clojure każda funkcja może być komparatorem, jeżeli tylko będziemy przestrzegali odpowiednich wytycznych.
Komparatory
Komparator (ang. comparator) to funkcja porównująca, która przyjmuje dwa argumenty i zwraca wartość określającą w jakiej relacji kolejności wartości tych argumentów powinny być uporządkowane. Używane są do m.in. do sortowania zbiorów danych (np. kolekcji czy sekwencji).
Możemy mieć do czynienia z dwoma typami komparatorów:
komparatory trójstanowe (ang. 3-way comparators) – zwracające jedną z trzech wartości wyrażających relację kolejności między pierwszym a drugim podanym elementem: pierwszy (kolejność zastana – pierwszy element powinien być umieszczony wcześniej), równy (kolejność zastana – pierwszy element powinien być umieszczony na tej samej pozycji lub pozycja względem drugiego nie ma znaczenia), drugi (kolejność wymaga zmiany – pierwszy element powinien być umieszczony na dalszej pozycji niż drugi);
komparatory dwustanowe (ang. 2-way comparators) – zwracające jedną z dwóch wartości wyrażających relację kolejności między pierwszym a drugim elementem: pierwszy (pierwszy element powinien być umieszczony przed drugim), drugi (pierwszy element powinien być umieszczony za drugim).
Komparatory dwustanowe bywają też nazywane komparatorami boolowskimi (ang. boolean comparators) i przez niektóre funkcje mogą być wywoływane dwukrotnie, aby dokonać sprawdzenia, czy zachodzi równość elementów.
Funkcje implementujące komparatory trójstanowe zwracają zwykle liczbę
całkowitą, a komparatory dwu stanowe wartość logiczną
(true
lub false
).
W zależności od typu danych możemy wyróżnić różne rodzaje porządków, które będą efektem pracy funkcji porównujących. Na przykład:
porządek liczbowy (np. porządek liczb całkowitych, porządek liczb zmiennoprzecinkowych czy porządek ułamków) dla danych numerycznych lub danych asocjacyjnych identyfikowanych numerycznie;
porządek leksykograficzny dla danych alfabetycznych (np. łańcuchów znakowych, symboli czy słów kluczowych) lub danych asocjacyjnych identyfikowanych słownikowo;
porządek mnogościowy dla danych złożonych, które różnią się liczbą elementów.
Sposób uporządkowania może być rosnący lub malejący, a rodzaje porządków można łączyć. Na przykład dla niektórych struktur danych zastosujemy najpierw porównywanie liczby ich elementów (porządek mnogościowy), a gdy będzie ona równa, skorzystamy z algorytmu bazującego na porządku liczbowym lub leksykograficznym kolejnych elementów z obu zestawów.
Komparator domyślny, compare
Domyślnym komparatorem w języku Clojure jest funkcja compare
. Przyjmuje ona dwa
argumenty, którymi powinny być wartości przeznaczone do porównania, a zwraca liczbę
całkowitą określającą jeden z trzech możliwych stanów wyjściowych:
- -1 lub mniejszą – jeżeli pierwszy element powinien poprzedzać drugi;
- 0 – jeżeli pozycja elementów powinna być taka sama;
- 1 lub większą – jeżeli pierwszy element powinien następować po drugim.
Pierwszy argument musi być instancją typu implementującego interfejs
Comparable
.
Funkcja compare
nie potrafi porównywać wartości różnych typów. Próba porównania
takich danych spowoduje zgłoszenie wyjątku java.lang.ClassCastException
. Wyjątkiem
jest tu wartość nieustalona nil
, która zawsze będzie uznawana za następującą po
porównywanej wartości (chyba, że tą drugą też będzie nil
– wtedy będziemy mieli do
czynienia z równą pozycją).
Domyślny komparator potrafi porównywać między innymi: wartości numeryczne,
łańcuchy znakowe, pojedyncze znaki, słowa kluczowe,
symbole, wektory, zbiory, mapy, obiekty File
oraz URI
i inne obiekty,
które implementują Comparable
.
Użycie:
(compare x y)
.
Zauważmy, że w liniach 5–7 poszczególne elementy wektorów są porównywane z zastosowaniem zasad porządku liczbowego, z kolei w liniach 9–11 mamy do czynienia z porządkiem mnogościowym (porównywana jest liczba elementów), ponieważ rozmiary wektorów są różne.
Programiści innych języków programowania mogą zdziwić się takim algorytmem
porównywania, ponieważ dominującą tendencją jest stosowanie porządku liczbowego lub
leksykograficznego, nawet gdy porównywane struktury są różnej liczebności. Jeżeli
potrzebujemy w taki właśnie sposób porównywać kolekcje, możemy uzupełnić mniejszą
z nich pustymi elementami (np. wartościami nil
) lub stworzyć własny komparator.
Tworzenie komparatorów
Komparator domyślny może nie spełniać naszych oczekiwań w kwestii porównywania wartości, np. porównywać zechcemy dane odmiennych typów. W takich przypadkach warto rozważyć utworzenie własnego komparatora.
Właściwości komparatorów
Zanim przystąpimy do definiowania odpowiedniej funkcji powinniśmy zadbać o to, aby nasz komparator spełniał ogólne założenia funkcji porównującej, a także zdecydować o jego charakterystycznych cechach.
Stanowość
Komparator trójstanowy powinien zwracać liczbę całkowitą (32–bitową wartość ze
znakiem typu java.lang.Integer
), a dwustanowy wartość
logiczną true
lub false
.
W przypadku komparatora trójstanowego należy sprawić, aby funkcja zwracała wartość:
- -1 lub mniejszą, gdy pierwszy element powinien być umieszczony wcześniej;
- 0 dla przypadku, gdy elementy są równe pod względem pozycji;
- 1 lub większą, gdy pierwszy element powinien być umieszczony później.
W przypadku komparatora dwustanowego należy założyć, że funkcja powinna zwracać:
true
, gdy pierwszy element powinien być umieszczony wcześniej;false
, gdy pierwszy element powinien być umieszczony później.
Wartość false
w komparatorze dwustanowym może komunikować też, że pozycja
porównywanych elementów powinna być zachowana.
Odwracalność
Komparator musi być odwracalny, tzn. gdy zamienimy miejscami wartości przekazywanych mu argumentów, powinniśmy uzyskać odwrotne decyzje odnośnie kolejności komunikowanej zwracaną wartością.
Porządek liniowy
Ważne jest, aby zbiór możliwych wartości, które będą porównywane z użyciem komparatora cechował porządek liniowy, tzn. taki, gdzie każdy element może być porównany z innym z tego zbioru. Nie wyklucza to oczywiście porównywania danych różnych typów pod warunkiem, że w funkcji komparatora zastosujemy koercję w odniesieniu do przyjmowanych argumentów.
Duplikaty kluczy
Budując komparator dla sortowanych kolekcji asocjacyjnych (np. sortowanych map czy zbiorów), należy unikać stanu wyjściowego, który komunikuje, że pozycje elementów są równe, ponieważ spowoduje to, że oba porównywane elementy zostaną umieszczone w kolekcji wyjściowej, co może być wbrew warunkowi unikatowości kluczy.
„Lub równe”
Nie powinno stosować się dwustanowych komparatorów, których decyzje bazują na operatorze relacyjnym „mniejsze lub równe” bądź „większe lub równe”, ponieważ prowadzić to może do nieprzewidzianych rezultatów podczas porównywania danych (m.in. wspomniane wcześniej duplikaty w przypadku struktur asocjacyjnych):
Interfejs Comparator
Funkcja porównująca powinna implementować interfejs
java.util.Comparator
. W Clojure każda funkcja definiowana z użyciem fn
,
defn
czy lambda-wyrażenia jest wyposażona w ten interfejs. Dla funkcji
utworzonych w inny sposób (np. metod pochodzących z Javy) można użyć
funkcjonału comparator
.
Funkcja comparator
Funkcja comparator
służy do generowania obiektów typu funkcyjnego wyposażonych
w interfejs Comparator
. Przyjmuje ona jeden argument, którym powinien być funkcyjny
obiekt, a zwraca funkcję, która wywołuje podany predykat i na podstawie jego
rezultatu zwraca wartość -1, 0 lub 1.
Funkcja ta przydaje się kiedy:
chcemy jako komparatora użyć obiektu funkcyjnego, który z jakichś przyczyn nie implementuje
java.util.Comparator
;chcemy stworzyć komparator trójstanowy na bazie funkcji, która zwraca wartość logiczną.
Użycie:
(comparator predykat)
.
W istocie funkcja comparator
jest bardzo prosta. Jej ciałem jest następujące
wyrażenie (pred
to przekazany jako argument predykat):
Widzimy więc, że dla predykatu dwuwartościowego (zwracającego np. true
lub false
)
będziemy w przypadku równej kolejności wartości mieli do czynienia z jego dwoma
wywołaniami. Gdy predykatem jest funkcja wyrażająca relację „mniejsze niż”, to
pierwszy warunek nie zostanie spełniony, drugi również, a zwrócona będzie wartość
domyślna 0.
Własna funkcja porównująca
Spójrzmy co się stanie, gdy spróbujemy posortować wektor zawierający klucze, symbole i liczby:
Otrzymaliśmy komunikat o wystąpieniu wyjątku ClassCastException
, który informuje
nas, że nie można było rzutować liczby wyrażonej obiektem klasy Long
do słowa
kluczowego (obiektu klasy Keyword
). Gdy wywołamy funkcję pst
, przekonamy się, że
miejscem, w którym doszło do usterki, był kod funkcji compare
, czyli wbudowanego
komparatora. Przyczyną problemu był fakt, że funkcja ta nie potrafi porównywać danych
różnych typów.
Funkcja compare-on-steroids
Spróbujmy stworzyć taki komparator, który byłby w stanie pomóc funkcji sort
w uszeregowaniu zaproponowanego zestawu danych.
Założenia:
Jeżeli mamy do czynienia ze słowem kluczowym lub symbolem, dokonujemy konwersji do łańcucha znakowego.
Jeżeli łańcuch znakowy reprezentuje wartość numeryczną, dokonujemy konwersji do typu, który tę wartość wyraża.
Jeżeli łańcuch znakowy nie reprezentuje wartości numerycznej, a jest porównywany z innym łańcuchem, dokonujemy porównania leksykograficznego (słownikowego).
Jeżeli porównujemy typ numeryczny z nienumerycznym, numeryczny powinien być zawsze pierwszy.
Wewnętrznie skorzystamy z
compare
, aby dokonywać porównań tych samych typów.
Użytkowanie komparatorów
Wywoływanie bezpośrednie
Funkcję porównującą możemy wywoływać bezpośrednio, np. w celu uwarunkowania wykonywania się programu. Zaletą użycia komparatora może być w tym przypadku możliwość pracy z danymi różnych typów.
Przekazywanie do funkcji
Najczęstszym sposobem korzystania z komparatorów jest ich przekazywanie do funkcji wyższego rzędu, gdzie będą wywoływane w celu porównania kolejnych elementów kolekcji lub sekwencji.
Funkcja sortująca
Weźmy za przykład wbudowaną funkcję sortującą sort
, której przekazać możemy
komparator. Będzie on wywoływany podczas szeregowania elementów w wynikowej
sekwencji.
Funkcjonały korzystające z komparatorów
W Clojure pewna liczba funkcji wyższego rzędu jako jeden z argumentów przyjmuje komparator. Są to głównie funkcje związane z sortowaniem elementów kolekcji i/lub sekwencji na żądanie lub z tworzeniem tzw. sortowanych kolekcji (zachowujących określony porządek podczas następczego dostępu):
sort
– sortuje elementy,sort-by
– sortuje elementy wg. wartości zwróconej przez funkcję,sorted-set-by
– tworzy zbiór sortowany,sorted-map-by
– tworzy mapę sortowaną,subseq
– tworzy sekwencję z zakresu elementów,rsubseq
– tworzy odwróconą sekwencję z zakresu elementów.