3.3 Elementy biblioteki STL
wprowadzenie
Standardowa Biblioteka Wzorców (Standard Template Library, STL) początkowo
powstała jako niezależna biblioteka. Jej pierwotnymi autorami byli Alexander
Stepanov (z Hewlett-Packard) i Meng Lee (z Silicon Graphics). Ewoluowała ona
również wraz z właściwościami C++, w wyniku czego nie od razu np. pojawiły
się w niej elementy wykorzystujące częściową specjalizację. Z aktualnej jej
wersji również nie wszystkie elementy zostały wniesione do biblioteki
standardowej C++. Biblioteka ta owszem, powstała dość późno, ale takoż nie
tak dawno C++ dojrzał do możliwości jej zaimplementowania. Jak sama nazwa
wskazuje, biblioteka ta jest bazowana na wzorcach, a nie na właściwościach
obiektowych. Dzięki czemu możliwe jest używanie jej właściwości z użyciem
obiektów wszystkich typów, również wbudowanych, gdyż elementy bazowe
biblioteki są zdefiniowane za pomocą konceptów, a nie klas (pojęcie to
objaśnię za chwilę).
STL zatem dostarcza struktury generycznych komponentów C++, które współpracują
bez powiązań. Np. iteratory są podzielone na kategorie ujęte w konceptach, ale
iteratorem jest również np. regularny wskaźnik (i to iteratorem dość
rozbudowanej kategorii!). Na tych wskaźnikach (jak i na wszelkich innych typach
zdefiniowanych przez użytkownika) mogą operować wszystkie algorytmy STL. Celem
twórców tej biblioteki jest przede wszystkim elastyczność, wydajność i
adaptacyjność poszczególnych jej komponentów.
Postaram się opisać tą bibliotekę niezbyt szczegółowo, choćby dlatego, że
mnóstwo w niej jest twardej, suchej teorii. Nie jest jednak skomplikowana, ani
trudna do zrozumienia, wymaga tylko wprowadzania wielu nowych pojęć.
Szczegółowy opis tej biblioteki można znaleźć na
stronie SGI o STL.
Biblioteka zawiera pięć najważniejszych rodzajów komponentów:
- algorytm: definiuje procedurę obliczeniową
- zbiornik (obiekt zbiorczy): zarządza zestawami obiektów
- iterator: dostarcza algorytmowi założeń do poruszania się wewnątrz
obiektu zbiorczego.
- funkcjonał: opakowuje funkcję w obiekt do używania przez inne komponenty
(funkcjonały z kolei mają swoje specjalizacje jak np. orzeczniki)
- adaptator: adaptuje komponent (dowolnego rodzaju) dla dostarczenia
innego interfejsu.
Ponieważ uniwersalizuje się dostęp do elementów zbiorników, iterowanie po
nich, czy wiele innych rzeczy, można zatem np. dostarczyć jedną funkcję, która
będzie pracować z każdym typem obiektu i każdym zbiornikiem (pracuje po prostu
na zakresie elementów, a co ten zakres stanowi to już jej nie obchodzi), który
spełnia odpowiednie wymagania (określone konceptem). Cała biblioteka jest
przede wszystkim zbiorem:
- reguł, wedle których należy tworzyć komponenty
- podstawowych komponentów utworzonych wedle tych reguł
Użytkownik może sobie zatem dodać własny komponent dowolnego rodzaju i - jeśli
utworzy go zgodnie z regułami i konceptami STL-a - będzie on współpracował
zarówno z tworami użytkownika, jak i z pozostałymi komponentami STL-a.
Koncepty definiuje się słownie i najbardziej ogólnie; nie mówimy zatem
"klasa X ma mieć metodę operator++()", lecz "dla dowolnego x
typu X zdefiniowano ++x" (nie wyszczególnia się, czy jest to funkcja
składowa, czy zwykła). W konceptach podaje się wyrażenia oznaczające
założenia, jakie dany typ ma spełniać; dla każdego zestawu wymagań jest podana
tablica, która specyfikuje zestaw prawidłowych wyrażeń i ich semantyki
(tablice te są w oryginalnej dokumentacji; ja ich tutaj nie będę przytaczać).
Oto dwa przykłady. Jeśli trzeba scalić dwie tablice do jednej, można to
wykonać w ten sposób:
int a[1000];
int b[2000];
int c[3000];
...
merge( a, a + 1000, b, b + 2000, c );
Można też np. scalić wektor i listę do liniowego niezainicjalizowanego obszaru
pamięci, można to zrobić tak:
vector<Employee> v;
list<Employee> l;
...
Employee* c = allocator<Employee>().allocate( v.size() + l.size() );
merge( v.begin(), v.end(), l.begin(), l.end(),
raw_storage_iterator<Employee*, Employee>( c ) );
gdzie begin() i end() to funkcje składowe typów zbiorczych, które zwracają
wartości odpowiednich kategorii iteratorów, a raw_storage_iterator to
adaptator, który pozwala algorytmom wstawić rezultat wprost do
niezainicjalizowanego obszaru pamięci wywołując odpowiedni konstruktor
kopiujący. W tym przykładzie, pierwszy argument funkcji allocate jest ilością
przydzielanych elementów (a nie ilością przydzielanych bajtów!).
Iterować można także po strumieniach (istream_iterator, ostream_iterator).
Wpisanie wartości przez taki iterator powoduje wysłanie jej do strumienia,
podobnie odczytanie - pobranie. Wszytkie algorytmy pracujące ze zbiornikami
pracują w stylu "Stąd - dotąd", czyli z określeniem początku i końca
ciągu.
Podstawowe komponenty
Do podstawowych komponentów zaliczamy operatory i pary.
Biblioteka STL definiuje symetryczne operatory relacji dla każdego typu, dla
którego zdefiniuje się ich przeciwieństwo. Nie będę się skupiał na
szczegółach, nadmienię więc tylko, że zdefiniowano je w <functional>
i definiuje się następujące operatory:
- `!=' odwraca `=='
- `>' używa `<', tylko w odwrotnej kolejności
- `<=' odwraca `>'
- `>=' odwraca `<'
Para (std::pair) z kolei jest to struktura z dwoma polami (first i second)
dowolnych typów (podawanych jako parametry wzorca). Dostarcza się też dla nich
operatory relacji == i <. Typy mogą być wydedukowane, tylko w tym celu
należy użyć funkcji make_pair i podać dwa obiekty, które należy sparować.
Obie te rzeczy są zadeklarowane w <utility>.
Co do operatorów: ich definicje zostały przeniesione do przestrzeni nazw
std::rel_ops, zatem trzeba zaimportować tą przestrzeń nazw, żeby ich używać.
Jest to wymóg stantardu (w przypadku gcc tak jest dopiero od wersji 3.0).
Zwracam uwagę, że jest to też niebezpieczne, bo importując tą przestrzeń
nazw do danej jednostki kompilacji, udostępnia się te operatory dla WSZYSTKICH
typów bez wyjątku!
Iteratory
Iteratory są podobne do wskaźników użytych do przemieszczania się po
tablicy. Mają zdefiniowany operator *, a także określa się dla nich typ
wartości (parametr wzorca będący typem), można je porównywać i dlatego
definiuje się również typ odległości iteratora (stara wersja dopuszczała różne
modele pamięci i w związku z tym np. różne typy odległości; obecna STL zakłada,
że program korzysta tylko z jednego modelu pamięci, więc typ odległości jest
zawsze ptrdiff_t). Zależnie od tego, jakie operacje iteratory udostępniają,
mamy pięć kategorii iteratorów:
- wejściowe (input); pozwalają na odczyt z iterowanego obiektu
- wyjściowe (output); pozwalają na zapis w iterowanym obiekcie
- postępowe (forward); pozwalają pobrać "następny" element
- dwukierunkowe (bidirectional); pozwalają pobrać "poprzedni"
element
- swobodnego dostępu (random access); pozwalają przeskoczyć kilka
elementów do przodu lub do tyłu
Hierarchia tych kategorii wygląda następująco:
input, output -> forward -> bidirectional -> random_access
Iteratory mogą być zmienialne lub stałe (stałe nie pozwalają modyfikować
wskazywanego obiektu); zależy od tego wartość zwracana przez operator*, która
może być referencją lub referencją do stałej. Stałe iteratory nie spełniają
wymagań iteratorów wyjściowych!
Głębszych wyjaśnień wymagają określenia "następny/poprzedni" element
i skok o kilka elementów. Iteratory bowiem przede wszystkim służą do tego,
żeby poruszać się wewnątrz zbiornika. Do tego potrzebne są reguły opisujące
ZAKRESY.
Wiesz zapewne z codziennego życia, że jeśli chcesz podać jakiś zakres (np. że
nie będzie cię od wtorku do niedzieli), to zawsze jest problem ze zrozumieniem
się, czy kraniec górny to element, który do tego zakresu wchodzi, czy nie
(czyli na to ktoś Ci zapewne odpowie: "Czy do niedzieli włącznie?").
Problem bowiem polega na tym, że granice wyznaczające zakres muszą być
umieszczone POMIĘDZY elementami, ale żeby pokazać ową granicę, musimy użyć
wskazania na elementy, a nie na "pomiędzy-elementy" (nie powiesz
przecież "nie będzie mnie od pomiędzy poniedziałkiem a wtorkiem a
pomiędzy sobotą a niedzielą"). Zatem to całe "pomiędzy" należy jakoś
zdefiniować. I to właśnie STL definiuje ZAWSZE (!) jako "pomiędzy
elementem wskazanym, a poprzednim elementem", czyli ta granica jest
zawsze PRZED wskazanym elementem.
Co jednak, kiedy chcemy podać jako zakres "cały zbiornik", albo po
prostu "aż do ostatniego elementu"? Gdybyśmy wskazali na ostatni
element jako górny zakres, to ponieważ zakres kończy się przed wskazanym
elementem, nie bylibyśmy w stanie w żaden sposób zawrzeć w zakresie ostatniego
elementu. Biblioteka STL zatem definiuje taką wartość iteratora, która
wskazuje za ostatni element zbiornika. Np. jeśli mamy taki zbiornik:
int tab[20];
stworzymy dla niego iterator:
int* i;
i przypiszemu mu taką wartość:
i = tab + 20;
to taka wartość iteratora `i' nazywa się wartością ZA-KOŃCOWĄ. Wartość
za-końcowa służy przede wszystkim do sprawdzenia, czy "już się dojechało do
końca". Koncept STL dla zbiorników (będzie to dokładniej opisane niżej)
zakłada, że iterator początkowy oraz końcowy (końcowy wskazuje na ten właśnie
tzw. za-koniec) są pobierane odpowiednio metodami begin() i end(). W tym
wypadku, jeśli do wskaźnika przypiszemy tablicę, to uzyskujemy iterator
begin(), a jeśli do niego dodamy rozmiar zbiornika – end().
Iteratory mają w założeniu wskazywać na jakiś element zbiornika. Jednak to
są obiekty, jak inne. Aby zatem wskazywały na coś, trzeba im odpowiednią
wartość przypisać. Poza tym, iterator zwykle nie ma pojęcia o zbiorniku, do
którego wskazuje (może być coś takiego dodane przez implementację szczególnie
dla "wersji debug", ale to już inna rzecz). Aby można było wykonywać na
iteratorach określone operacje, wartość iteratora musi spełniać określone
warunki, a w szczególności mieć (lub nie mieć) określone właściwości.
"Zakońcowość" to jedna z nich. Poza tym, wartości iteratorów mogą być też:
- WYŁUSKIWALNE, jeśli dla przyjętej przezeń wartości operator * ma określone
zachowanie (w przypadku wskaźnika oznacza to, że musi być mu przypisany jakiś
konkretny obiekt).
- WŁAŚCIWE, jeśli – najprościej mówiąc – wskazują na taki element,
jakiego się na tym miejscu spodziewamy. Iterator musi być właściwy, żeby
operacje na nim były poprawne. Oczywiście w niektórych wypadkach wartość
iteratora jest znana, mimo że teoretycznie nie zostanie on unieważniony i można
z tego skorzystać (ale pod warunkiem, że nie wynika to z założeń
implementacyjnych, takich np. jak sposób rozmieszczania obiektów)
- OSOBLIWE (to pojęcie jest już chyba znane, jeśli nie - wróć do rozdziału o
deklaracji zmiennych :*), jeśli nie zostały zainicjalizowane lub zostały
unieważnione w sposób uniemożliwiający jasne określenie tego, na co on
wskazuje. W ogólności, iterator osobliwy nie jest związany z żadnym zbiornikiem
i odwołanie się do takowego w jakichkolwiek okolicznościach jest niepoprawnym
kodem.
Wyjaśnię jeszcze dokładniej, o co chodzi z "niewłaściwymi" wartościami (i czym
się ona różni od osobliwej). Przede wszystkim oczywiście każda wartość osobliwa
jest niewłaściwa. Iterator staje się niewłaściwy zawsze po wykonaniu dowolnej
operacji wewnątrz zbiornika, która zmieniła jego liczebność (tzn. w tym również
wymieniła jeden element na inny). Czasem po takiej operacji iterator staje się
osobliwy i wtedy trzeba go tak czy siak ponownie zainicjalizować. Jednak nie
zawsze; jesteśmy w stanie w niektórych przypadkach przewidzieć, na co będzie
ten iterator nam wskazywać. Przykładowo operacja zastępowania jednego zakresu
drugim w ogólności zmienia liczebność zbiornika, ale jeśli wielkość zakresu
usuwanego i wstawianego jest taka sama, to iterator nie powinien zmienić
elementu wskazywanego (pod warunkiem, oczywiście, że nie należy do usuwanego
zakresu). To oznacza, że nie stał się on osobliwym, ale mimo wszystko wykonano
operację unieważniającą iterator i dlatego stał się on nieważny.
Inaczej mówiąc, wartość niewłaściwa powstaje po unieważnieniu iteratora, a to,
czy ona jest również osobliwa, wynika z niektórych szczególnych warunków.
Często jest nawet wyłuskiwalna (jeśli nie stała się przypadkiem wartością
za-końcową). Zauważ zatem, że wartość wyłuskiwalna nie musi być właściwa.
Dzieje się tak wtedy, gdy iterator wskazuje na obiekt, ale nie taki, jakiego
się na tym miejscu spodziewamy. Niedługo dowiesz się zresztą, że niektóre
algorytmy mogą spowodować małe zamieszanie wewnątrz zbiornika, wskutek czego
iterator, który był WŁAŚCIWY przed operacją, po niej może zostać UNIEWAŻNIONY,
tzn. stanie się niewłaściwy (nie zmieniając wartości i pozostając
wyłuskiwalnym!).
Właściwości iteratorów mają ze sobą też pewne związki:
- wartości za-końcowe nie są wyłuskiwalne
- wartości wyłuskiwalne i za-końcowe są zawsze nieosobliwe
Zauważ, że – podobnie jak pusty wskaźnik – wartość za-końcowa jest
niewyłuskiwalna, ale za to jest właściwa. Żeby dowiedzieć się, po co dokładnie
ona jest, poznajmy następną właściwość iteratorów: iterator `j' nazywamy
OSIĄGALNYM z `i', jeśli istnieje skończony ciąg wywołań operatora ++ dla
`i', który spowoduje, że i == j. Pamiętaj oczywiście, że nie oznacza to, iż
można inkrementować jeden iterator dotąd, aż może w końcu trafi w odpowiednie
miejsce w pamięci :). Jeśli iterator j jest osiągalny z i, to oznacza to
również, że:
- oba iteratory mają wartość nieosobliwą (jak zresztą wiesz, jest to
podstawowe założenie dla wszystkich bez wyjątku obiektów, jeśli w ogóle chce
się dla nich zakładać coś jeszcze) i właściwą (czyli uzyskaną z jakiegoś
zbiornika)
- OBA iteratory wskazują na węzły uzyskane z TEGO SAMEGO zbiornika
Zwracam bardzo szczególną uwagę na ten ostatni punkt, żeby jakiś mądrala nie
zaczął zadawać głupich pytań w stylu "czy jeśli porównam za-koniec jednej
tablicy z początkiem drugiej i wyjdzie równe, to znaczy, że mogę się odwołać
do wyłuskanego iteratora za-końcowego?". Przede wszystkim bowiem, dwóch
iteratorów nie wolno porównywać, jeśli jeden z nich nie jest osiągalny z
drugiego (w jakimkolwiek kierunku). "Nie wolno" to znaczy, że wynik porównania
niczego nie oznacza i uzależnianie jakiejś operacji od tego wyniku jest
niepoprawnym kodem.
W przypadku osiągalności można też mówić o "wstecznej osiągalności", czyli
od drugiego do pierwszego można przejść operatorem "--".
I tak doszliśmy do pojęcia ZAKRESU. Zakres jest to taka para iteratorów `i'
i `j', że iterator `j' jest osiągalny z `i' i oznaczają początek i koniec
pewnego ciągu elementów. Pamiętaj, że iterator wyznaczający koniec NIE WCHODZI
do zakresu. Zauważ więc, że:
- Jeśli ++i == j, to [i, j) zawiera tylko jeden element, mianowicie
`*i'.
- Zakres [i, i) to zakres pusty.
Dlaczego? Dlatego, że jeśli iterator
`x' iteruje po zakresie [i, j), to najpierw przybiera wartość `i', a
potem (przed jakąkolwiek operacją!) sprawdzany jest warunek `j == x'; jego
spełnienie oznacza, że osiągnięto koniec zakresu. Zakres bardzo przypomina to
najbardziej typowe użycie instrukcji for:
for ( int i = 0; i < 10; i++ )
Tutaj właśnie zmienna sterująca `i' przechodzi zakres [0, 10).
Dla iteratorów istnieje jeszcze jedna możliwość (ponad to, co dotyczy
wskaźnika) stania się niewłaściwym. Otóż załóżmy, że iterator wskazuje na
obiekt będący na początku zbiornika. Jeśli algorytm spowodował takie
zamieszanie, że ten wskazany element nagle znalazł na końcu zbiornika, to
iterator staje się niewłaściwy. Zauważ – nie staje się niewłaściwy
iterator w sensie wskazania na obiekt (czyli dla iteratora i wartość &*i
pozostaje właściwa), czyli innymi słowy pozostaje wyłuskiwalny, ale staje się
takim jako sam iterator (iterator utracił jakąś posiadaną właściwość). Wyobraź
sobie np., że z iteratorów `i' i `j' drugi był osiągalny z pierwszego. I teraz
algorytm przestawił iterator `i' w inne miejsce, w wyniku czego teraz `i' jest
osiągalne z `j' (to jest możliwe w przypadku listy). O ile zatem para
( i, j ) mogła być zakresem przed taką operacją, o tyle po niej
już nie może. Czasem się też może zdarzyć, że w zbiorniku o zwartym układzie
elementów (np. wektor) usunięto element ze środka. Wtedy na pozycję, na którą
wskazywał wcześniej dany iterator, wjechał już zupełnie inny element.
Oczywiście takie iteratory należy traktować tak samo, jak iteratory osobliwe.
Czyli nie używać :). Możemy co prawda stwierdzić, że iterator taki pozostaje
wyłuskiwalny, ale jeśli wiara ta opiera się zbytnio na implementacji, to znaczy
że zawierzanie takiej regule jest "niepoprawnym postępowaniem" :).
W ogólności zatem, należy wiedzieć, które operacje podstawowe na iteratorach i
zbiornikach powodują ich unieważnienie. Na przykład usunięcie elementu ze
środka zbiornika powoduje unieważnienie odpowiednich iteratorów w zależności od
rodzaju zbiornika. Dla listy unieważniony zostanie tylko iterator wskazujący na
usuwany element. Dla wektora - wszystkie od usuniętego aż do końca zbiornika.
Dla dwukolejki (deque) i zbiorników sortowanych - wszystkie w całym zbiorniku
(zbiorniki sortowane są implementowane - przynajmniej w najpopularniejszej
implementacji SGI - jako drzewa zrównoważone i po operacji zmieniającej
liczebność zbiornika są ponownie równoważone).
Omówię teraz szczegółowo poszczególne kategorie iteratorów.
- Iteratory wejściowe
Dla iteratorów wejściowych `i' prawidłowy jest przede wszystkim odczyt
wartości wyrażenia `*i'. Definiuje się też operator ++, ale nie stawia mu się
żadnych konkretnych wymagań, poza tym, że *i++ ma mieć taki sam efekt, jak
gdyby `i' było wskaźnikiem (ale tylko w ogólności; nie wymaga się np., żeby
r == s implikowało ++r == ++s). Również poprzednie kopie wartości *r nie muszą
być osiągalne, zatem należy pamiętać, że operujące na takich iteratorach
algorytmy muszą być jednokrokowe (nie da się przejść tej samej wartości
iteratora dwa razy!). Właściwie to nawet nie powinno się używać bezpośrednio
wyrażenia `*i', ale wyłącznie `*i++' (implementacja często odwala całą
rzeczywistą robotę w operatorze `*', natomiast `++' nie robi zupełnie nic). Nie
owijając w bawełnę, przykładem takiego iteratora jest istream_iterator.
- Iteratory wyjściowe
Do nich odnosi się właściwie dokładnie to samo, co do wejściowych z tą tylko
różnicą, że wyrażenie `*i' pozwala tylko na zapis do tak wskazanej
l-wartości. W szczególności należy spełnić takie dwa warunki:
- należy coś wpisać przez iterator przed jego zinkrementowaniem (++), czyli
ciąg ` i++; i++; ' nie jest prawidłowym kodem
- dowolna wartość iteratora wyjściowego może mieć najwyżej jedną aktywną
kopię, czyli ` i = j; *i++ = a; *j = b; ' nie jest prawidłowym kodem
Modelem takiego iteratora jest ostream_iterator.
- Iteratory postępowe
Od tego momentu zaczynają się iteratory, które - poza dodatkowymi
właściwościami - mogą być albo wejściowo-wyjściowe (jeśli są zmienialne) albo
tylko wejściowe (jeśli są stałe). Tu będą opisywane właściwości iteratorów,
które pozwalają na poruszanie się wewnątrz ciągów, zatem kwestia zapisu i
odczytu jest tutaj pomijana, poza tym, że dla każdego zbiornika definiuje się
zawsze co najmniej iterator i const_iterator.
Iteratory postępowe pozwalają na poruszanie się wewnątrz zbiornika. Zbiornik
taki musi być wedle konceptu "zbiornik postępowy" (np. lista
jednokierunkowa), tzn. taki, który dla dowolnego elementu wskazanego przez
iterator jest w stanie podać następny element poprzez użycie dla iteratora
operatora ++. Nie ma żadnych restrykcji co do ilości aktywnych
kopii oraz r == s implikuje ++r == ++s.
- Iteratory dwukierunkowe
Iterator ten pozwala na poruszanie się wewnątrz zbiornika do przodu i do tyłu.
Zbiornik taki - jak się można domyślać - musi spełniać koncept "zbiornik
dwukierunkowy", a dokładnie spełniać koncepty "zbiornik
postępowy" (udostępniający iteratorowi operator ++) i "zbiornik
odwracalny" (udostępniający operator --, pozwalający przejść na
poprzedni element); modelem takiego zbiornika jest lista dwukierunkowa, a także
tablica. Dodatkowo oczywiście r == s implikuje --r ==
--s, a także spełnione jest --(++r) == r.
Warto też wspomnieć, że dla zbiorników dwukierunkowych (bo zbiorników
wyłącznie odwracalnych przecież nie ma:*) definiuje się jeszcze iterator
odwrócony. Polega to na tym, że za pomocą operatora ++ porusza się on... do
tyłu. Nie jest to wcale takie bezsensowne – proszę nie zapominać, że
większość algorytmów pracuje na zakresie oznaczonym początkiem i końcem,
przechodząc do następnego elementu operatorem ++. Iterator taki umożliwia tym
algorytmom pracować w przeciwnym kierunku.
- Iteratory swobodnego dostępu
Iterator ten pozwala na dowolne poruszanie się wewnątrz zbiornika i
indeksowanie elementów względem iteratora. Do takich iteratorów można
normalnie dodawać wartości typu iterator::difference_type aby przeskoczyć
odpowiednią ilość elementów. Iteratory te mogą poruszać się po zbiorniku
spełniającym koncept "zbiornika swobodnego dostępu" (np. tablicy) i
zdefiniowano dla nich operatory + i -.
Biblioteka dostarcza również funkcje nazwane `advance' i `distance'. Dla
iteratorów swobodnego dostępu używają operatorów + i -, a dla wejściowych,
postępowych i dwukierunkowych – ++. Funkcje `advance' i `distance'
pozwalają odpowiednio przejść od jednego iteratora do drugiego, oddalonego o
pewną wartość typu iterator::difference_type (iterator jest podany przez
referencję) i zmierzyć odległość pomiędzy dwoma podanymi iteratorami (stara
wersja wpisywała wynik do trzeciej wartości podanej przez referencję; nowa,
zwracająca wynik jest zależna od właściwości zwanej częściową specjalizacją
wzorców i tym samym wprowadzonej w miarę niedawno własności zwanej
iterator_traits).
Pliki nagłówkowe: <algorithm>, <iterator>
Wedle tych podanych kategorii iteratorów istnieją iteratory dla konkretnych
zbiorników. W zbiornikach STL-a są wewnątrz klas zdefiniowane typy będące
iteratorami przeznaczonymi do pracy z tym zbiornikiem:
- iterator
- const_iterator
- reverse_iterator
- const_reverse_iterator
Są to oczywiście jedynie wewnętrzne definicje typów (przez typedef); ich
rzeczywiste klasy są już szczegółem implementacyjnym biblioteki. Nie muszę
chyba dodawać, że reverse_iterator istnieją tylko w zbiornikach odwracalnych.
Będzie zaraz o nim szerzej.
Przede wszystkim jednak mamy dostępne ogólne klasy dla iteratorów:
- istream_iterator (dla strumienia wejściowego)
- ostream_iterator (dla strumienia wyjściowego)
Oraz ponadto następujące klasy będące adaptatorami iteratorów:
- front_insert_iterator - wstawiający zawsze z przodu
- back_insert_iterator - wstawiający zawsze z tyłu
- insert_iterator - wstawiający zawsze na określonej pozycji
Te iteratory specjalizuje się iteratorem zbiornika, a inicjalizuje się
bezpośrednio zbiornikiem, insert_iterator'owi dodatkowo trzeba podać pozycję i
ten jako jedyny może się przemieszczać. Zazwyczaj nie używa się ich
bezpośrednio, lecz za pomocą pomocniczych funkcji front_inserter,
back_inserter i inserter. Np. jeśli chcemy skopiować tablicę do
listy, która jest pusta, nie możemy napisać po prostu:
copy( tab, tab+20, ls.begin() );
gdyż spowodowałoby to wylot. Tak można zrobić tylko gdy lista zawiera już 20
elementów, a owa operacja spowoduje ich nadpisanie. Jeśli chcemy te elementy
dorzucić do listy, należy napisać:
copy( tab, tab+20, back_inserter( ls ) );
- reverse_iterator
- reverse_bidirectional_iterator
Te adaptatory należy konkretyzować iteratorem; powstały iterator przy pomocy
operatora ++ przechodzi na poprzedni element (bidirectional dodatkowo
operatorem -- na następny). Zbiorniki spełniające koncept "zbiornika
odwracalnego" posiadają metody rbegin() i rend(), które zwracają właśnie
reverse_iterator (oczywiście rbegin() zwraca ostatni element, a rend() wartość
za-końcową, poprzedzającą pierwszy element). Te identyfikatory są też
definiowane wewnętrznie przez każdy zbiornik dwukierunkowy, zatem np.
reverse_iterator<vector<int>::iterator> jest równoważne
vector<int>::reverse_iterator (a to się chyba pisze krócej :*).
Nie znaczy to bynajmniej, że używanie reverse_iterator<> nie ma
sensu – niektóre algorytmy mogą zechcieć polegać na typie
iteratora, o którym nie wiadomo, czy posiada taki typ, jak reverse_iterator,
czy też po prostu, czy ten iterator posiada operator -- (co w szczególności
okaże się podczas kompilacji ;*).
- raw_storage_iterator
Ten adaptator również konkretyzuje się iteratorem, ale nie każdy iterator może
przyjąć, tylko ten, który może iterować po liniowej pamięci (szczegółowo
zostanie opisany przy alokatorach).
Koncepty
Zajmijmy się więc najmniejszymi elementami biblioteki. Biblioteka definiuje
kilka standardowych konceptów (pomijam tu koncepty iteratorów, inaczej
KATEGORIE, gdyż chyba omówiłem je wystarczająco dokładnie).
- typy danych
- domyślnie-konstruowalny (default-constructible; definiuje konstruktor() )
- przypisywalny (assignable; definiuje = i konstruktor kopiujący)
- porównywalny (equality-comparable; definiuje ==)
- porządkowalny (less-than-comparable; definiuje <)
Dodatkowe wyjaśnienia chyba nie są potrzebne.
- zbiorniki
Zbiorniki posiadają następujące właściwości:
- zbiornik (container)
Jest to obiekt, który zawiera w sobie inne obiekty oraz posiada metody
dające do nich dostęp. Posiada zawsze zdefiniowany wewnątrz typ
`iterator', który służy do iterowania po jego wnętrzu. Typ wartości
zawieranych obiektów musi spełniać koncept "przypisywalny" i
"domyślnie-konstruowalny", sam zbiornik również spełnia koncept
"przypisywalny". Nie zakłada się nic co do porządkowości tych
elementów, ani ile może być aktywnych kopii. Posiada metody: begin, end,
size, max_size, empty i swap. Zbiornik zawłaszcza elementy, tzn. trwanie
elementów nie przekracza trwania zbiornika. Zakres od begin() do end()
jest zawsze zakresem prawidłowym. Jego iterator spełnia koncept iteratora
wyjściowego.
Od siebie jeszcze dodam - jakiś czas temu zdefiniowałem sobie zbiornik
podobny do `list', w którym węzeł był zintegrowany z elementem. Wydaje
mi się, że można go nazwać zbiornikiem (i to nawet dwukierunkowym),
aczkolwiek zbiornik ten wcale nie wymagał, żeby jego elementy były
domyślnie-konstruowalne, a jedynie przypisywalne. A powodem, dla którego
ustalono taką regułę jest to, że np. w liście jest węzeł główny, który
służy do zwracania go przez end(), jest tworzony z użyciem konstruktora
domyślnego (bezargumentowego). Ja węzeł główny zdefiniowałem inaczej, więc
domyślny konstruktor do niczego nie jest tam potrzebny. Podobnie nie
sądzę, żeby używał go konstruktor typu vector. Istnieją tylko niektóre
metody i niektóre algorytmy, które tworzą nowe elementy bez podania
wartości i one potrzebują domyślnego konstruktora, ale przecież nikt nie
musi ich używać.
- zbiornik postępowy (forward container)
Jest to zbiornik, którego elementy są ułożone kolejno. Każdy element ma
swój następny element i zakłada się, że będzie on taki sam przez cały czas
(pod warunkiem, że nie zmieni się celowo kolejności elementów przez
wykonanie operacji UNIEWAŻNIAJĄCEJ iteratory). Jego iterator spełnia
koncept iteratora postępowego. Posiada zatem metody begin() i end().
- zbiornik odwracalny (reversible container)
Jest to zbiornik, którego każdy element ma swój poprzedni (analogicznie
jak powyżej). Jego iterator spełnia koncept iteratora dwukierunkowego.
Posiada dodatkowo metody rbegin() i rend().
- zbiornik swobodnego dostępu (random access container)
Jest to zbiornik, którego iterator spełnia koncept iteratora swobodnego
dostępu. Udostępnia operator [].
Zbiorniki dzielimy następnie na ciągi i zbiorniki asocjacyjne.
- Ciągi:
- ciąg (sequence)
Jest to zbiornik postępowy o zmiennej długości, którego elementy są
ułożone w ścisłym liniowym porządku. Wspiera wstawianie i usuwanie
elementów.
- ciąg przedniego wstawiania (front insertion sequence)
Jest to ciąg, który umożliwia wstawianie elementów na początek zbiornika
oraz pozyskiwanie elementów z początku zbiornika. Posiada do tego stosowne
metody i skróty (front, push_front, pop_front).
- ciąg tylnego wstawiania (back insertion sequence)
Jak wyżej, ale na koniec zbiornika - back, push_back, pop_back.
- Zbiorniki asocjacyjne:
- zbiornik asocjacyjny (associative container)
Jest to zbiornik o zmiennej długości, który pozwala na operowanie
elementami na podstawie kluczy. Pozwala na wstawianie i usuwanie
elementów, ale jest to jakby "worek": nie pozwala np. na
wstawianie elementu na konkretnej pozycji (wynika to stąd, że elementy
wewnątrz zbiornika są posortowane i to zbiornik wybiera, gdzie element należy
wstawić). Należy pamiętać, że elementy takiego zbiornika nie są
"przypisywalne", nie mają zatem iteratora zmienialnego. Zbiornik ten definiuje
typy key_type i value_type, które mogą być takie same.
- zwykły zbiornik asocjacyjny (simple ...)
Jest to zbiornik asocjacyjny, którego elementy same są kluczami, tzn.
key_type i value_type to ten sam typ. Modelem tego konceptu jest `set'.
- parowy zbiornik asocjacyjny (pair ...)
Jest to zbiornik asocjacyjny, którego elementy są parami. Jedna z nich to
key_type, czyli wartość-klucz, a druga to value_type, czyli konkretna
wartość. W tym zbiorniku oczywiście nie można zmieniać elementów, ale
można zapisywać w elemencie wartości (czyli (*i).second jest
przypisywalne). Modelem tego konceptu jest `map'.
- sortowany ... (sorted ...)
Jest to zbiornik asocjacyjny, którego elementy są posortowane rosnąco
wedle wartości klucza. Klucz musi być oczywiście porządkowalny.
Modelami są set i map.
- mieszany ... (hashed ...)
Miałem o tym nie pisać, bo to nie jest koncept standardowy, ale
rozszerzenie SGI. Jest to zbiornik asocjacyjny, którego elementy są
rozmieszczone zgodnie z wartością klucza mieszającego. Modelami
są hash_set i hash_map, które – zaznaczam jeszcze raz –
są tylko rozszerzeniem STL w wersji SGI! (Nadmienię jedynie, że rozszerzenia
SGI są, owszem, dostępne w większości kompilatorów, aczkolwiek nie ma żadnych
reguł co do tego, jak ich używać; np. w gcc należy użyć nagłówka
<ext/hash_map>, a deklaracje są umieszczone w przestrzeni nazw
__gnu_cxx, a nie std.)
- unikalny ... (unique ...)
Jest to zbiornik asocjacyjny, którego klucze są unikalne (tzn. nie mogą w
danym zbiorniku istnieć dwa klucze będące równymi - operator `=='), co
oznacza w praktyce, że dodanie do zbiornika elementu równego jednemu z już
w nim będących nie zostanie wykonane (tzn. zostanie zignorowane).
- wielokrotny ... (multiple ...)
Jest to zbiornik asocjacyjny, w którym dopuszcza się dowolną ilość takich
samych kluczy, co w praktyce oznacza, że po usunięciu ze zbiornika
elementu o podanym kluczu, nadal może on w nim występować!
- funkcjonały
Funkcjonałem nazywamy klasę, której zdefiniowano operator `()'. Obiekt
takiej klasy (obiekt funkcyjny) nazywamy FUNKTOREM. Na rzecz FUNKTORA wywołuje
się metodę `operator()', co oznacza "wywołanie funktora".
Funktory są w efekcie czymś podobnym do wskaźników do funkcji, z tym tylko, że
mają zupełnie inną zawartość: ich pola istnieją tylko na rzecz operacji
wykonywanej przez `()', jak również - jeśli nic takiego nie jest potrzebne
- mogą to być obiekty fikcyjne (tzn. nie zawierające żadnych pól), a wywołanie
funktora może być również rozwinięte. Funktorem może być oczywiście również
wskaźnik lub referencja do funkcji. Np. dlatego, że są to jak najbardziej
obiekty, dla których wyrażenie "f()" (z odpowiednią liczbą argumentów) jest
prawidłowe. Z tym tylko, że jej wywołanie już raczej nie będzie rozwinięte
(tzn. jest raczej mało prawdopodobne, że kompilator dokona rozwinięcia w takim
przypadku).
STL definiuje następujące rodzaje funkcjonałów:
- generator
Inaczej funkcja bez argumentów (w bibliotece boost określa się to jako "funkcja
nularna").
- funkcja unarna
Inaczej funkcja z jednym argumentem.
- funkcja binarna
Inaczej funkcja z dwoma argumentami. Algorytmy STL nie używają nigdzie
więcej, niż dwóch argumentów dla funkcjonałów (a boost owszem).
- generator adaptacyjny
Jest to generator, który zdefiniował wewnątrz typ `result_type', będący
typem zwracanym przez funktor.
- funkcja unarna adaptacyjna
Jest to funkcja unarna, która zdefiniowała wewnątrz typy `result_type' i
`argument_type'.
- funkcja binarna adaptacyjna
Jest to funkcja binarna, która zdefiniowała wewnątrz typy `result_type',
`first_argument_type' i `second_argument_type'.
- orzeczniki:
- orzecznik (predicate)
Jest to funkcja unarna, która ORZEKA o spełnieniu jakiegoś warunku, czyli
sprawdza warunek i zwraca prawdę lub fałsz. Przykładem może być funkcja,
która sprawdza, czy liczba jest dodatnia.
- orzecznik binarny
Jest to funkcja binarna, analogiczna do powyższej. Przykładem może być
funkcja, która sprawdza, czy podane argumenty są sobie równe.
- orzecznik adaptacyjny
Jest to orzecznik, a zarazem funkcja unarna adaptacyjna. Definiuje
argument_type, a result_type jako bool.
- orzecznik binarny adaptacyjny
Jest to orzecznik, a zarazem funkcja binarna adaptacyjna. Definiuje typy
argumentów, a result_type jako bool.
Predefiniowane funkcjonały w STL (odpowiadające operatorom):
- operacje arytmetyczne
- plus (+)
- minus (-)
- multiplies (*)
- divides (/)
- modulus (%)
- negate (-) (unarna)
Zauważ, że nie ma funkcjonałów odpowiadających operatorom jednoargumentowym
~ i * (że nie wspomnę o ++ i --).
- operacje relacji
- equal_to ( == )
- not_equal_to ( != )
- less ( < )
- greater ( > )
- less_equal ( <= )
- greater_equal ( >= )
- operacje logiczne
- logical_and ( && )
- logical_or ( || )
- logical_not ( ! )
Tu drobna uwaga – mimo, że standardowo w C++ operatory te przyjmują typ bool,
to jednak są to nadal wzorce funkcjonałów, czyli można specyfikować dowolny
typ przyjmowanego przez nie argumentu (w efekcie i tak wywoła się podany
operator). Po co – nie wiem. Nie zdefiniowano nawet bool jako domyślnego
parametru wzorca (ale z drugiej strony to jest dobre; nie należy zapominać, że
taki operator ktoś sobie mógł przeciążyć i to niekoniecznie zgodnie z tymi
typami).
Adaptatory funkcjonałów:
- binder1st - tworzy binder, będący funkcją unarną, z funkcji binarnej,
"przywiązując" podany argument jako pierwszy dla tej binarnej; nie należy tego
używać wprost, lecz za pomocą pomocniczej funkcji `bind1st'
- binder2nd - jak powyżej, ale przywiązuje drugi argument
- pointer_to_unary_function, pointer_to_binary_function - pakuje wskaźnik
do funkcji w funkcjonał adaptacyjny. Zarówno unarną jak i binarną funkcję
pakuje się poprzez funkcję pomocniczą ptr_fun(). Funkcja ta jako argument
przyjmuje wskaźnik do funkcji i zwraca stosowny funktor.
- unary_compose, binary_compose - wiąże jedno wywołanie funkcji z drugim;
dostępne przez funkcje pomocnicze compose1 i compose2. Np. compose1( f, g )
tworzy taki funtor h, że wywołanie h( x ) jest równoważne f( g( x ) ).
Podobnie compose2( f, g1, g2 ) tworzy takie h, że h( x ) jest równoważne
f( g1( x ), g2( x ) ). UWAGA!!! Kompozytory nie są częścią biblioteki
standardowej C++; jest to rozszerzenie SGI!
- unary_negate, binary_negate - adaptatory odwracające znaczenie
orzecznika (odpowiednio unarnego i binarnego). Używa się tutaj również funkcji
pomocniczych odpowiednio not1 i not2.
Adaptatory funkcji składowych:
- mem_fun_t - zamienia metodę bezargumentową na funkcjonał. Należy używać
funkcji pomocniczej mem_fun, która jako argument przyjmuje wskaźnik na
metodę. Daje do możliwość podania również metody wirtualnej z klasy
bazowej, co umożliwia połączenie generycznego programowania z
dziedziczeniem i polimorfizmem. Utworzony zostanie funktor unarny, którego
argument jest wskaźnikiem do typu wartości.
- mem_fun_ref_t - jak powyżej; należy stosować mem_fun_ref, a utworzony
funktor jako argument przyjmuje referencję do typu wartości.
- mem_fun1_t - jak mem_fun_t, ale zamienia metodę z jednym argumentem na
binarny funkcjonał. Pierwszym argumentem jest wskaźnik do typu wartości.
- mem_fun1_ref_t - wyjaśnienia chyba zbędne
Oczywiście do wszystkich z nich istnieją także odpowiedniki z const (np.
const_mem_fun_t). To takoż są typy i raczej nie używa się ich bezpośrednio,
lecz poprzez adaptory mem_fun i mem_fun_ref.
Tu drobna ciekawostka. Przedstawione tu adaptory są bardzo ubogie i średnio
wygodne w użyciu. Jeśli kogoś interesuje, polecam zapoznanie się z
boost::bind (www.boost.org). Jest on o tyle
lepszy, że posiada dużo większe możliwości i jest bardziej ogólny.
Nie tylko zastępuje wszystkie powyższe adaptory funkcjonałów, ale także może
być użyty w wielu innych sytuacjach, jak również ilość argumentów funkjonałów
jest ograniczona do 10, a nie do 2, jak w STL-u. Przykładowo, not1( f )
można zapisać też jako bind( logical_not<bool>(), bind( f, _1 ) ).
Do kompozycji, boost posiada również rodzinę funkcji compose; powyższe można
zapisać także jako compose_f_gx( logical_not<bool>(), f ).
Z drugiej jednak strony, zetnknąłem się z sytuacją, gdzie boost::bind nie
chciało działać z pewnych nie do końca jasnych przyczyn, a udało mi się to z
std::bind1st.
Zbiorniki
Zbiorniki oraz uogólnienie sposobów posługiwania się nimi to niemal główny
powód istnienia STL. Wielka szkoda oczywiście, że taka biblioteka nie powstała
wcześniej, wskutek czego twórcy bibliotek specjalistycznych zazwyczaj
definiowali swoje (praktycznie każda biblioteka, która miala swoje początki
przed 1997 rokiem, włączając takie, jak MFC, czy Qt, dysponuje jakimś solidnym
zestawem zbiorników; Qt to nawet posiada replikę STL o nazwie QTL). Żadna z
nich jednak nie mogła się poszczycić taką funkcjonalnością, jak te z STL.
Zresztą wedle słów Bjarne Stroustrupa, to właśnie dlatego stała się biblioteką
standardową.
Istnieją różne rodzaje zbiorników, nawet tej samej kategorii. Sposób
iteracji po nich jest zunifikowany, a różnią się one sposobem implementacji, a
więc de facto szybkością wykonywanych różnych operacji. Np. wektor jest
tablicą, której rozmiar może się zmieniać, jej iteratory są swobodnego dostępu,
ale jej powiększanie i wstawianie elementów w środku ("rozpychanie") jest
liniowego czasu (nie zawsze oczywiście; dla wektora specyfikuje się wielkość
zapasowa, zatem jej powiększanie jest zazwyczaj stałego – amortyzując
– czasu; głównie chodzi tutaj o fragmentowanie pamięci w razie
powiększania tablicy). Inaczej jest z listą, gdzie wstawianie elementu i jej
powiększanie jest stałego czasu, ale jej iteratory są tylko dwukierunkowe.
Należy pamiętać o jeszcze jednej rzeczy: każdemu zbiornikowi można określić
sposób przydzielania pamięci (zostaną one opisane dalej); należy podawać to
zawsze jako ostatni parametr wzorca.
- Ciągi
- `vector'
Ciąg tylnego wstawiania o zmiennej długości i liniowym układzie elementów; jego
iteratory są swobodnego dostępu. Udostępnia - poza standardowymi metodami
ciągów - również operator []. Iterator wskazujący na element wektora po
operacji wstawiania elementu jest unieważniany (chodzi nie tylko o
"przesuwanie" elementów w tablicy; poczytaj o funkcji realloc). Ciekawym typem
jest vector<bool>, znany wcześniej jako bit_vector (istniał tylko ze
względu na brak w niektórych kompilatorach właściwości pt. "partial
specialization"). Jego elementy faktycznie zajmują jeden bit (choć oczywiście
jego długość jest zawsze przybliżana do pełnego bajtu :*).
- `list'
Ciąg przedniego i tylnego wstawiania o zmiennej długości, gdzie każdy element
trzymany jest przez węzeł podwójnie wiązany. Jego iteratory są dwukierunkowe.
Operacja taka jak splatanie dwóch list jest stałego czasu. Co ważne, usuwanie i
wstawianie elementów nie unieważnia iteratorów (z wyjątkiem tych, które
wskazują na usuwane elementy). Należy jednak pamiętać, że jeśli `i' i `j' są
iteratorami wyznaczającymi zakres wewnątrz zbiornika, to wstawienie elementu
wewnątrz tego zakresu dla wektora oznacza unieważnienie iteratora górnego
zakresu, ale nie zmienia dystansu pomiędzy `i' a `j'; w przypadku listy jest
odwrotnie: iteratory pozostają ważne, ale zmienia się dystans pomiędzy nimi.
- `deque'
Nazwa jest skrótem od "double-ended queue" ("dwukońcowa kolejka", czy też,
mówiąc krócej, "dwukolejka") i wymawia się jak "deck". Jest bardzo podobna do
wektora, jej iteratory są również swobodnego dostępu. Nie udostępnia jednak
metod reserve i capacity. Najbardziej typowym użyciem tego ciągu jest dodawanie
i usuwanie elementów na jednym z jej końców (metody push_front, push_back,
pop_front, pop_back). Iteratory są unieważniane zawsze przy każdym (również na
końcach!) wstawianiu elementów, przy usuwaniu tylko z jej środka, a z końców
tylko te, które wskazują na usuwane. Typ ten zresztą z reguły nie jest nigdy
używany bezpośrednio; jest on tylko podstawą wykorzystywaną potem przez
adaptatory `stack' i `queue' (choć te adaptatory potrafią adaptować dowolny
zbiornik, acz `deque' jest domyślny).
Zwracam uwagę na charakterystyczne rzeczy każdego z tych ciągów. Wektor np.
nie jest ciągiem przedniego wstawiania (i podobnie string, który ma z wektorem
co nieco wspólnego), zatem nie ma push_front() i pop_front(). Wektor
i dwukolejka mają z kolei pewne wspólne cechy, odróżniające je od listy:
są to zbiorniki, w którym elementy są ułożone w "jednym ciągu" (i właśnie
dlatego są zbiornikami swobodnego dostępu). To powoduje, że łatwiej się po
nich iteruje, liczenie liczby elementów, czy dystansu między dwoma
iteratorami, jest stałego czasu. W przypadku listy takie operacje są liniowego
czasu (w ilość elementów). Lista jednak może być dowolnie dzielona na kawałki
i sklejana i te operacje są dla listy stałego czasu (a łatwo sobie wyobrazić,
ile zamieszania to by powodowało w przypadku wektora, czy dwukolejki). Tak
samo, w przypadku listy usunięcie dowolnego elementu i wstawienie w dowolnym
miejscu jest takoż stałego czasu. W ogólności, listy nie należy stosować tam,
gdzie trzeba wciąż elementy przeglądać, czy liczyć (podpowiem tylko, że
"najszybszy" algorytm sortujący, introsort, w przypadku listy okazuje się
najwolniejszy, właśnie z tych powodów), a wektora i dwukolejki tam, gdzie
należy wciąż elementy przekładać z miejsca na miejsce lub też często elementy
się usuwa z kolejki i wkłada.
Tak przy okazji, to co tu napisałem o dwukolejce, to nie jest do końca prawda.
Dwukolejka jest bardzo szczególnym zbiornikiem, ze względu na swoją budowę.
Jest to coś, co mi zresztą jakiś czas temu przyszło do głowy (acz nie w takiej
postaci): zbiornik klejony. To polega na tym, że wewnątrz tego zbiornika są
mniejsze zbiorniki, które dopiero przechowują elementy. To powoduje, że można
swobodnie i dość szybko doklejać kolejne elementy z jednej i drugiej strony,
jeśli się coś nie mieści, to tworzy się kolejny podzbiornik i jedzie dalej.
Nie wiem tego dokładnie, ale mógłbym sobie wyobrazić, że nawet wstawianie
w środku jakiegoś elementu byłoby mniej kosztowne, niż w przypadku wektora
(zauważ, że każdy z podzbiorników może sam sobie specyfikować pojemność
zapasową!). To wszystko ma niestety jedną poważną wadę (i właśnie z tego
powodu nie o takim zbiorniku klejonym myślałem): jak się można domyślać,
iteracja po tym zbiorniku nie jest taka prosta. Każdy awans iteratora jest
obarczony porównywaniem w celu sprawdzenia, czy iterator po awansie będzie
nadal siedział w tym samym podzbiorniku, a także każde porównanie z innym
iteratorem to porównanie dwóch (a nie jednej, jak w większości) wartości.
Podobnie size(), jak się można domyślać, będzie może nie liniowy, ale też
i nie taki znów stały. Dlatego też należy sobie raczej darować iterowanie po
takim zbiorniku, w miarę możliwości (ja miałem pomysł trochę inny –
powinno się zgenerycyzować sposób iteracji po zbiorniku i wyznaczać, które
algorytmy pracują jednolicie na całym zbiorniku – jak for_each – i
te wołać po kolei dla każdego podzbiornika, a które muszą pracować na całym
– jak sort – i te już wołać normalnie).
- Adaptatory ciągów
- `stack'
Tzw. "stos", czyli "LIFO" (last in, first out -
odrzucanie i zbieranie elementu z tego samego końca). Posiada metody push i
pop, które odrzucają i zbierają element. Należy pamiętać, że typem zwracanym
pop jest void. Powód jest prosty - metoda top zwraca element szczytowy przez
referencję, gdyż tak jest "most efficient", a pop tak elementu
zwracać nie może. Można podać dowolny podległy zbiornik, ale domyślnym jest
deque.
- `queue'
Tzw. "kolejka", czyli "FIFO" (first in, first out -
odrzucanie na jeden koniec, a zbieranie z drugiego). Posiada metody push i pop,
a także front, która zwraca przez referencję najbliższy element.
- `priority_queue'
Podobna do kolejki, z tym że jej elementy są zawsze odpowiednio poukładane,
zgodnie z regułami sterty - patrz make_heap itp. (trzecim parametrem wzorca
jest orzecznik; domyślnie stosuje się operator `<'). Domyślnym podległym
zbiornikiem jest vector. Nie umożliwia iterowania po elementach (mimo, że
iteratory są swobodnego dostępu), a powodem tego jest właśnie sterta.
- Zbiorniki asocjacyjne
Tu się rozdrabniać nie będę. Właściwości tych zbiorników zostały opisane przy
konceptach. Wyszczególnię tylko, jakie typy należą do konkretnych właściwości.
Dzielą się one na:
- ze względu na to, co stanowi wartość kluczową:
- zbiór: set, multiset; jedynym istotnym
parametrem jest tutaj pierwszy; przeznaczeniem zbioru jest zawierać elementy
po to, żeby można było je później wyszukiwać przez porównanie (inaczej mówiąc,
zawiera tylko klucze)
- mapa: map, multimap; należy podać dwa
parametry: klucz oraz typ danych im przyporządkowanych; przeznaczeniem mapy
jest ustanawiać odwzorowanie wartość jednej kategorii na wartość drugiej
kategorii (mogą być one tych samych albo różnych typów); elementami mapy
są pary
Przypominam, że elementy będące kluczami są stałe (czyli nie spełniają
konceptu `przypisywalny')! W przypadku mapy oczywiście same elementy zbiornika
uważa się za stałe, ale samo pole `second' ma zachowaną pierwotną wariancję.
- ze względu na restrykcje co do równości elementów:
- unikalne: set, map; zbiorniki unikalne mogą zawierać tylko różne elementy,
tzn. nie może w nich być dwóch kluczy, które byłyby sobie równe
- wielokrotne: multiset, multimap; zbiorniki wielokrotne mogą zawierać
klucze, które byłyby sobie równe, aczkolwiek skoro ten zbiornik jest sortowany,
to wszystkie takie elementy leżą blisko siebie; istnieje też algorytm
pozwalający uzyskać zakres elementów o takim samym kluczu, equal_range
Zbiorniki asocjacyjne mają też specjalizowane metody find() i insert().
Wstawianie elementów do tego zbiornika nie powinno wskazywać miejsca
wstawiania; zostanie ono wybrane przez zbiornik. Istnieje, owszem, metoda
insert() z dodatkowym argumentem, iteratorem, ale – jak się wyraża
dokumentacja – stanowi on jedynie podpowiedź, a w praktyce ta metoda
istnieje tylko dlatego, żeby była zgodna z insert() w ciągach. Dla zbioru
oczywiście metoda find() jest kluczowa o tyle, że pozwala stwierdzić, czy
dany element się znajduje w zbiorze (zbiór głównie do tego jest właśnie
przeznaczony). W ogólności, zbiorniki asocjacyjne przeznacza się do tego,
żeby w nich później wyszukiwać; zbiór do tego, żeby grupować elementy, a
mapę do tego, żeby ustanawiać powiązanie elementów pomiędzy dwoma zbiorami
(np. napis do liczby całkowitej, czy liczbę całkowitą do wskaźnika na funkcję
itp.). Odradzam (i to stanowczo odradzam!) trzymanie w zbiorze
"poważnych" obiektów (uprzedzam, bo widziałem już np. trzymanie w zbiorze
wskaźników do dużych obiektów). Zbiór powinien zawierać tylko takie obiekty,
wśród których będzie coś wyszukiwane przez porównanie. Metoda find() również
istnieje właśnie dlatego, że – odmiennie, niż ogólny algorytm find()
– wyszukuje binarnie. Tzn. dokładnie to nie jest tak; wyszukiwanie jest
po prostu logarytmicznego czasu, ale i sam zbiornik ma niekiedy skomplikowaną
budowę (w wyniku czego iteracja po nim nie jest stałego czasu, a co najwyżej
amortyzownanego stałego). W ogólności: zbiorniki sortowane mają zoptymalizowane
operacje wyszukiwania i po części operacje wstawiania (obie logarytmicznego
czasu), ale niestety iteracja po nim jest wolniejsza, niż np. w przypadku
wektora (ale też nie do tego są te zbiorniki przeznaczone).
- Pakiet łańcuchów
Najbardziej znanym łańcuchem jest string, który jest łańcuchem znaków typu
char. Jest to jednak nie tyle typ, ile specjalizacja wzorca basic_string,
który jako parametry wzorca przyjmuje typ znaku, reguły CECHOWANIA znaku
(character traits) no i - tradycyjnie - alokator.
Reguły cechowania znaków to po prostu klasa, która zawiera odpowiednie
definicje typów i statyczne metody, określające sposób wykonywania
odpowiednich operacji. Taka klasa jest oczywiście nawet zdefiniowana i to jako
wzorzec (zatem domyślnie drugim parametrem wzorca basic_string jest
std::char_traits<charT>, gdzie charT jest pierwszym parametrem wzorca).
Najważniejsze rzeczy nt. basic_string napisałem wcześniej (pisząc o nim jako o
typie string), szczegółowiej zostanie opisany w następnym rozdziale.
Algorytmy
Algorytmy są bezwzględnie jedną z najmocniejszych stron STL-a, a zarazem
największej jej części. Główną ich zaletą jest to, że potrafią pracować prawie
ze wszystkim, wystarczy jedynie, żeby dany typ spełniał pewne wymagania.
Algorytmy są zaimplementowane jako wzorce funkcji.
- Algorytmy niezmieniające (tzn. nie modyfikujące układu obiektów w
zbiorniku). Aktualnie, wszystkie poza for_each, to najróżniejsze wyszukiwacze.
- for_each( first, last, unary_fn )
Dla każdego elementu z zakresu [first, last) wywołaj unary_fn. Zwraca unary_fn.
Zauważ, że funkcja ta umożliwia modyfikację elementów zbiornika (w tym również
przyjmowanie przez referencję kolejnych elementów w unary_fn). Teoretycznie też
można by tak zorganizować, żeby elementy były nawet usuwane przez unary_fn
(oczywiście nie w kompozycji ze zbiornikami STL-a, ale w ogólności nie jest to
niemożliwe). Jest tylko jeden problem: w momencie usunięcia elementu niektóre
z iteratorów są unieważniane, więc w tym momencie należałoby przerwać iterację
(w szczególności, trudno sobie wyobrazić, żeby taka operacja mogła być w
jakikolwiek sposób poprawna). Z kolei unary_fn może być również obiektem
kumulacyjnym, który zapamiętuje jakieś dane z każdej iteracji, jest bowiem
zwracany jako rezultat for_each.
- find( first, last, value )
- find_if( first, last, pred )
Znajdź w podanym zakresie wartość równą value (_if - spełniającą pred).
Zwraca iterator wskazujący na znaleziony element lub last jeśli nie znalazł.
- adjacent_find( first, last )
Znajdź taki element, że jego następnik jest mu równy.
- adjacent_find( first, last, pred )
Znajdź taki element `i', że pred( *i, *i+1 ) jest spełniony. Jak
poprzednio, jeśli element nie zostanie znaleziony, zwracany jest last.
- find_first_of( first1, last1, first2, last2 [, pred] )
Jak `find', ale znajduje w zakresie [first1, last1) którykolwiek z elementów
z zakresu [first2, last2). Jeśli podano `pred', jest on użyty do
porównywania elementów, jeśli nie, używa się operatora ==.
- count( first, last, value )
Zlicza elementy równe `value' w podanym zakresie i zwraca obliczoną wartość.
- count_if( first, last, pred )
Jak wyżej, ale elementy spełniające `pred'.
- mismatch( first1, last1, first2 [, pred] )
Porównuje dwa zakresy (operatorem == lub `pred' jeśli podano). Zwraca PARĘ,
w której są odpowiednio iterator z pierwszego zakresu i iterator symetrycznie
mu odpowiadający z drugiego. Jeśli na którejś pozycji podane zakresy się
różnią, zwracana jest para iteratorów, których dereferencje się różnią, jeśli
zakresy okażą się identyczne - pierwszym z pary jest last1.
- equal( first1, last1, first2 [, pred] )
Identyczny z mismatch( first1, last1, first2 [, pred] ).first == last1.
- search( first1, last1, first2, last2 [, pred] )
Wyszukuje w zakresie [first1, last1) ciąg [first2, last2); zwraca
wartość iteratora z pierwszego zakresu, na którym szukany ciąg się zaczyna;
jeśli nie znaleziono - last1.
- search_n( first, last, count, value [, pred] )
Szuka w podanym zakresie ciągu `count' elementów równych `value' lub
spełniających z nią `pred'.
- find_end( first1, last1, first2, last2 [, pred] )
Ta nazwa jest trochę myląca; bardziej odpowiadałaby jej pewnie search_end lub
nawet rsearch. Funkcja robi zgoła to samo co search z tym tylko że od końca.
Jeśli ciąg nie zostanie znaleziony, zwracana jest wartość last1.
- Algorytmy zmienające
- copy( first, last, result )
Kopiuje elementy z podanego zakresu do `result' wywołując kolejno operator
przypisania. Zwróć uwagę, że sposób wstawiania elementów do zbiornika
docelowego jest bezpośrednio zależny od iteratora. Jeśli zatem jest to
zywkły iterator do zbiornika (i umożliwia przypisywanie przez niego), to
będzie to powodować nadpisywanie elementów. Żeby wstawiać elementy należy
użyć jednego z iteratorów wstawiających, np. back_insert_iterator. Zauważ też,
że jeśli zakresy się na siebie nakładają (tzn. result jest osiągalne z first i
last jest osiągalne z result), copy nie może zostać użyte; w takim wypadku
jednak przyda się copy_backward.
- copy_backward( first, last, result )
Jak copy, ale kopiuje od tyłu i - co ważne - result jest GÓRNYM zakresem
docelowym (wartością za-końcową zakresu docelowego!).
- swap( x, y )
Zamienia miejscami x i y. Argumenty muszą spełniać koncept "przypisywalny".
- iter_swap( x, y )
Identyczne z swap( *x, *y ), gdzie x i y są iteratorami postępowymi. Istnieje
ze względu na to, że niektóre kompilatory nie radzą sobie z wydedukowaniem
typów argumentów przy wywołaniu jako `swap'.
- swap_ranges( first, last, first2 )
Zamienia miejscami podane zakresy.
- transform( first, last, result, op1 ),
transform( first1, last1, first2, result, op2 )
Przetwarza przy pomocy podanej funkcji podany zakres elementów. Pierwsza
wersja pobiera argumenty z podanego zakresu, przetwarza przy pomocy op1 i
wstawia wynik poprzez result (result = op1( first )).
Druga wersja podobnie, z tym że pobiera pierwszy argument z pierwszego
zakresu, a drugi - z drugiego (result = op2( first1, first2 )).
- replace( first, last, old, new )
Zamienia w podanym przedziale wszystkie znalezione wartości `old' na
`new' (oba podane przez referencję).
- replace_if( first, last, pred, new )
Jak replace, ale dla elementów spełniających `pred'.
- replace_copy( first, last, result, old, new )
Jak copy, ale jeśli w źródłowym zakresie wartość == old, wstawiane jest tam
`new'.
- replace_copy_if( first, last, result, pred, new )
Jak replace_copy, ale sprawdzany jest `pred'.
- fill( first, last, value )
Wypełnia podany zakres wartością (operator przypisania).
- fill_n( first, count, value )
Jak fill, ale górny zakres jest first + count.
- generate( first, last, op )
Jak fill, ale wartość uzyskuje z generatora `op'.
- generate_n
...
- remove( first, last, value )
Usuwa z podanego zakresu elementy równe podanej wartości. W rzeczywistości
oczywiście nic nie jest usuwane (tzn. nie zmienia się size() zbiornika), a
jedynie następuje nadpisanie elementów tak, aby nie było wśród nich podanej
wartości oraz kolejność elementów została zachowana. Otrzymujemy z tego nowy
zakres, gdzie początek jest taki sam, natomiast koniec zakresu jest zwracany
jako rezultat i jest on równy last (jeśli nie ma w podanym zakresie szukanej
wartości) lub wstecznie osiągalny z last. Zakres wyznaczany od nowego końca
zakresu do poprzedniego pozostaje niezmieniony. Żeby te elementy naprawdę
usunąć, należy rezultat wywołania tej funkcji podać jako pierwszy iterator
do metody erase() zbiornika.
- remove_if( first, last, pred )
Jak `remove', ale tylko elementy spełniające `pred'.
- remove_copy( first, last, result, value )
Jak `remove', ale wykonuje kopiowanie.
- remove_copy_if( first, last, result, pred )
No... no dobra, napiszę coś więcej. Ten algorytm kopiuje
elementy z jednego zakresu do drugiego, pomijając te, które
spełniają `pred'. Jak komuś potrzeba czegoś w rodzaju copy_if (a
widziałem już specjalistów, którzy taki algorytm dostarczali!),
to może użyć tego, z tym tylko że orzecznik trzeba ująć w not1.
- unique( first, last [, pred] )
Działa identycznie jak remove, z tym że usuwa wszystkie elementy, które są
sobie równe z wyjątkiem jednego z nich (tzn. po wykonaniu każdy element jest
unikalny). Jeśli podamy `pred', testuje się nim dwa sąsiednie elementy, w
przeciwnym wypadku używa się operatora ==.
- unique_copy( first, last, result )
Kopiuje elementy, pomijając powtarzające się.
- reverse( first, last )
Odwraca kolejność elementów w zakresie (iteratory dwukierunkowe!)
- reverse_copy
...
- rotate( first, mid, last )
Zamienia zakres [mid, last) z [first, mid).
- rotate_copy
...
- partition( first, last, pred )
Dzieli przedział na dwie części, gdzie wcześniejsza zawiera elementy
spełniające podany warunek, a dalsza nie. Iterator rozpoczynający ten
drugi zakres jest zwracany. Nie gwarantuje się tej samej kolejności elementów po
operacji.
- stable_partition
Jak partition, ale gwarantuje się zachowanie tej samej kolejności elementów
po operacji.
- Sortowanie
- sort( first, last [, comp] )
Sortowanie (introsort). Sortowanie jest niestabilne, tzn. jeśli elementy są
sobie równe, nie gwarantuje się, że pozostaną względem siebie ułożone tak
samo. Sortowanie jest rosnące, porównania dokonuje się operatorem < lub
podanym orzecznikiem `comp'.
- stable_sort
Sortowanie stabilne. Używa merge sort. Zwracam uwagę, że introsort jest
najszybsze, ale działa w praktyce tylko na zbiorniku swobodnego dostępu.
Mergesort zaś jest wolniejsze "w ogólności", ale na zbiorniku, który nie
jest swobodnego dostępu będzie działać szybciej (lista ma również metodę
sort, która wykonuje sortowanie właśnie metodą mergesort).
- partial_sort( first, mid, last )
Sortowanie częściowe wg algorytmu heapsort. Polega to na tym, że elementy w
efekcie są posortowane, ale tylko na pierwszych mid - first pozycjach;
pozostałe są ułożone w nieokreślonym porządku. Reszta jak sort.
- partial_sort_copy
...
- is_sorted
Sprawdza, czy elementy są posortowane (wg < lub podanego orzecznika)
- nth_element( first, nth, last )
- nth_element( first, nth, last, pred )
Podobne do partition( first, last, bind1st( less<T>, *nth ) ). Dokonuje
takiego podziału zbiornika, że wszystko, co jest mniejsze (operator < lub
podany orzecznik) niż to, co podano jako 'nth' znajduje się zakresie [first,
nth).
- Algorytmy niezmieniające dla posortowanych zakresów
- lower_bound( first, last, value [, comp] )
Zwraca iterator wskazujący na najwcześniejsze miejsce w przedziale, w które
tą wartość należałoby wstawić nie naruszając porządku.
- upper_bound
Jak wyżej, ale najpóźniejsze miejsce.
- equal_range( first, last, value [, comp] )
Zwraca parę iteratorów, które wyznaczają pod-zakres podanego zakresu, którego
elementy są równe `value' (tzn. ani value < *i, ani *i < value) lub nie jest
spełniony `comp' dla dowolnej kolejności argumentów. Jest to algorytm w
szczególności przeznaczony dla zbiorników multiset i multimap.
- binary_search
Działa identycznie, jak `find' z tym, że kryteria wyszukiwania (i argumenty)
są identyczne jak w equal_range.
- Scalanie zakresów
- merge
Scala dwa zakresy wrzucając je do wskazanego miejsca docelowego.
- inplace_merge( first, mid, last [, comp] )
Podany przedział powinien być podzielony na dwie posortowane części (jak po
wykonaniu partition). Funkcja porządkuje elementy w całym przedziale (jak
widać, stable_sort to jakby wywołanie stable_partition i inplace_merge).
- Operacje na zbiorach
Te algorytmy operują na nie tyle zbiorach, co posortowanych rosnąco zakresach.
Udostępniają znane z matematyki operacje na zbiorach, jak "zawieranie się
zbiorów", "suma zbiorów" i "różnica zbiorów", tylko
coś tu nie widzę operacji "należy do zbioru" (find to nie jest to, bo
nie jest to algorytm specjalizowany do posortowanych zakresów); być może
autorzy stwierdzili, że jej uogólnieniem jest `includes', w końcu można jej
podać zakres składający się z jednego elementu (ostatecznie zbiorniki, które
reprezentują zbiory mają swoje find, tylko nie wiem po co w takim razie
wprowadzono dodatkowo poniższe algorytmy).
- includes( first1, last1, first2, last2 [, comp] )
Sprawdza, czy w zakresie 2 znajdują się wszystkie te elementy, co w
zakresie 1. Wymaga się, żeby zakresy były posortowane rosnąco. Nie ma
wymagań, aby elementy były unikalne i dopuszcza się, żeby zakres 2 zawierał
więcej takich samych elementów, niż zakres 1; musi jednak zawierać ich
co najmniej tyle, co zakres 1.
- set_union( first1, last1, first2, last2, result [, comp] )
Tzw. "suma zbiorów"; w sumie to samo, co merge, ale elementy powinny
być w obu zakresach posortowane, a operacja jest stabilna (nie narusza
sortowania).
- set_intersection
Jak wyżej, część wspólna zbiorów.
- set_difference
Jak wyżej, różnica zbiorów.
- set_symmetric_difference
Jak wyżej, różnica symetryczna zbiorów.
- Sterty
- push_heap( first, last )
Wstawia element poprzedni od `last' do sterty (zakłada się, że zakres
[first, last - 1) już jest stertą).
- pop_heap
Usuwa największy element ze sterty.
- make_heap
Tworzy z podanego zakresu stertę.
- sort_heap
Sortuje stertę (niestabilnie!).
- Algorytmy numeryczne
- min
- max
- min_element
- max_element
Tu chyba nie trzeba tłumaczyć. *_element wymagają zakresu, a min i max dwóch
wartości spełniających "porządkowalne". Wszystkie jako ostatni
argument mogą mieć orzecznik.
- next_permutation
- prev_permutation
Przekręca podany zakres na następną/poprzednią permutację. Zwraca wartość
bool oznaczającą, czy operacja została wykonana.
- accumulate( first, last, init [, bin_op] )
Sumuje elementy z podanego zakresu, ustawiając wartość początkową zmiennej
sumującej na `init'; jej wartość jest zwracana. Opcjonalnie bin_op może być
użyte zamiast dodawania (pierwszym argumentem jest poprzednia wartość
zmiennej sumującej).
- inner_product( first1, last1, first2, init [, op1, op2] )
Normalnie funkcja ta najpierw wykonuje result = init, a potem dla każdej
pary elementów <i, j> z dwóch zakresów wykonuje `result += *i * *j'. W
drugiej wersji op1 zastępuje dodawanie, a op2 mnożenie.
- partial_sum( first, last, result [, op] )
Liczy sumy częściowe wstawiając je do `result' (op może zastąpić dodawanie).
Przykładowo, do *result wstawiane jest *first, do *(result + 1) wstawiane
jest *first + *(first + 1) i tak dalej.
- adjacent_difference
Liczy różnice kolejnych elementów. Pierwszy element jest przepisywany, a
każdy następny jest różnicą elementu na danej pozycji i jego poprzednika.
Alokatory
(nieczynne z powodu choroby)
Darmowy hosting zapewnia PRV.PL