Monika Dekster

C++: Spis treści

  1. Cechy C++
  2. Programowanie obiektowe
  3. Inicjalizacja klas - konstruktory
  4. Semantyka przenoszenia
  5. Przeładowanie operatorów
  6. Templates
  7. Wyrażenia lambda
  8. Dziedziczenie
  9. Polimorfizm
  10. Wyjątki
  11. Biblioteka STL: kontenery
  12. Biblioteka STL: algorytmy
  13. Biblioteka STL: iteratory

Cechy C++

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.

Standardy C++:

C++ standards



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

Obiektowość - własne typy danych

  1. C++ pozwala na zdefiniowanie własnego typu danych, z którego można korzystać tak samo jak z typu wbudowanego
  2. Typ, to nie tylko dane (liczby, napisy, etc.) ale także i zbiór operacji, które można na obiekcie tego typu wykonać (np. typ int z operacjami +, -, *, /, %)
  3. Łatwiej jest zrozumieć i zmodyfikować program, który zawiera typy ściśle odpowiadające pojęciom z dziedziny zastosowań, niż program, który tego nie robi
  4. Głównym pomysłem w definiowaniu nowych typów jest oddzielenie szczegółów implementacji od cech istotnych dla właściwego korzystania z tego typu) - enkapsulacja

Definicja klasy

  1. Do opisu nowego typu definiujemy nową klasę
    
    class nazwa {
    	// ciało klasy
    };
    
  2. Tworzenie obiektu klasy:
    
    nazwa obiekt;
    
  3. Ta definicja jest analogiczna do definicji zmiennych typów wbudowanych, np. (int n;)
  4. Mozna również tworzyć wskaźniki do obiektów i tablice obiektów:
    
    nazwa* ptr; // wskaźnik do typu nazwa
    nazwa tab[10]; // tablica obiektów typu nazwa
    

Składowe klasy

  1. Podejście funkcjonalne:
    • Atrybuty (dane):
      
      struct date {
      	short day;
      	short month;
      	int year;
      };
      
    • Metody (funkcje):
      
      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.
  2. Podejście obiektowe:
    • Aby powiązać dane z funkcjami, czyli aby wskazać, że na obiektach typu 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;
      };
      
    • Widać, że powyższe funkcje są także składowymi klasy date
    • Nazwy deklarowane w klasie mają zakres ważności równy obszarowi całej klasy

Odwoływanie się do składowych klasy

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();

Enkapsulacja (ukrywanie informacji)

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:

Korzyści z enkapsulacji danych:

Reguły składniowe:

Klasa a obiekt

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.

Definiowanie funkcji składowych

Funkcja składowa może być zdefiniowana:

Inicjalizacja klas - konstruktory

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:

Lista inicjalizacyjna

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

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 konwertujący (C++03)

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;
}

Semantyka przenoszenia (Move semantics)

Motywacje dla semantyki przenoszenia

lvalue i rvalue

Aby 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.

Referencje rvalue - rvalue references

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).

Reguły wiązania referencji

Wprowadzenie referencji do rvalue rozszerza reguły wiązania referencji:

Implementacja semantyki przenoszenia

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

Semantyka przenoszenia w klasach

Aby zaimplementować semantykę przenoszenia dla klasy należy zapewnić jej:

Funkcje specjalne klas w C++11

W C++11 istnieje sześć specjalnych funkcji składowych klasy:

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.

Reguła =default

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.

Reguła "Rule of Five"

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()

Implementacja funkcji std::move(obj) dokonuje rzutowania na rvalue reference - jest to w praktyce static_cast<T&&>(obj).

Perfect Forwarding

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ą.

Return Value Optimization & Copy Elision

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

Przeładowanie operatorów

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));   

Uwagi dotyczące definiowania operatorów

Operatory jednoargumentowe

Operator jednoargumentowy (przedrostkowy) @ można zadeklarować jako:

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);
}

