Język C++ został stworzony w latach osiemdziesiątych XX wieku (pierwsza wersja pojawiła się w 1979 r.) przez Bjarne Stroustrupa jako obiektowe rozszerzenie języka C. Poza językiem C, na definicję języka C++ miały wpływ takie języki, jak Simula (z której zaczerpnął właściwości obiektowe) oraz Algol, Ada, ML i Clu.
Początkowo język C++ był dostępny w takim standardzie, w jakim opracowano ostatnią wersję kompilatora Cfront (tłumaczący C++ na C), później opublikowano pierwszy nieformalny standard zwany ARM (Annotated Reference Manual), który sporządzili Bjarne Stroustrup i Margaret Ellis. Standard języka C++ powstał w 1998 roku (ISO/IEC 14882-1998 "Information Technology – Programming Languages – C++"). Standard ten zerwał częściowo wsteczną zgodność z ARM w swojej bibliotece standardowej; jedyne, co pozostało w stanie w miarę nienaruszonym to biblioteka iostream.
Początkowo najważniejszą zmianą wprowadzoną w C++ w stosunku do C było programowanie obiektowe, później jednak zaimplementowano wiele innych ulepszeń, mających uczynić ten język wygodniejszym i bardziej elastycznym od swojego pierwowzoru. Niektóre zmiany w standardzie języka C były zainspirowane językiem C++ (np. słowo inline w C99).
Nazwa języka została zaproponowana przez Ricka Mascitti w 1983 roku, kiedy to po raz pierwszy użyto tego języka poza laboratorium naukowym. Wcześniej używano nazwy "C z klasami". Nazwa języka C++ nawiązuje do faktu bycia "następcą języka C", przez użycie w niej operatora inkrementacji "++".
Technical Report (TR) to zbiór proponowanych rozszerzeń do biblioteki standardowej C++, takich jak wyrażenia regularne, sprytne wskaźniki, tablice haszujące i generatory liczb losowych.
Year | C++ Standard | Informal name |
---|---|---|
1998 | ISO/IEC 14882:1998 | C++98 |
2003 | ISO/IEC 14882:2003 | C++03 |
2011 | ISO/IEC 14882:2011 | C++11 |
2014 | ISO/IEC 14882:2014 | C++14 |
2017 | ISO/IEC 14882:2017 | C++17 |
2020 | ISO/IEC 14882:2020 | C++20 |
2023 | ISO/IEC 14882:2023 | C++23 |
int
z operacjami +, -, *, /, %)
class nazwa {
// ciało klasy
};
nazwa obiekt;
int n;
)
nazwa* ptr; // wskaźnik do typu nazwa
nazwa tab[10]; // tablica obiektów typu nazwa
struct date {
short day;
short month;
int year;
};
void set_date(date&, short, short, int);
date& next_date(const date&);
void print(const date&);
Nie ma powiązania między danymi i funkcjami.
date
mogą działać tylko te, a nie inne funkcje, definiujemy klasę:
class date {
short day;
short month;
int year;
public:
void set_date(short, short, int);
date& next_date() const;
void print() const;
};
date
Aby odnieść się do atrybutów obiektu lub metod klasy można używać jednej z poniższych notacji
obiekt.atrybut;
wsk_obiektu->atrybut;
referencja_obiektu.atrybut;
obiekt.metoda();
wsk_obiektu->metoda();
referencja_obiektu.metoda();
np.
date tomorrow, *birthday, *day_after_tomorrow;
date& tom = tomorrow;
birthday = &tomorrow;
tomorrow.day = 4;
birthday->year = 2003;
tomorrow.print();
day_after_tomorrow = birthday->next_date();
Nie wszystkie składowe klasy muszą być widoczne na zewnątrz (jak to było w przypadku struktur), tzn. nie wszystkie atrybuty czy metody klasy mogą być dostępne spoza tej klasy.
Etykieta public: dzieli składowe klasy na dwie części:
private:
) -
składowe dostępne tylko z wnętrza klasy (tylko metody tej klasy mają
dostęp do składowych prywatnych klasy)public:
), inaczej
interfejs klasy - składowe dostępne spoza klasy; publiczne atrybuty
oraz metody mogą być używane przez funkcje nie należące do klasyKorzyści z enkapsulacji danych:
Reguły składniowe:
Definicja klasy nie definiuje żadnych obiektów. Jest to tylko określenie nowego typu danych. Dopiero mając gotowy projekt (definicję klasy) można utworzyć obiekty tej klasy
class person { // klasa
char name[40]; // część prywatna
int age;
public: // część publiczna
void set(const char*, int);
void print();
}; // koniec definicji klasy
...
person student1, student2, professor; // obiekty; odpowiednik zmiennych
Każdy obiekt klasy znajduje się w swoim odrębnym miejscu w pamięci.
Metody klasy są składowane jednokrotnie(bo są wspólne dla wszystkich obiektów tej klasy).
Metoda jest narzędziem, za pomocą którego dokonujemy operacji na atrybutach klasy. Wywołanie:
student1.set("Jan Kowalski", 21);
professor.set("Albert Einstein", 57);
należy rozumieć: na rzecz obiektu student1
wykonaj funkcję
set
z danymi argumentami.
Funkcja składowa może być zdefiniowana:
class person { // klasa
char name[40]; // część prywatna
int age;
public: // część publiczna
void set(const char* n, int a) { // definicja metody
strcpy(name, n);
age = a;
}
void print(); // deklaracja metody
};
class person { // klasa
char name[40]; // część prywatna
int age;
public: // część publiczna
void set(const char*, int); // deklaracja metody
void print(); // deklaracja metody
};
void person::set(const char* n, int a) { // definicja metody
strcpy(name, n);
age = a;
}
Ponieważ funkcja znajduje się poza definicją klasy, dlatego nazwa
funkcji została uzupełniona nazwą klasy, do której ta funkcja należy;
służy do tego operator zakresu ::. Nazwą funkcji jest teraz cały napis:
person::set
Konstruktor jest specyficzną funkcją, która jest wywoływana zawsze gdy tworzony jest obiekt. Jeśli programista nie utworzy konstruktora dla klasy, kompilator automatycznie utworzy konstruktor, który nic nie będzie robił. Konstruktor nie pojawi się nigdzie w kodzie, jednak będzie on istniał w skompilowanej wersji programu i będzie wywoływany za każdym razem, gdy będzie tworzony obiekt klasy. Jeśli chcemy zmienić domyślne własności konstruktora jaki jest tworzony przez kompilator C++ wystarczy, że utworzymy własny konstruktor dla klasy.
W odróżnieniu od dotąd poznanych funkcji, konstruktor nie posiada zwracanego typu danych. Druga istotna własność konstruktora to jego nazwa. Konstruktor musi nazywać się tak samo jak nazwa klasy. Konstruktory mogą posiadać parametry tak samo jak zwykłe funkcje. C++ umożliwia również tworzenie kilku konstruktorów dla jednej klasy (na ogólnych zasadach obowiązyjących przy przeładowaniu funkcji, czyli konstruktory muszą się różnić listą parametrów).
Gdy tworzymy klasę, wszystkie zmienne jakie są zadeklarowane wewnątrz niej są zainicjalizowane przypadkowymi wartościami, które zmieniamy w konstruktorze.
Przykład:
class JakasKlasa {
int a;
char b;
public:
JakasKlasa() {
a = 123;
b = 'x';
}
};
Czasami zachodzi potrzeba zainicjalizowania zmiennej w trakcie tworzenia klasy, a nie po jej utworzeniu. Aby to zrobić, należy użyć następującego zapisu:
class JakasKlasa {
int a;
char b;
public:
JakasKlasa() : a{123}, b{'x'} { }
};
Taki zapis ma kilka bardzo istotnych zalet:
const
i referencji.Lista inicjalizacyjna to lista oddzielonych przecinkami identyfikatorów pól (składowych) z podanymi w nawiasach okrągłych argumentami dla konstruktorów obiektów będących składowymi tworzonego obiektu. Zwykle są to jednocześnie argumenty formalne definiowanego konstruktora, choć nie musi tak być. Jeśli argumentem przesyłanym do konstruktora obiektu składowego na liście inicjalizacyjnej jest obiekt tego samego typu, co ta składowa, to traktowane to będzie jako wywołanie konstruktora kopiującego. Taka składnia działa również dla składowych, które są typu wbudowanego - twórcy języka starali się, aby reguły składniowe dla typów obiektowych i wbudowanych były do siebie tak podobne, jak to tylko możliwe.
Listę inicjalizacyjną, poprzedzoną dwukropkiem, umieszcza się bezpośrednio po nawiasie zamykającym listę parametrów konstruktora, a przed nawiasem klamrowym otwierającym definicję tego konstruktora.
Jeśli w klasie tylko deklarujemy konstruktor, a jego definicję podajemy poza klasą, to w deklaracji listy inicjalizacyjnej nie umieszczamy.
To jest logiczne: lista inicjalizacyjna należy logicznie do implementacji, a nie do interfejsu (kontraktu). Jak pamiętamy, odwrotnie było z argumentami domniemanymi (domyślnymi) - nie tylko konstruktorów, ale w ogóle funkcji; jeśli deklaracja występuje, to argumenty domniemane muszą być zdefiniowane właśnie w deklaracji, ale nie w definicji, bowiem ich wartość, i sama ich obecność, należy jak najbardziej do kontraktu z użytkownikiem (interfejs).
Niezależnie od kolejności na liście inicjalizacyjnej, składowe obiektu są inicjowane zawsze w kolejności ich deklaracji w ciele klasy.
Niektóre kompilatory wysyłają ostrzeżenia, jeśli kolejność deklaracji w definicji klasy i kolejność na liście inicjalizacyjnej nie są zgodne.
Na liście inicjalizacyjnej nie musimy wymieniać wszystkich składowych klasy. Te składowe, które nie zostały wymienione na liście, zainicjalizowane będą (przed rozpoczęciem wykonania ciała konstruktora!) przez:
Pola stałe stosuje się rzadko. Zazwyczaj wystarczy mechanizm ochrony danych poprzez umieszczenie składowej w sekcji prywatnej klasy. Również pola odnośnikowe nie występują często: składowa odnośnikowa jest inną nazwą czegoś spoza klasy, co stwarza niepotrzebną zwykle więź między obiektem a danymi spoza obiektu. Natomiast pola obiektowe, występują często i w takich przypadkach stosowanie listy inicjalizacyjnej może być konieczne.
Konstruktor kopiujący jest wywoływany, gdy inicjujemy nowo tworzony obiekt obiektem już istniejącym. Konstruktor kopiujący kopiuje wartość każdej niestatycznej składowej klasy do jej odpowiednika w nowo tworzonym obiekcie. Jest to tzw. kopiowanie płytkie. Prototyp konstruktora kopiującego ma postać
Nazwa_klasy(const Nazwa_klasy &);
Mogłoby się wydawać, że tego rodzaju konstruktor często w ogóle nie będzie potrzebny. Tak jednak nie jest: jest on potrzebny praktycznie zawsze, choć nie zawsze sami musimy go definiować. Zauważmy bowiem, że kopiowanie obiektów zachodzi zawsze, gdy obiekt pełni rolę argumentu wywołania funkcji, jeśli tylko przekazywany jest do funkcji przez wartość, a nie przez wskaźnik lub referencję; na stosie musi zostać położona kopia obiektu. Podobnie rzecz się ma przy zwracaniu przez wartość obiektu jako wyniku funkcji: tu również jest wykonywana kopia obiektu. Za każdym razem w takim przypadku używany jest konstruktor kopiujący.
Ponieważ niejawny konstruktor kopiujący nie kopiuje składowych statycznych oraz nie kopiuje łańcuchów, tylko ich adresy, w klasach w których występują składowe statyczne albo łańcuchy dynamiczne, należy zdefiniować jawnie konstruktor kopiujący.
Dlaczego argument musi być referencją, a nie obiektem przekazywanym przez wartość? Gdyby był obiektem, to ponieważ argumenty przekazywane przez wartość są podczas wywołania kopiowane i kładzione na stosie, musiałaby najpierw zostać wykonana kopia tego obiektu. Ale do tego potrzebne byłoby ... wywołanie konstruktora kopiującego i przesłanie do niego przez wartość argumentu, a do tego znowu trzeba by wykonać kopię, a więc wywołać konstruktor kopiujący, i tak dalej, bez końca. Konstruktor kopiujący byłby zatem wywoływany rekursywnie w nieskończoność.
Z drugiej strony, przekazując do konstruktora obiekt-wzorzec przez
referencję, dajemy mu możliwość zmiany tego obiektu-wzorca, dostaje on bowiem
wtedy oryginał obiektu, a nie jego kopię. Najczęściej taka zmiana byłaby
niepożądana. Dlatego właśnie, aby się przed możliwością takiej zmiany
zabezpieczyć, parametr konstruktora kopiującego deklarujemy z modyfikatorem
const
. Sam kompilator zadba wtedy o to, abyśmy nawet przypadkowo
nie zmodyfikowali obiektu-wzorca.
Konstruktor, którego jedynym argumentem niedomyślnym jest obiekt dowolnej klasy lub typ wbudowany. Powoduje niejawną konwersję z typu argumentu na typ klasy własnej konstruktora. Na przykład:
W C++11 konstruktorem kowertującym może być każdy konstruktor nie posiadający
własności explicit
.
class MojaKlasa {
public:
MojaKlasa(int parametr) { // konstruktor konwertujący z typu int na typ MojaKlasa
// ciało konstruktora
}
};
void funkcja(MojaKlasa obiekt) { /* ciało funkcji */ }
int main () {
int zmienna = 5;
funkcja(zmienna); // wywołanie konstruktora konwertującego z int na MojaKlasa
return 0;
}
auto_ptr<T>
w C++98 symulował semantykę przenoszenia za pomocą konstruktora kopiującego i operatora przypisaniaAby umożliwić implementację semantyki przenoszenia C++11 wykorzystuje podział obiektów na:
Operacja przenoszenia, która wiąże się ze zmianą stanu jest niebezpieczna dla obiektów lvalue ponieważ obiekt może zostać użyty po wykonaniu takiej operacji. Operacje przenoszenia są bezpieczne dla obiektów rvalue.
C++11 wprowadza referencje do rvalue - rvalue references, które zachowują się podobnie jak klasyczne referencje z C++98 (zwane w C++11 lvalue references).
T&&
Wprowadzenie referencji do rvalue rozszerza reguły wiązania referencji:
Używając rvalue references możemy zaimplementować semantykę
przenoszenia. Przykładem może być klasa std::vector<T>
,
która przeciąża operację push_back()
na dwa sposoby:
template <typename T>
class vector {
public:
void push_back(const T& item); // inserts a copy of item
void push_back(T&& item); // moves item into container
};
Dla tak zdefiniowanej klasy kompilator uniknie tworzenia niepotrzebnych kopii dla obiektów, które mogą zostać przeniesione:
void create_and_insert(std::vector& coll) {
string str = "text";
coll.push_back(str); // insert a copy of str; str is used later
coll.push_back(str + str); // rvalue binds to push_back(string&&)
// temp is moved into container
coll.push_back("text"); // rvalue binds to push_back(string&&)
// tries to move temporary object into container
coll.push_back(std::move(str)); // tries to move str object into container
// str is no longer used
}
Innym przykładem mało wydajnej implementacji z wykorzystaniem kopiowania jest
implementacja swap()
w C++98:
template <typename T>
void swap(T& a, T& b) {
T temp = a; // copy a to temp
a = b; // copy b to a
b = temp; // copy temp to b
} // destroy temp
Funkcja std::swap()
może zostać wydajniej zaimplementowana w
C++11 z wykorzystaniem semantyki przenoszenia - zamiast kopiować przenosimy
wewnętrzny stan obiektów (np. wskaźniki do zasobów):
template <typename T>
void swap(T& a, T& b) {
T temp {std::move(a)}; // tries to move a to temp
a = std::move(b); // tries to move b to a
b = std::move(temp); // tries to move temp to b
} // destroy temp
Aby zaimplementować semantykę przenoszenia dla klasy należy zapewnić jej:
W C++11 istnieje sześć specjalnych funkcji składowych klasy:
X();
~X();
X(const X&);
X& operator=(const X&);
X(X&&);
X& operator=(X&&);
Specjalne funkcje mogą być:
Specjalne funkcje zdefiniowane jako = default
są traktowane jako
user declared.
Kiedy destruktor oraz operacje kopiowania nie są zadeklarowane przez użytkownika (user declared), to klasy domyślnie implementują semantykę przenoszenia.
Konceptualny kod domyślnego konstruktora przenoszącego i przenoszącego operatora przypisania:
class X : public Base {
Member m_;
public:
X(X&& x) : Base(static_cast (x)), m_(static_cast(x.m_)) {}
X& operator=(X&&) {
Base::operator=(static_cast (x));
m_ = static_cast(x.m_);
return *this;
}
};
Jeśli klasa nie zapewnia prawidłowej semantyki przenoszenia - w rezultacie
wykonania operacji std::move()
odbywa się kopiowanie.
Jeżeli jedna z poniższych funkcji specjalnych klasy jest user declared
specjalne funkcje przenoszące nie są generowane przez kompilator i operacja przenoszenia jest implementowana poprzez kopiowanie elementu (fallback to copy).
Aby umożliwić efektywne przenoszenie składowych obiektów należy zdefiniować (najlepiej) wszystkie funkcje specjalne.
Jeśli w klasie jest konieczna implementacja jednej z poniższych specjalnych funkcji składowych:
najprawdopodobniej należy zaimplementować wszystkie. Ta
regułą stosuje się również do funkcji specjalnych zdefiniowanych jako
default
.
Implementacja funkcji std::move(obj)
dokonuje rzutowania na rvalue reference - jest to w praktyce static_cast<T&&>(obj)
.
Przeciążanie funkcji w celu optymalizacji wydajności z wykorzystaniem semantyki przenoszenia i referencji rvalue może prowadzić do nadmiernego rozrostu interfejsów:
class Gadget;
void have_fun(const Gadget&);
void have_fun(Gadget&); // copy semantics
void have_fun(Gadget&&); // move semantics
void use(const Gadget& g) {
have_fun(g); // calls have_fun(const Gadget&)
}
void use(Gadget& g) {
have_fun(g); // calls have_fun(Gadget&)
}
void use(Gadget&& g) {
have_fun(std::move(g)); // calls have_fun(Gadget&&)
}
int main() {
const Gadget cg;
Gadget g;
use(cg); // calls use(const Gadget&) then calls have_fun(const Gadget&)
use(g); // calls use(Gadget&) then calls have_fun(Gadget&)
use(Gadget()); // calls use(Gadget&&) then calls have_fun(Gadget&&)
}
Rozwiązaniem jest szablonowa funkcja przyjmująca jako parametr wywołania
T&&
(forwarding reference) i przekazująca
argument do następnej funkcji z wykorzystaniem funkcji
std::forward<T>()
.
class Gadget;
void have_fun(const Gadget&);
void have_fun(Gadget&); // copy semantics
void have_fun(Gadget&&); // move semantics
template <typename Gadget>
void use(Gadget&& g) {
have_fun(std::forward<Gadget>(g)); // forwards original type to have_fun()
// rvalue reference if T is Gadget
// without forward<> only have_fun(const Gadget&)
// and have_fun(Gadget&) would get called
}
int main() {
const Gadget cg;
Gadget g;
use(cg); // calls use(const Gadget&) then calls have_fun(const Gadget&)
use(g); // calls use(Gadget&) then calls have_fun(Gadget&)
use(Gadget()); // calls use(Gadget&&) then calls have_fun(Gadget&&)
}
Funkcja std::forward<T>()
działa jak warunkowe rzutowanie
na T&&
, gdy dedukowanym parametrem szablonu jest typ nie
będący referencją.
W C++17 wymagane jest, aby inicjalizacja zmiennych z wartości tymczasowych (prvalue) wykorzystywała mechanizm copy elision.
W rezultacie istnienie konstruktorów kopiujących lub przenoszących dla klasy nie jest wymagane jeśli chcemy:
Przykład:
class CopyMoveDisabled {
public:
int value;
CopyMoveDisabled(int value) : value{value} {}
CopyMoveDisabled(const CopyMoveDisabled&) = delete;
CopyMoveDisabled(CopyMoveDisabled&&) = delete;
};
Copy elision dla zwracanych wartości:
CopyMoveDisabled copy_elided() {
return CopyMoveDisabled{42};
}
CopyMoveDisabled cmd = copy_elided(); // OK since C++17
Copy elision dla argumentów funkcji:
void copy_elided(CopyMoveDisabled arg) {
cout << "arg: " << arg.value << endl;
}
copy_elided(CopyMoveDisabled{665}); // OK since C++17
Klasy definiowane przez użytkownika muszą być co najmniej tak samo dobrymi typami jak typy wbudowane. Oznacza to, że:
To drugie wymaga, by twórca klasy mógł definiować operatory.
Definiowanie operatorów wymaga ostrożności.
Operatory definiujemy przede wszystkim po to, by móc czytelnie i wygodnie zapisywać programy. Jednak bardzo łatwo można nadużyć tego narzędzia (np. definiując operację + na macierzach jako odejmowanie macierzy). Dlatego projektując operatory (symbole z którymi są bardzo silnie związane pewne intuicyjne znaczenia), trzeba zachować szczególną rozwagę.
Większość operatorów języka C++ można przeciążać, tzn. definiować ich znaczenie w sposób odpowiedni dla własnych klas.
Przeciążanie operatora polega na zdefiniowaniu metody (prawie zawsze może to
też być funkcja) o nazwie składającej się ze słowa operator
i nazwy
operatora (np. operator=
).
Można przeciążać wszystkie zdefiniowane w języku operatory z wyjątkiem:
., .*, ::, ?:, sizeof
Tak zdefiniowane metody (funkcje) można wywoływać zarówno w notacji operatorowej:
a = b + c;
jak i funkcyjnej (tej postaci praktycznie się nie stosuje):
a = (b.operator+(c));
=, (), [], −>
można deklarować jedynie jako
(niestatyczne) metody.Operator jednoargumentowy (przedrostkowy) @
można zadeklarować jako:
typ operator@()
i wówczas @a
jest interpretowane jako: a.operator@()
typ1 operator@(typ2)
i wówczas @a
jest interpretowane jako: operator@(a)
.Operatorów ++
oraz −−
można używać zarówno w postaci
przedrostkowej jak i przyrostkowej. W celu rozróżnienia definicji przedrostkowego
i przyrostkowego ++
(−−
) wprowadza się dla operatorów przyrostkowych dodatkowy
parametr typu int
.
class X{
public:
X operator++(); // przedrostkowy ++a
X operator++(int); // przyrostkowy a++
};
int main(){
X a;
++a; // to samo co: a.operator++();
a++; // to samo co: a.operator++(0);
}
Operator dwuargumentowy @
można zadeklarować jako:
typ1 operator@(typ2)
i wówczas a @ b
jest interpretowane jako: a.operator@(b)
typ1 operator@(typ2, typ3)
i wówczas
a @ b
jest interpretowane jako: operator@(a, b)
.Kopiujący operator przypisania jest czymś innym niż konstruktor kopiujący!
O ile nie zostanie zdefiniowany przez użytkownika, to będzie zdefiniowany przez kompilator, jako przypisanie składowa po składowej (więc nie musi to być przypisywanie bajt po bajcie). Język C++ nie definiuje kolejności tych przypisań.
Zwykle typ wyniku definiuje się jako X&
, gdzie
X
jest nazwą klasy, dla której definiujemy
operator=
.
Uwaga na przypisania x = x
, dla nich operator=
też
musi działać poprawnie!
Jeśli nie chcemy, by dana klasa miała zdefiniowany operator=
, to
nie wystarczy go nie definiować (bo zostanie wygenerowany automatycznie). Musimy
zabronić jego stosowania. Można to zrobić na dwa sposoby:
private:
(to jest dobre
rozwiązanie, bo teraz już w czasie kompilacji otrzymamy komunikaty o próbie
użycia tego operatora poza definiowaną klasą).
Wywołanie:
wyrażenie_proste(lista_wyrażeń);
uważa się za operator dwuargumentowy z wyrażeniem prostym jako pierwszym argumentem i, być może pustą, listą wyrażeń jako drugim. Zatem wywołanie:
x(arg1, arg2, arg3);
interpretuje się jako:
x.operator()(arg1, arg2, arg3)
Wyrażenie:
wyrażenie_proste [ wyrażenie ];
interpretuje się jako operator dwuargumentowy. Zatem wyrażenie:
x[y];
interpretuje się jako:
x.operator[](y)
Wyrażenie:
wyrażenie_proste -> wyrażenie_proste;
uważa się za operator jednoargumentowy. Wyrażenie:
x -> m;
interpretuje się jako:
(x.operator->())->m;
Zatem operator->()
musi dawać wskaźnik do klasy, obiekt klasy
albo referencję do klasy. W dwu ostatnich przypadkach, ta klasa musi mieć
zdefiniowany operator ->
(w końcu musimy uzyskać coś co będzie
wskaźnikiem).
In summary:
Generics in Java
Templates in C++
The format for declaring function templates with type parameters is:
template <class identifier> function_declaration;
template <typename identifier> function_declaration;
The only difference between both prototypes is the use of either the keyword
class
or the keyword typename
. Its use is indistinct,
since both expressions have exactly the same meaning and behave exactly the same
way.
For example, to create a template function that returns the greater one of two
objects we could use:
template <class T>
T get_max (T a, T b) {
return (a > b ? a : b);
}
Here we have created a template function with T as its template parameter. This template parameter represents a type that has not yet been specified, but that can be used in the template function as if it were a regular type. As you can see, the function template GetMax returns the greater of two parameters of this still-undefined type.
To use this function template we use the following format for the function call:
function_name<type>(parameters);
For example, to call get_max()
to compare two integer values of
type int we can write:
int x, y;
get_max<int>(x,y);
When the compiler encounters this call to a template function, it uses the
template to automatically generate a function replacing each appearance of
T
by the type passed as the actual template parameter
(int
in this case) and then calls it. This process is automatically
performed by the compiler and is invisible to the programmer.
If the generic type T
is used as a function parameter,
the compiler can find out automatically which data type has to
instantiate without having to explicitly specify it within angle brackets.
So we can write:
int x, y;
get_max(x,y);
Because our template function includes only one template parameter
(class T
) and the function template itself accepts two parameters,
both of type
int i;
long l;
get_max(i, l); // Error!!! Different types!!!
This is not correct, since our function template expects two arguments of the same type, and in this call to it we use objects of two different types.
But we can define function templates that accept more than one type parameter, simply by specifying more template parameters between the angle brackets. For example:
template <class T, class U>
T get_min (T a, U b) {
return (a < b ? a : b);
}
This function template accepts two parameters of different types and returns
an object of the same type as the first parameter (T) that is passed. For
example, after that declaration we could call get_min()
with:
int i;
long l;
get_min(i, l); // OK; equivalent to get_min<int, long>(i, l);
even though the parameters have different types, since the compiler can determine the appropriate instantiation anyway.
We also have the possibility to write class templates, so that a class can have members that use template parameters as types. For example:
template <class T, class U> class mypair {
T first;
U second;
public:
mypair (T first, U second) : first{first}, second{second} {}
void print();
};
template <class T, class U>
void mypair<T, U>::print () {
cout << "(" << first << ", " << second << ")" << endl;
}
int main () {
mypair p(1, 7.5); // mypair<int, double> p(1, 7.5);
p.print();
return 0;
}
In case that we define a function member outside the declaration of the class template, we must always precede that definition with the template prefix.
If we want to define a different implementation for a template when a specific type is passed as template parameter, we can declare a specialization of that template.
For example, let's suppose that we have a very simple function calledmin_tmpl()
that returns miniumum value of its parameters. It uses
the < operator, so it is not suitable for those types for which this operator
is no defined, for instance char
array. For such types it would be
convenient to have a completely different implementation, so we decide to
declare a function template specialization.
template<class T> T min_tmpl (T a, T b) { // overload (a)
return a < b ? a : b;
}
template<class T> T* min_tmpl (T* a, T* b) { // overload (b)
return *a < *b ? a : b;
}
const char *min_tmpl (const char *n1, const char *n2) { // specialization (c)
return (strcmp(n1, n2) < 0 ? n1 : n2);
}
int main () {
int n = min_tmpl (10, 20); // template version (a)
cout << n << endl;
double d = min_tmpl (10.0, 20.0); // template version (a)
cout << d << endl;
int a1 = 5;
int a2 = 4;
int* a3 = min_tmpl(&a1, &a2); // template version (b)
cout << *a3 << endl;
const char *s1 = "One";
const char *s2 = "Two";
const char *s3 = min_tmpl (s1, s2); // specialized version (c)
cout << s3 << endl;
return 0;
}
Besides the template arguments that are preceded by the class or typename keywords, which represent types, templates can also have regular typed parameters, similar to those found in functions.
template <class T, int N = 10> class mysequence {
T memblock[N];
public:
void setmember(int n, T value) {
memblock[n]=value;
}
T getmember(int n) {
return memblock[n];
}
};
int main () {
mysequence <int> myints; // 10 ints
myints.setmember(0, 100);
cout << myints.getmember(0) << '\n';
mysequence <double, 5> myfloats; // 5 doubles
myfloats.setmember(3, 3.1416);
cout << myfloats.getmember(3) << '\n';
return 0;
}
Funktor jest klasą, która posiada implementację operatora ()
.
Tworzenie takich funktorów może być kłopotliwe i pracochłonne, szczególnie
jeżeli funktor ma być użyty jednokrotnie. Wtedy kod niepotrzebnie sie
komplikuje.
class X {
public:
int operator()(int n) { return 2 * n; }
};
Wyrażenia lambda są uproszczonym zapisem funktorów.
Prosty przykład:
auto mul2 = [](int n) -> int { return 2 * n; };
lub nawet prościej - typ zwracany zostanie odgadnięty przez kompilator:
auto mul2 = [](int n) { return 2 * n; };
Nawiasy kwadratowe, []
zaczynają definicję lambdy. Podobnie jak
funkcja, lambda może mieć parametry, po których następuje część wykonywalna.
Przekazywanie parametrów odbywa się tak samo jak dla zwykłych funkcji. Po
symbolu strzałki ->
może występować typ wartości zwracanej (trailing
return type), jednak najczęściej jest on wnioskowany z treści lambdy.
Część wykonywalna może być dowolna, aczkolwiek w praktyce ogranicza się ją do kilku instrukcji.
Lambda jest obiektem, tak więc posiada typ i może być przechowywana
(na ogólnych zasadach zasięgu zmiennych). Należy jednak pamiętać, że typ lambdy
jest definiowany przez kompilator i znany tylko dla niego. Dlatego przy
definiowanu instancji lambdy zawsze używamy specyfikacji auto
.
Lambdy świetnie nadają się do wykorzystania w funkcjach STL (głównie sekcja
algorithms
), np.:
int main() {
vector<int> v { 1, 2, 3 };
auto print = [] (int n) { cout << n << endl; };
for_each(v.begin(), v.end(), print);
}
W powyższym przykładzie algorytm for_each
powoduje wywołanie
lambdy dla każdego elementu kontenera.
Można to zapisać nawet prościej, wpisując lambdę bezpośrednio w miejsce wywołania. Istnieje ona wtedy tylko w obrębie wywołującej ją funkcji, czyli dokładnie tam gdzie jest potrzebna!
int main() {
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(), [](int n) { cout << n << endl; });
// find iterator to the first even number
auto it = find_if(v.begin(), v.end(), [](int n) { return n % 2 == 0; });
if (it != v.end()) cout << *it << endl;
}
Kiedy definiujemy lambdę, kompilator używa tej definicji do zbudowania tymczasowej klasy (funktora), o nazwie znanej tylko dla kompilatora.
Kod użytkownika:
[](int& n) { n *= 2; };
Po stronie kompilatora powstanie coś w stylu:
class _SomeCompilerGeneratedName_ {
public:
void operator()(int& n) const { n *= 2; }
};
Czyli program
int main() {
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(), [](int& n) { n *= 2; });
}
zostanie przez kompilator przetłumaczony mniej więcej tak:
class _SomeCompilerGeneratedName_ {
public:
void operator()(int& n) const { n *= 2; }
};
int main() {
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(), _SomeCompilerGeneratedName_{});
}
Widać różnicę!!!
Często chcielibyśmy odwołać się z wnętrza lambdy do obiektów z jej otoczenia (czyli zakresu w jakim została zdefiniowana). Moglibyśmy przesłać te obiekty jako parametry, ale to nie zadziała z algorytmami, ponieważ nie posiadają mechanizmu przekazywania dodatkowych parametrów.
Gdybyśmy pisali własny funktor, moglibyśmy przesłać odpowiednie parametry do konstruktora klasy. W przypadku lambd wykorzystujemy mechanizm przechwytywania kontekstu.
Kontekstem lambdy jest zbiór obiektów istniejących w momencie jej wywołania. Obiekty te mogą zostać przechwycone i użyte wewnątrz lambdy.
Przechwycenie obiektu przez nazwę oznacza, że wewnątrz lambdy powstaje
lokalna kopia tego obiektu. Wartość factor
nie może zostać
zmieniona - to tylko lokalna kopia.
int main() {
int factor = 10;
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(), [factor](int n) { cout << n * factor << endl; });
}
Przechwycenie obiektu przez referencję oznacza, że lambda może zmieniać jego zawartość (i ta zmiana będzie widoczna po zakończeniu działania lambdy). W poniższym przykładzie sumujemy elementy wektora.
int main() {
int total{};
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(), [&total](int n) { total += n; });
cout << total << endl;
}
Uwaga: Lambda jest obiektem i jak każdy obiekt może być kopiowana, przekazywana jako parametr, przechowywana w kontenerze, itp. Ma też swój wyznaczony zasięg i czas życia, który czasem może być inny niż zasięg / czas życia przechwyconego obiektu. W związku z tym należy zachować ostrożność przy przechwytywaniu obiektów lokalnych przez referencję, ponieważ może się zdarzyć, że lambda będzie posiadała referencję do już nieistniejącego obiektu.
Można przechwycić wszystkie zmienne w zasięgu lambdy wykorzystując tzw. default-capture.
int i;
double d;
vector<double> v(1000);
auto lam1 = [&]() { /* some code */ }; // capture everything by reference
auto lam1 = [=]() { /* some code */ }; // capture everything by value
Poniższa tabela pokazuje różne warianty lambda captures
[ ] ( ) { } | no captures |
[=] ( ) { } | captures everything by copy (not recommendded) |
[&] ( ) { } | captures everything by reference (not recommendded) |
[x] ( ) { } | captures x by copy |
[&x] () { } | captures x by reference |
[&, x] () { } | captures x by copy, everything else by reference |
[=, &x] () { } | captures x by reference, everything else by copy |
Jeżeli lambda posiada niepustą listę przechwytywanych obiektów, kompilator dodaje odpowiednie pola składowe do klasy funktora i konstruktor inicjalizujący te pola. Należy jednak pamiętać, że dla każdego obiektu przechwytywanego przez wartość tworzona jest kopia oryginału. Dla każdego obiektu przechwytywanego przez referencję, ta referencja jest przechowywana.
int main() {
int total{};
int offset{1};
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(),
[&total, offset](int& n) { total += n + offset; });
cout << total << endl;
}
Kompilator generuje kod podobny do następującego
class _SomeCompilerGeneratedName_ {
int& total_; // context captured by reference
int offset_; // context captured by value
public:
_SomeCompilerGeneratedName_(int& t, int o) : total_{t}, offset_{o} {}
void operator()(int& n) const {
total_ += n + offset_;
}
};
int main() {
int total{};
int offset{1};
vector<int> v { 1, 2, 3 };
for_each(v.begin(), v.end(), _SomeCompilerGeneratedName_{total, offset});
cout << total << endl;
}
Jak można zauważyć w powyższych przykładach, operator wywołania funkcji
generowany przez kompilator, ma atrybut const
. Oznacza to, że
jeżeli lambda przechwytuje obiekt przez wartość to nie może jej zmienić. Aby
było to możliwe używamy atrybutu mutable
, który powoduje
opuszczenie const
, jak poniżej:
[n]() mutable { ++n; }
// is equivalent to
class _SomeCompilerGeneratedName_ {
int n_;
public:
_SomeCompilerGeneratedName_(int n) : n_{n} {}
void operator()() { ++n_; }
};
Lambdy mogą i często są używane wewnątrz funkcji składowych. Ponieważ są to
osobne obiekty, nie mają one bezpośredniego dostępu do innych składowych
otaczającej klasy. Aby przechwycić te składowe (łącznie ze składowymi
prywatnymi), przechwytujemy wskaźnik this
.
class Filter {
vector<int> v;
int level;
public:
Filter(vector<int> v, int l) : v{v}, level{l} {}
void filter() {
remove_if(v.begin(), v.end(), [this](int n) { return n < level; });
}
};
Callable object jest ogólną nazwą każdego obiektu, który może być wywoływany jak funkcja.
W C / C++ mamy pojęcie wskaźnika funkcyjnego, który pozwala na zapamiętanie adresu dowolnej funkcji (pod warunkiem zgodności sygnatury). Jednak wskaźnik do funkcji zewnętrznej ma inną sygnaturę niż wskaźnik do funkcji składowej i inną niż lambda. Byłoby dobrze mieć ogólny wskaźnik do dowolnego obiektu callable o danej sygnaturze.
std::function
jest wzorcem, który może przechować dowolny obiekt
callable o zgodnej sygnaturze.
#include <functional>
void f() { cout << "Hello from f()" << endl; }
class SimpleCallBack {
std::function<void(void)> callback; // void (*callback)(void)
public:
SimpleCallBack(std::function<void(void)> callback) : callback{callback} {}
void execute() {
if(callback != nullptr) { // is the function valid?
callback(); // call like a normal function
}
}
};
class Functor {
public:
void operator()() { cout << "Hello from Functor" << endl; }
};
int main() {
SimpleCallBack(f).execute();
SimpleCallBack(Functor{}).execute();
SimpleCallBack([]() { cout << "Hello from lambda" << endl; }).execute();
}
Dziedziczenie polega na tworzeniu nowych klas na podstawie już istniejących.
Powiedzmy, że piszemy program dotyczący różnych pojazdów. Podstawowym elementem
programu będą właśnie owe pojazdy. Tak więc zdefiniujmy sobie klasę
pojazd
.
class pojazd {
public:
int predkosc;
int przyspieszenie;
int ilosc_kol;
int kolor;
};
Dla ułatwienia wszystkie składowe one publiczne. Chcemy teraz zdefiniować
ciężarówkę. Dopisujemy więc pole ladownosc
.
class ciezarowka {
public:
int predkosc;
int przyspieszenie;
int ilosc_kol;
int kolor;
int ladownosc;
};
Obie klasy są niemal jednakowe. Odróżnia je zaledwie jeden detal. Jest to
ladownosc
w klasie drugiej. Zatem klasa ciezarowka
jest jakby rozbudowaną klasą pojazd
. Można zrobić to szybciej i
krócej korzystając z dziedziczenia. Zamiast pisać od początku całą klasę
ciezarowka
wystarczy poinformować kompilator, że ta klasa jest
rozwiniętą wersją klasy pojazd
. W C++ robi się to tak:
class ciezarowka : public pojazd {
public:
int ladownosc;
};
W wyniku takiego zapisu otrzymaliśmy klasę ciezarowka
, która
jest pochodną klasy pojazd. Oznacza to, że zawiera ona wszystkie wady i zalety
klasy swojego przodka. Za nazwą klasy pochodnej stawiamy dwukropek a następnie
określamy sposób dziedziczenia. Ustala się to za pomocą znanych etykiet.
Dziedziczenie prywatne oznacza, że wszystkie składniki klasy bazowej staną się
niedostępne w klasie pochodnej. Niezależnie od sposobu dziedziczenia, prywatne
składniki klasy podstawowej zawsze pozostaną niedostępne w klasie pochodnej!
Podczas dziedziczenia chronionego, składniki publiczne i chronione w klasie
pochodnej będą chronione. Dziedziczenie publiczne jest najprostsze i chyba
najczęściej stosowane. Praktycznie nie powoduje żadnych zmian. Dostęp do
składników odziedziczonych jest nadal taki sam, jak w klasie podstawowej.
składniki w klasie podstawowej | sposób dziedziczenia | składniki w klasie pochodnej |
---|---|---|
prywatne chronione publiczne |
prywatne | niedostępne niedostępne niedostępne |
prywatne chronione publiczne |
chronione | niedostępne chronione chronione |
prywatne chronione publiczne |
publiczne | niedostępne chronione publiczne |
Podczas dziedziczenia zawartość klasy bazowej staje się automatycznie
zawartością klasy pochodnej. Zatem wszystkie składniki i funkcje składowe stają
się dostępne w klasie pochodnej. Ta dostępność jest uwarunkowana sposobem
dziedziczenia. W powyższym przykładzie składowe były publiczne. Gdybyśmy jednak
użyli składowych prywatnych, nie mielibyśmy do nich dostępu. Jak w takim razie
można odnieść się do prywatnych składników klasy podstawowej z wnętrza klasy
pochodnej? Z myślą o dziedziczeniu została zaprojektowana etykieta
protected
. Składowe klasy oznaczone tą etykietą są traktowane w
klasie podstawowej jako prywatne. Jednakże w przeciwieństwie do składników
prywatnych są dostępne w klasach pochodnych.
Składowe, które NIE są dziedziczone:
W C++ możemy mówić o dwóch typach polimorfizmu. Jeden z nich poznaliśmy: polega on na wykorzystaniu mechanizmu przeładowania funkcji i operatorów (polimorfizm czasu kompilacji). Drugim typem jest polimorfizm działający w czasie wykonywania programu. Poniższy schemat przedstawia ideę.
Jednym z kluczowych cech dziedziczenia jest fakt, że wskaźnik do klasy pochodnej jest kompatybilny (w sensie typu) ze wskaźnikiem do klasy bazowej. Oznacza to, że wskaźnik do klasy bazowej może z powodzeniem być użyty również do wskazywania na obiekty klas pochodnych.
Rozpatrzmy następujący przykład:
// virtual members
#include <iostream>
using namespace std;
class Polygon {
protected:
int width, height;
public:
Polygon(int w, int h) : width(w), height(h) {}
virtual int area() {
return 0;
}
};
class Rectangle: public Polygon {
public:
int area() {
return width * height;
}
};
class Triangle: public Polygon {
public:
int area() {
return width * height / 2;
}
};
int main () {
Rectangle rect(4, 5);
Triangle trgl(4, 5);
Polygon poly(4, 5);
Polygon* ppoly1 = ▭
Polygon* ppoly2 = &trgl;
Polygon* ppoly3 = &poly;
cout << ppoly1-<area() << endl;
cout << ppoly2-<area() << endl;
cout << ppoly3-<area() << endl;
return 0;
}
Składowa wirtualna jest funkcją składową, która może być zredefiniowana w
klasie pochodnej z zachowaniem jej własności w momencie wywałania jej przez
wskaźnik / referencję. Aby funkcja stała się wirtualna poprzedzamy jej
deklarację słowem kluczowym virtual
.
W powyższym przykładzie wszystkie trzy klasy (Polygon, Rectangle,
Triangle
) mają te same składowe. Funkcja area()
została
zadeklarowana jako wirtualna w klasie bazowej, ponieważ będzie później
zredefiniowana w klasach pochodnych. Składowe niewirtualne również mogą być
redefiniowane w klasach pochodnych, ale jeżeli wywołamy je przy pomocy
referencji do klasy bazowej, faktycznie zostanie wywołana funkcja z klasy
bazowej (to wiązanie jest dokonywane na etapie kompilacji - early
binding). Gdybyśmy usunęli atrybut virtual
z deklaracji
funkcji area()
, to wszystkie wywołania tej funkcji zwróciłyby
wartość zero (ponieważ tę wartość zwraca funkcja z klasy bazowej).
Tak więc atrybut virtual
umożliwia tzw. late binding,
czyli decyzja o tym, która z funkcji zostanie wywołana jest podejmowana dopiero
w czasie wykonania programu, na podstawie rzeczywistego typu obiektu, a nie typu
wkaźnika / referencji. W powyższym przykładzie odpowiednia wersja funkcji
area()
jest wywoływana dla obiektu klasy Rectangle
,
Triangle
i Polygon
.
Zauważmy, że w klasie Polygon
wartość zwracana przez funkcję
area()
jest arbitralna. Wielokąt nie jest żadną konkretną figurą i
nie można mówić o jego powierzchni. W takich sytuacjach, kiedy klasa jest
używana wyłącznie jako baza dla innych klas, możemy zdefiniować klasę
abstrakcyjną. Klasa taka może (a nawet powinna) mieć wirtualną / e funkcję
/ e składową / e, ale bez podawania jej / ich definicji (które w tym przypadku
nie mają sensu). Funkcje te zwane są funkcjami czysto wirtualnymi - pure
virtual functions i w programie ich definicje są zastępowane przez =
0
.
Nowa wersja klasy Polygon
mogłaby wyglądać następująco:
class Polygon {
protected:
int width, height;
public:
Polygon(int w, int h) : width(w), height(h) {}
virtual int area() = 0;
};
Klasy, które zawierają przynajmniej jedną funkcję czysto wirtualną są
nazywane klasami abstrakcyjnymi. Klasy abstrakcyjne nie mogą być używane
to tworzenia obiektów (czyli nie możemy użyć instrukcji Polygon
poly;
). Możemy natomiast definiować odpowiednie wskaźniki i wykorzystywać
ich polimorficzne możliwości.
Ostatni program demonstruje cechy polimorfizmu, łącząc je z dynamiczną alokacją pamięci, zastosowaniem konstruktorów i inicjalizacji.
class Polygon {
protected:
int width, height;
public:
Polygon (int w, int h) : width(w), height(h) {}
virtual int area (void) = 0;
void printarea() {
cout << this-<area() << '\n';
}
};
class Rectangle: public Polygon {
public:
Rectangle(int a,int b) : Polygon(a,b) {}
int area() {
return width*height;
}
};
class Triangle: public Polygon {
public:
Triangle(int a,int b) : Polygon(a,b) {}
int area() {
return width*height/2;
}
};
int main () {
Polygon * ppoly1 = new Rectangle (4,5);
Polygon * ppoly2 = new Triangle (4,5);
ppoly1-<printarea();
ppoly2-<printarea();
delete ppoly1;
delete ppoly2;
return 0;
}
Każda klasa, która posiada co najmniej jedną funkcję wirtualną, ma dodatkowe pole będące adresem tzw. tablicy funkcji wirtualnych. Jest to tablica adresów funkcji właściwych dla danej klasy. Umożliwia ona zastosowanie odpowiedniej do typu obiektu funkcji, niezależnie od typu wskaźnika / referencji
Jeżeli w jakimś miejscu programu zajdzie nieoczekiwana sytuacja, programista
piszący ten kod powinien zasygnalizować o tym. Dawniej polegało to na zwróceniu
specyficznej wartości, co nie było zbyt szczęśliwym rozwiązaniem, bo sygnał
musiał być taki jak wartość zwracana przez funkcję. W przypadku obsługi sytuacji
wyjątkowej mówi się o obiekcie sytuacji wyjątkowej, co często zastępowane jest
słowem wyjątek. W C++ wyjątki się "rzuca", służy do tego instrukcja
throw
.
Tam gdzie spodziewamy się wyjątku umieszczamy blok try
, w którym
umieszczamy "podejrzane" instrukcje. Za tym blokiem muszą (tzn. musi
przynajmniej jedna) pojawić się bloki catch
. Wygląda to tak:
try {
fun(); // fun() can throw an exception
} catch(ExceptionType e) {
// exception handling
}
W instrukcji catch
umieszczamy typ jakim będzie wyjątek. Rzucić
możemy wyjątek dowolnego typu (wbudowany, biblioteczny, własna klasa wyjątku),
dlatego tu określamy co nas interesuje. Nazwa tego obiektu nie jest konieczna,
ale jeżeli chcemy znać wartość musimy ten obiekt nazwać. Bloków catch może być
więcej, najczęściej tyle ile możliwych typów do wyłapania. Co ważne jeżeli
rzucimy wyjątek konkretnego typu to "wpadnie" on do pierwszego pasującego catch
nawet jeżeli inne nadają się lepiej. Dotyczy to zwłaszcza klas dziedziczonych.
Można zadać pytanie, po co nam wyjątki. Możemy przecież użyć starych, dobrych instrukcji warunkowych. Przyczyn jest kilka:
Wbrew pozorom nie wszystko da się zrobić bez użycia wyjątków. Jednym z przypadków jest wystąpienie błędu w obrębie konstruktora. Jak wtedy zasygnalizować ten błąd? Konstruktor z definicji nie zwraca wartości. Natomiast może rzucić wyjątek. To jest podstawowa cecha podejścia RAII (Resource Acquisition Is Initialization). Zadaniem konstruktora jest zapewnienie inicjalizacji pól klasy i środowiska, w jakim będą wykonywane funkcje składowe. To często wymaga akwizycji zasobów, takich jak pamięć, pliki, sockety, itp.
SomeClass {
vector<double> v(100000); // needs to allocate memory
ofstream os("myfile"); // needs to open a file
...
};
W obu przypadkach musielibyśmy sprawdzić, czy alokacja pamięci dla wektora /
otwarcie pliku się powiodło. Ponieważ konstruktory klasy vector
jak
i ofstream
generują wyjątki w przypadku niepowodzenia, nie musimy
tego robić i dodatkowo nie ma obawy, że zapomnimy to zrobić i program nie będzie
działał poprawnie. Jest to szczególnie istotne dla klas złożonych z wielu
zależnych od siebie obiektów
Dla zwykłych funkcji możemy albo zwrócić kod błędu, bądź ustawić globalną
zmienną (np. errno
). Ustawienie zmiennej globalnej nie działa za
dobrze - musimy testować ją bezpośrednio po zmianie wartości, inaczej inna
funkcja może ją ponownie zmienić. Jeszcze gorzej wygląda sprawa w przypadku
programów wielowątkowych.
Zwrócenie kodu błędu przez funkcję też nie zawsze jest łatwe. Często każda wartość jest wartością poprawną i trudno jest ustalić jakąś jako błędną.
int divide(int a, int b) {
if(b != 0) return a / b;
return ???; // any int value is a legal quotient
};
W takich przypadkach musielibyśmy zwracać pary wartości (wynik i kod błędu).
Nie powinno się używać wyjątków jako kolejnej wartości zwracanej przez funkcję. Kluczową techniką jest RAII (resource acquisition is initialization), która używa destruktorów klas w celu wymuszenia odpowiedniego zarządzania zasobami.
void f(string s) {
File_handle f(s,"r"); // File_handle's constructor opens the file called "s"
... // use f
} // here File_handle's destructor closes the file
Jeżeli część funkcji f()
oznaczona "use f" wygeneruje wyjątek,
destruktor File_handle
jest mimo to wywołany (zapewnia to mechanizm
obsługi wyjątków) i plik jest prawidłowo zamknięty, w przeciwieństwie do
następującego "starego" przypadku
void old_f(const char* s) {
FILE* f = fopen(s, "r"); // open the file named "s"
... // use f
fclose(f); // close the file
}
Tutaj, jeżeli funkcja old_f()
zgłosi wyjątek (lub użyje zdania
return
, plik nie zostanie zamknięty.
W większych systemach, funkcja, która wykryła błąd musi przekazać go dalej do innej funkcji, odpowiedzialnej za obsługę tego błędu. Taka "propagacja błędu" często przechodzi przez dziesiątki funkcji, z których dopiero ostatnia zajmuje się obsługą (bo tylko ona ma wystarczająco informacji, żeby wiedzieć, co należy zrobić w konkretnym przypadku). Wyjątki pozwalają na bardzo prostą propagację błędów, nie angażując w ten proces funkcji pośredniczących.
void f1() {
try {
// ...
f2();
// ...
} catch (some_exception& e) {
// ...code that handles the error...
}
}
void f2() { ...; f3(); ...; }
void f3() { ...; f4(); ...; }
void f4() { ...; f5(); ...; }
void f5() { ...; f6(); ...; }
void f6() { ...; f7(); ...; }
void f7() { ...; f8(); ...; }
void f8() { ...; f9(); ...; }
void f9() { ...; f10(); ...; }
void f10() {
// ...
if ( /*...some error condition...*/ )
throw some_exception();
// ...
}
Jak widać tylko dwie "końcowe" funkcje: ta, która wykryła błąd,
f10()
i ta odpowiedzialna za jego obsługę, f1()
, są
"zaśmiecone" informacją o ewentualnym błędzie. Pozostałe nie uczestniczą w
propagacji.
Jednak używając kodów powrotu, wszystkie funkcje muszą aktywnie uczestniczyć w procesie propagacji błędu.
int f1() {
// ...
int rc = f2();
if (rc == 0) {
// ...
} else {
// ...code that handles the error...
}
}
int f2() {
// ...
int rc = f3();
if (rc != 0) return rc;
// ...
return 0;
}
// ... more functions as above
// ... f3() ... f8()
int f9() {
// ...
int rc = f10();
if (rc != 0) return rc;
// ...
return 0;
}
int f10() {
// ...
if (...some error condition...)
return some_nonzero_error_code;
// ...
return 0;
}
Wady:
f2()
do f9()
niepotrzebnymi
warunkami, które ich nie dotycząCo w przypadku, gdy zawodzi destruktor?
Destruktor jest jednym z miejsc, gdzie nie wolno generować wyjątków. Dlaczego?
class A {
public:
void f() {
// ...
throw Ex1();
}
~A() {
// ...
throw Ex2(); // Design error
}
};
int main() {
try{
A a;
a.f(); // Before jumping to 'catch', destructor for 'a' is called
} catch(Ex1 e) {
// ...
} catch(Ex2 e) {
// ...
}
}
Zasadą C++ jest, że w momencie wykrycia wyjątku przez blok try
,
zanim sterowanie zostanie przeniesione do bloku catch
, zostaną
wywołane destruktory dla wszystkich lokalnych obiektów w pełni skonstruowanych.
W przypadku powyżej oznacza to, że podczas działania funkcji f()
na
obiekcie a
zostanie wyłapany wyjątek typu Ex1
(ponieważ generuje go funkcja f()
). Ale zanim sterowanie zostanie
przeniesione do bloku catch
, zostanie uruchomiony destruktor dla
obiektu a
, który wygeneruje kolejny wyjątek, tym razem typu
Ex2
! W takim razie jak powinna wyglądać obsługa takiej sytuacji? Do
którego z bloków terminate()
, która zabija proces!
Dlatego jako regułę przyjmuje się, że destruktor nie generuje wyjątków.
Należy pamiętać, że zasada o destrukcji obiektów lokalnych w momencie wyłapania wyjątku dotyczy obiektów w pełni skonstruowanych. Jeżeli wyjątek jest generowany w konstruktorze, to budowa obiektu nie zostanie ukończona i destruktor nie zostanie wywołany. Oznacza to, że przydzielone do tej pory zasoby powinny zostać zwolnione ręcznie, bądż, co lepsze, korzystając z RAII (klas automatycznie zwalniających zasoby). Na przykład zamiast zwykłych wskaźników do alokacji pamięci lepiej używać tzw. smart pointers, które mają wbudowany mechanizm zwalniania zaalokowanej pamięci.
C++, w odróżnieniu od większości języków z wyjątkami, jest bardzo liberalny w
sensie typów, jakie mogą być generowane w instrukcji throw
.
Formalnie można wygenerować wyjątek dowolnego typu. Rodzi to kolejne pytanie:
jakich typów powinno się używać?
Po pierwsze lepiej używać obiektów, niż typów wbudowanych. Jeżeli to możliwe,
należy korzystać z typów pochodnych bibliotecznej klasy
std::exception
. Umożliwia to wyłapanie takich wyjątków przez blok
catch(std::exception e)
. Możemy oczywiście stosować bardziej
specyficzne wyjątki, np std::runtime_error
.
Najpopularniejszą praktyką jest rzucanie obiektów tymczasowych, np.
class MyException : public std::runtime_error {
public:
MyException() : std::runtime_error("MyException") {}
};
void f() {
// ...
throw MyException();
}
Tutaj tymczasowy obiekt typu MyException
jest tworzony i
rzucany. Klasa MyException
dziedziczy z klasy
std::runtime_error
, a ta z kolei z std::exception
.
class MathException : public runtime_error {
public:
MathException(const string& error) : runtime_error(error) {}
~MathException() {}
const char* what() const noexcept {
return runtime_error::what();
}
};
class IntOverflow : public MathException {
string op;
int op1, op2;
public:
IntOverflow(const string& error, const string& op, int op1, int op2)
: MathException(error), op(op), op1(op1), op2(op2) {}
~IntOverflow() {}
const char* what() const noexcept {
static char cs[256];
sprintf(cs, "%s: %d %s %d", MathException::what(), op1, op.c_str(), op2);
return cs;
}
};
class DivZero : public MathException {
string op;
int op1, op2;
public:
DivZero(const string& error, const string& op, int op1, int op2)
: MathException(error), op(op), op1(op1), op2(op2) {}
~DivZero() {}
const char* what() const noexcept {
static char cs[256];
sprintf(cs, "%s: %d %s %d", MathException::what(), op1, op.c_str(), op2);
return cs;
}
};
int add(int x, int y) {
if ((x > 0 && y > 0 && x > INT_MAX - y) ||
(x < 0 && y < 0 && x < INT_MIN - y))
throw IntOverflow("IntOverflow", "+", x, y);
cout << x << " + " << y << " = " << x+y << endl;
return x + y;
}
int divide(int x, int y) {
if (y == 0) throw DivZero("DivZero", "/", x, y);
cout << x << " / " << y << " = " << x/y << endl;
return x / y;
}
int main () {
try {
add (1,2);
add (INT_MAX, -2);
add (INT_MAX, 2);
}
catch (MathException& me) {
cout << me.what() << endl;
}
try {
add (INT_MIN, 2);
add (INT_MIN, -2);
}
catch (MathException& me) {
cout << me.what() << endl;
}
try {
divide (4,2);
divide (4,0);
}
catch (MathException& me) {
cout << me.what() << endl;
}
}
Jeżeli klauzula catch
przechwytuje wyjątek przez referencję, to
będzie się on zachowywał polimorficznie (pod warunkiem, że odpowiednie funkcje
będą wirtualne: tu na przykład funkcja print_error()
. W powyższym
przykładzie zostanie prawidłowo rozpoznany typ wyjątku, mimo, że wyłapujemy
tylko wyjątek bazowy (MathException
).
Kontener jest sekwencją elementów (obiektów), dysponującym mechanizmami alokacji i dealokacji pamięci i dostępu do poszczególnych elementów przy użyciu iteratorów i / lub funkcji składowych
Standardowe kontenery implementują struktury danych takie jak:
Kontenery oszczędzają bardzo dużo czasu poświęcanego na reimplementację typowego kodu. Oprócz tego biblioteka ma standardowy interfejs dla funkcji składowych. Dzięki temu w prosty sposób mogą być stosowane w algorytmach STL.
Można wyróżnić cztery typy kontenerów:
Kontenery sekwencyjne są używane do tworzenia struktur danych obiektów tego samego typu w porządku liniowym. Do kontenerów sekwencyjnych zaliczamy:
array
tablica statycznavector
tablica dynamicznaforward_list
list jednokierunkowalist
list dwukierunkowadeque
kolejka, z możliwościa dodawania / usuwania elementów na
początku i na końcuWhile std::string
is not included in most container lists, it does in fact meet the requirements of a SequenceContainer
.
Kontenery adaptacyjne nie są pełnymi kontenerami a raczej otoczkami
(wrappers) innych kontenerów, takich jak vector
,
deque
, lub list
. Kontenery te limitują interfejs
użytkownika zgodnie z przeznaczeniem kontenera.
Rozpatrzmy kontener std::stack
, który praktycznie jest kolejką
LIFO. Deklaracja stosu wygląda następująco:
template<
class T,
class Container = std::deque<T>
> class stack;
Czyli implementacja kontenera polega na wykorzystaniu
std::deque<T>
. Biblioteka daje możliwość zmiany
std::deque
na inny typ kontenera, który powinien spełniać
następujące kryteria:
back()
, push_back()
i
pop_back()
Kontenery std::vector
, std::deque
i
std::list
spełniają te wymagania i mogą być użyte jako kontener
bazowy.
Standardowe kontenery adaptacyjne to:
stack
(LIFO)queue
(FIFO)priority_queue
kolejka umożliwiająca dostęp do największego
elementu w stałym czasieKontenery asocjacyjne są stosowane do posortowanych struktur danych o czasie wyszukiwania według klucza rzędu $O(\log n)$.
Wśród kontenerów asocjacyjnych wyróżniamy takie, które wymagają unikalnych kluczy i takie, które dopuszczają więcej niż jeden element z danym kluczem
set
: kolekcja elementów posortowanych według kluczamap
: kolekcja par (klucz, wartość) posortowana według
kluczaset
i map
są zwykle implementowane z
wykorzystaniem drzew czerwono-czarnychmultiset
multimap
Dla każdego z tych kontenerów można podać komparator kluczy, np. dla
std::set
template<
class Key,
class Compare = std::less<Key>,
class Allocator = std::allocator<Key>
> class set;
Defaultową funkcją porównującą dla kontenerów asocjacyjnych jest
std::less()
. Może ona zostać zmieniona w czasie deklaracji
kontenera
Nieuporządkowane kontenery asocjacyjne implementują nieposortowaną strukturę danych, wykorzystującą technikę haszowania. W najgorszym przypadku czas dostępu jest $O(n)$, ale na ogół znacznie mniejszy.
Dla wszystkich kontenerów tego typu dostęp do danych zapewnia klucz. Podobnie do posortowanych kontenerów, kontenery nieposortowane możemy podzielić na te, które wymagają unikalnych kluczy i te, które dopuszczają duplikaty:
unordered set
unordered_map
unordered_multiset
unordered_multimap
Również tutaj podczas tworzenia kontenera możemy nadpisać jego defaultowe ustawienia:
template<
class Key,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>,
class Allocator = std::allocator<Key>
> class unordered_set;
Standardowe kontenery ułatwiają tworzenie nowych systemów bez potrzeby reimplementacji najczęściej używanych struktur danych i algorytmów. Do ich zalet należy:
Systemy wykorzystujące standardowe kontenery STL są łatwiejsze do zrozumienia niż te, które używają własnych, nieznanych szerzej implementacji.
std::vector
std::vector
jest klasą wzorcową reprezentującą tablicę
dynamiczną, zwykle alokowaną na stercie (chyba, że w momencie deklaracji wektora
zmienimy standardowy alokator). Rozmiar wektora zwiększa lub zmniejsza się
automatycznie podczas dodawania / usuwania elementów. Należy jednak pamiętać, że
podlega tym samym narzutom czasowym co inne dynamiczne alokowane obiekty.
std::vector
ułatwia operacje, które mogą być skomplikowane dla
zwykłych tablic, np.:
Ponieważ std::vector
jest obiektem, jego destruktor jest wołany
automatycznie gdy kończy się jego zakres. Destruktor (między innymi) zwalnia
zaalokowaną pamięć, ograniczając możliwość wycieków pamięci.
Jak inne kontenery std::vector
jest klasą wzorcową, co oznacza,
że należy explicite podać typ elementów wektora (od C++-17 kompilator zwykle
jest w stanie wywnioskować ten typ na podstawie inicjalizatora).
//Declare an empty std::vector that will hold ints
std::vector<int> v;
Można od razu zainicjalizować wektor podając tzw. listę inicjalizacyjną:
//v will be sized to the length of the initializer list
std::vector<int> v1 {-1, 3, 5, -8, 0};
std::vector v2 {-1, 3, 5, -8, 0}; // since C++-17
Wektory mają przeładowany konstruktor kopiujący i operator przypisania, więc kopiowanie ich jest proste.
auto v3(v1); // copy ctor
v2 = v1; // assignment operator
std::vectori
posiada interfejs umożliwiający prosty dostęp do
danych kontenera:
front()
pierwszy elementback()
ostatni elementoperator []
bez sprawdzania zakresu indeksuat()i
ze sprawdzaniem zakresu indeksu (rzuca wyjątek przy
próbie wyjścia poza tablicę)data()
wskaźnik do tablicy przechowującej dane
std::cout << "v1.front() is the first element: " << v1.front() << std::endl;
std::cout << "v1.back() is the last element: " << v1.back() << std::endl;
std::cout << "v1[0]: " << v1[0] << std::endl; // no bounds check
std::cout << "v1.at(4): " << v1.at(4) << std::endl; // bounds are checked
Wniosek: Używajmy operatora []
tylko w momencie, gdy jesteśmy
pewni, że indeksacja jest poprawna. W pozostałych przypadkach bezpieczniejsze
jest użycie funkcji at()
.
data()
Funkcja data()
zwraca adres wewnętrznego bufora danych klasy
std::vestor
. Może to być użyteczne, jeżeli potrzebujemy interfejsu
do tablicy w standardzie C, np.:
void carr_func(int* vec, size_t size) {
std::cout << "carr_func - vec: " << vec << std::endl;
}
Powyższa funkcja przyjmuje argument typu int*
(tablicę w stylu
C). Wywołanie tej funkcji z obiektem klasy std::vector
spowoduje
błąd kompilacji. Możemy to umożliwić wykorzystując funkcję
data()
//carr_func(v1, v1.size()); // Error:
carr_func(v1.data(), v1.size()); // OK:
Do dodawania / usuwania elementów wektora można wykorzystać jedną z funkcji:
push_back()
emplace_back()
pop_back()
clear()
resize()
emplace()
insert()
erase()
w celu dodania nowego elementu na koniec wektora używamy funkcji
push_back()
lub emplace_back()
. Te funkcje działają
bardzo podobnie, z jedną zasadniczą różnicą: emplace_back()
pozwala
na przekazanie w wywołaniu argumentów konstruktora, czyli nowy element może być
utworzony w miejscu. Jeżeli dodajemy istniejący obiekt (lub chcemy stworzyc
obiekt tymczasowy) to używamy push_back()
.
Dla typów wbudowanych rozróżnienie jest trywialne:
int x = 0;
v2.push_back(x); // adds element to end
v2.emplace_back(10); // constructs an element in place at the end
Dla bardziej skomplikowanych obiektów emplace_back()
bywa
użyteczne
// Constructor: circular_buffer(size_t size)
std::vector<circular_buffer<int>> vb;
vb.emplace_back(10); // forwards the arg to the circular_buffer constructor to make a buffer of size 10
Aby dodać element w dowolnym miejscu wektora używamy funkcji
insert()
lub emplace
(różnica jak powyżej). Dodatkowo
przekazujemy funkcji iterator do miejsca, gdzie należy wstawić element
v2.insert(v2.begin(), -1); // insert an element at the beginning
v2.emplace(v2.end(), 1000); //construct and place an element at the iterator
Aby usunąć wyznaczony element wywołujemy erase()
z iteratorem, wskazującym
na ten element.
v2.erase(v2.begin()); // erase element - also needs an iterator
Ostatni element usuwamy wywołując pop_back()
v2.pop_back(); // removes last element
Uwaga: funkcja nie zwraca wartości! Jeżeli chcemy przeczytać wartość
usuwanego elementu, można najpierw wywołać end()
.
Wywołanie clear()
usuwa wszystkie elementy wektora i ustawia
size()
na 0 (ale capacity()
zostaje niezmienione).
Wektor często zajmuje więcej pamięci niż to wynika z jego rozmiaru
(zwracanego przez size()
. Wynika to z tego, że pamięć wektora
alokowana jest blokami, aby uniknąć każdorazowej realokacji. Ponadto usunięcie
elementu nie powoduje zwolnienia zajmowanej przez niego pamięci.
Są jednak funkcje, pomagające w zarządzaniu pamięcią wektora:
reserve()
shrink_to_fit()
resize()
clear()
Realokacja pamięci jest kosztowna. Dlatego jeżeli znamy (przynajmniej w
przybliżeniu) docelowy rozmiar wektora, lepiej zaalokować od razu cały obszar,
używając np. funkcji reserve()
.
v2.reserve(10) // increase vector capacity to 10 elements
reserve()
może tylko zwiększyć rozmiar wektora. Jeżeli żądana
pojemność jest mniejsza od aktualnej, funkcja nic nie zmienia
Jeżeli chcemy zmniejszyć pojemność wektora do jego aktualnego rozmiaru,
korzystamy z funkcji shrink_to_fit()
.
// If you have reserved space greater than your current needs, you can shrink the buffer
v2.shrink_to_fit();
Funkcja clear()
może być użyta do usunięcia wszystkich elementów
wektora bez zmiany jego pojemności.
v2.clear()
Funkcja resize()
zmniejsza lub zwiększa rozmiar wektora. Przy
zwiększeniu rozmiaru, nowe elementy zostaną wyzerowane (można też podać inną
wartość początkową). Jeżeli podamy mniejszy rozmiar niż aktualny, elementy z
końca wektora zostaną usunięte.
v2.resize(7); // resize to 7. The new elements will be 0-initialized
v2.resize(10, -1); // resize to 10. New elements initialized with -1
v2.resize(4); // shrink and strip off extra elements
Po wektorze, jak i po każdym innym kontenerze, możemy wygodnie poruszać się
przy pomocy skróconej wersji pętli for
. Aby na przykład wydrukować
wszystkie elementy wektora możemy napisać:
std::cout << std::endl << "v2: " << std::endl;
for (const auto& t : v2) {
std::cout << t << " ";
}
std::cout << std::endl;
Standardowe algorytmy znajdują się w <algorithms>
.
STL Algorithms Library składa się z następujących części:
template <typename RandomAccessIterator>
void sort(RandomAccessIterator a, RandomAccessIterator b);
template <typename RandomAccessIterator>
void stable_sort(RandomAccessIterator a, RandomAccessIterator b);
find()
template <typeame InputIterator, typename T>
InputIterator find(InputIterator first, InputIterator last, const T& value);
Znajduje pierwszą pozycję w zakresie [first, last)
, w której
znajduje się wartość value
. Jeżeli nie ma takiej wartości funkcja
zwraca last
.
find_if()
template <typeame InputIterator, typename Predicate>
InputIterator find_if(InputIterator first, InputIterator last, Predicate p);
Znajduje pierwszą pozycję w zakresie [first, last)
, dla której
spełniony jest predykat p
. Jeżeli nie ma takiej pozycji funkcja
zwraca last
.
count()
template<class InputIt, class T>
typename iterator_traits<InputIt>::difference_type
count(InputIt first, InputIt last, const T &value);
Funkcja zlicza elementy równe value
.
count_if()
template<class InputIt, class UnaryPredicate>
typename iterator_traits<InputIt>::difference_type
count_if(InputIt first, InputIt last, UnaryPredicate p);
Funkcja zlicza elementy dla których predykat p
jest prawdziwy.
copy()
template<class InputIterator, class OutputIterator>
void copy(InputIterator first, InputIterator last, OutputIterator result);
Algorytm kopiuje sekwencję obiektów [first, last)
na sekwencję
[result, result + (last - first))
.
swap()
template <class T> void swap(T &a, T &b);
Algorytm zamienia wartościami obiekty przekazane przez referencję.
transform()
template <class InputIterator, class OutputIterator, class UnaryOperation>
OutputIterator transform(InputIterator first, InputIterator last,
OutputIterator result, UnaryOperation op);
transform
wywołuje funkcję op
dla każdego obiektu
sekwencji [first, last)
i wynik umieszcza odpowiednio na pozycji
[result, result + (last - first))
.
replace()
template <class ForwardIterator, class T>
void replace(ForwardIterator first, ForwardIterator last,
const T& old_value, const T& new_value);
replace
modyfikuje obiekty sekwencji [first, last)
tak, że obiekty o wartości old_value
przyjmują wartość
new_value
podczas gdy pozostałe nie ulegają zmianie.
fill()
template <class ForwardIterator, class T>
void fill(ForwardIterator first, ForwardIterator last, const T& value);
fill
przypisuje wartość value
do każdego
obiektu sekwencji [first, last)
.
generate()
template <class ForwardIterator, class Generator>
void generate(ForwardIterator first, ForwardIterator last, Generator gen);
generate
przypisuje do każdego obiektu sekwencji [first,
last)
wartość generowaną przez last - first
kolejnych
wywołań funkcji gen
.
accumulate()
template <class InputIt, class T>
T accumulate(InputIt first, InputIt last, T init);
accumulate
oblicza sumę wartości init
i elementów sekwencji [first,
last)
.
inner_product()
template <class InputIt1, class InputIt2, class T>
T inner_product(InputIt1 first1, InputIt1 last1, InputIt2 first2, T init);
inner_product
oblicza sumę wartości init
i iloczynu
skalarnego elementów sekwencji [first1, last1)
i [first2,
first2 + (last1 - first1))
.
Przykład:
#include <iostream>
#include <numeric>
using namespace std;
int main() {
double u[3] = { 1.1, 2.2, 3.3 };
double v[3] = { 11.1, 22.2, 33.3 };
double sum = accumulate(u, u+3, 0.0);
double ip = inner_product(u, u+3, v, 0.0);
cout << "sum = " << sum << endl;
cout << "inner product = " << ip << endl;
return 0;
}
Wymienione wyżej algorytmy to tylko wybrane przykłady z bardzo obszernej listy. Po kompletny spis możliwości biblioteki należy sięgnąć do dokumentacji.
Więcej przykładów z użyciem algorytmów można znaleźć w źródłach na stronie przedmiotu
Iterators act as a bridge that connects algorithms to STL containers and allows the modifications of the data present inside the container. They allow you to iterate over the container, access and assign the values, and run different operators over them, to get the desired result.
Iterators in C++ are classified into 5 major categories based on their functionality:
STL CONTAINER | ITERATOR SUPPORTED |
---|---|
Vector | Random-Access |
List | Bidirectional |
Dequeue | Random-Access |
Map | Bidirectional |
Multimap | Bidirectional |
Set | Bidirectional |
Multiset | Bidirectional |
Stack | Does not support any iterator |
Queue | Does not support any iterator |
Priority-Queue | Does not support any iterator |
The input iterator is the simplest and least used iterator among the five main iterators of C++. It sequentially uses this iterator for input operations. In other words, you can say that it is used to read the values from the container. It is a one-way iterator. Once you have read a value, you are only allowed to increment the iterator. You can not decrement the input iterator in any way.
Features:
==, !=
)*
)++
, prefix and postfix)Output iterators serve exactly the opposite purpose as the input iterators. This iterator is sequentially used for output operations. In other words, you can say that it is used to assign the values. But it can not access the values. It is complementary to the input iterators where you can access the values, but can not assign them. Like the input iterator, it is a one-way iterator. Once you have assigned a value, you are only allowed to increment the iterator, and you can not decrement the output iterator in any way.
Features - as above
Forward iterators serve the purpose of both the input and output iterators. That’s why these iterators are said to be the combination of input and output operators. You can access the values (functionality of input iterators) as well as assign the values (functionality of output iterators). As the name suggests, these iterators are one-way iterators. You can only traverse in the forward direction.
Features - as above
Bidirectional iterators can iterate in either direction. They are considered as the forward iterators with two decrement operators because they offer the same functionality as the forward iterators, except that they can move in both directions.
Features:
==, !=
)*
)++
, prefix and postfix)--
, prefix and postfix)Features:
==, !=
)*
)++
, prefix and postfix)--
, prefix and postfix)<, <=, > >=
)[]
)begin()
: The begin()
function returns a
pointer pointing to the first element of the container. This pointer can
point in either direction of the container and hence it is
bidirectional.end()
: The end()
method returns a pointer
pointing to the element that comes after the last element of the
container. This element is not real, it is a virtual element that
contains the address of the last element.