Operatory dwuargumentowe

Operator dwuargumentowy @ można zadeklarować jako:

Kopiujący operator przypisania

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:

Operator wywołania funkcji

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) 

Operator indeksowania

Wyrażenie:


wyrażenie_proste [ wyrażenie ];

interpretuje się jako operator dwuargumentowy. Zatem wyrażenie:


x[y];

interpretuje się jako:


x.operator[](y) 

Operator dostępu do składowej klasy

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).

C++ templates

Java Generics vs. C++ templates
  1. In C++ generic functions/classes can only be defined in headers, since the compiler generates different functions for different types (that it's invoked with). So the compilation is slower. Java uses a technique called "erasure" where the generic type is erased at runtime.
  2. In C++ templates, parameters can be any type or integral but in Java, parameters can only be reference types.
  3. For C++ templates, separate copies of the class or function are likely to be generated for each type parameter when compiled. However for Java generics, only one version of the class or function is compiled and it works for all type parameters.
  4. C++ templates can be specialized - a separate implementation could be provided for a particular template parameter. In Java, generics cannot be specialized. C++ does not support wildcard. Java supports wildcard as type parameter if it is only used once.
  5. In C++, static variables are not shared between classes of different type parameters. In Java, static variables are shared between instances of a classes of different type parameters.

In summary:

Generics in Java

  1. are generated at runtime
  2. use Object substitution and casting instead of multiple native versions of the class
  3. support explicit constraints

Templates in C++

  1. are generated at compile time
  2. create native codebase per template parameter
  3. support explicit constraints since C++ 20 (concepts)
Function templates

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 T, we cannot call our function template with two objects of different types as arguments:


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.

Class templates

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.

Template specialization and overloading

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 called min_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;
}
Non-type parameters for templates

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;
}

Wyrażenia lambda

Wprowadzenie do wyrażeń lambda

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;
}

Under the hood, czyli jak to jest zrobione

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ę!!!

Przechwytywanie kontekstu (lambda captures)

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

Under the hood (again)

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;
}

Mutable Lambda Function

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 w funkcjach składowych klas

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 objects

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

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:

  1. Konstruktory
  2. Destruktor
  3. Operator przypisania

Polimorfizm

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ę.

C++ virtual

Wskaźniki do klasy bazowej

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.

Funkcje wirtualne

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 = &rect;
	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.

Klasy abstrakcyjne

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;
}

Praktyczna implementacja mechanizmu polimorfizmu

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

C++ virtual

Wyjątki

Co to są wyjątki

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.

Po co nam wyjątki?

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).

Jak używać wyjątków?

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.

Propagacja błędów

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:

  1. Zaśmiecanie funkcji f2() do f9() niepotrzebnymi warunkami, które ich nie dotyczą
  2. Zwiększenie objętości kodu
  3. Zwiększenie stopnia skomplikowania kodu
  4. Wymaga, by kod powrotu z funkcji odpowiadał za dwie różne rzeczy: faktyczną wartość zwracaną przez funkcję w przypadku sukcesu i kod błędu w przypadku jego wystąpienia. Czasem wymaga to dodatkowego parametru mówiącego, z którym z tych wariantów mamy do czynienia

Wyjątki i destruktory

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 powinno przenieść się sterowanie programu? Nie ma na to pytanie dobrej odpowiedzi i w takiej sytuacji automatycznie wołana jest funkcja terminate(), która zabija proces!

Dlatego jako regułę przyjmuje się, że destruktor nie generuje wyjątków.

Co jeżeli konstruktor zawodzi?

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.

Jakich typów wyjątków używać?

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.

Wyjątki i polimorfizm


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).

STL: Kontenery

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 (Sequence Containers)

Kontenery sekwencyjne są używane do tworzenia struktur danych obiektów tego samego typu w porządku liniowym. Do kontenerów sekwencyjnych zaliczamy:

While std::string is not included in most container lists, it does in fact meet the requirements of a SequenceContainer.

Container Adapters

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:

Kontenery std::vector, std::deque i std::list spełniają te wymagania i mogą być użyte jako kontener bazowy.

Standardowe kontenery adaptacyjne to:

Kontenery asocjacyjne

Kontenery 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

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

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:

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;

Dlaczego używać standardowych kontenerów?

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:

  1. kontenery STL są bardzo dobrze przetestowane i raczej nie wymagają debugowania przez użytkownika
  2. kontenery STL są szybkie i optymalnie zaprojektowane; jest mała szansa, że będziemy w stanie napisać lepszą wersję
  3. kontenery STL współdzielą wspólny interfejs co ułatwia ich wykorzystanie bez konieczności odwoływania się do szczegółowej dokumentacji
  4. kontenery STL są bardzo dobrze udokumentowane i ich wykorzystanie jest proste

Systemy wykorzystujące standardowe kontenery STL są łatwiejsze do zrozumienia niż te, które używają własnych, nieznanych szerzej implementacji.

Przykładowy kontener: 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.:

  1. W każdym momencie mamy dostęp do aktualnego rozmiaru wektora; przy wykorzystaniu wbudowanych tablic musimy ręcznie śledzić aktualny rozmiar.
  2. Podobnie mamy dostęp do rozmiaru zaalokowanej pamięci
  3. Zmiana rozmiaru wektora jest automatyczna
  4. Wstawianie / usuwanie elementów do środka tablicy sprowadza się do wywołania funkcji; wbudowane tablice wymagają ręcznego przesuwania elementów w momencie wstawiania nowego

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.

Tworzenie wektora

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

Accessing Data

std::vectori posiada interfejs umożliwiający prosty dostęp do danych kontenera:


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:

Dodawanie i usuwanie elementów

Do dodawania / usuwania elementów wektora można wykorzystać jedną z funkcji:

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).

Zarządzanie pamięcią

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:

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

Poruszanie się po kontenerze

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;

Algorytmy

Standardowe algorytmy znajdują się w <algorithms>. STL Algorithms Library składa się z następujących części:

  1. Sortowanie
    • Quicksort
      
      template <typename RandomAccessIterator>
      void sort(RandomAccessIterator a, RandomAccessIterator b);
      
    • Stable sort
      
      template <typename RandomAccessIterator>
      void stable_sort(RandomAccessIterator a, RandomAccessIterator b);
      
  2. Algorytmy niezmiennicze (Algorytmy, które nie modyfikują zawartości kontenera)
    • 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.

  3. Algorytmy modyfikujące
    • 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.

  4. Algorytmy numeryczne
    • 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

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.

Algorithms

Iterators in C++ are classified into 5 major categories based on their functionality:

  1. Input iterator
  2. Output iterator
  3. Forward iterator
  4. Bidirectional iterator
  5. Random access iterator

Iterators

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

Input 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:

  1. Equality and Inequality operator (==, !=)
  2. Dereferencing, used to access the data whose address is being pointed to by a pointer (*)
  3. Incrementable (++, prefix and postfix)
  4. Swappable

Output iterator

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 iterator

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 iterator

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:

  1. Equality and Inequality operator (==, !=)
  2. Dereferencing, used to access the data whose address is being pointed to by a pointer (*)
  3. Incrementable (++, prefix and postfix)
  4. Decrementable (--, prefix and postfix)
  5. Swappable

Random access iterator

Features:

  1. Equality and Inequality operator (==, !=)
  2. Dereferencing, used to access the data whose address is being pointed to by a pointer (*)
  3. Incrementable (++, prefix and postfix)
  4. Decrementable (--, prefix and postfix)
  5. Swappable
  6. All relational operators (<, <=, > >=)
  7. Arithmetic operators
  8. Offset dereference operator ([])

Operations on iterators

  1. 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.
  2. 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.

Iterators