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. Odzwierciedla ona fakt, że język ten jest rozszerzeniem języka C. 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.
gcc / g++
Program gcc (g++)
jest tylko interfejsem. Jako pakiet składa się
z wielu programów. Każda faza budowania ma przydzieloną osobną aplikację.
cpp
(C PreProcessor), tworzy plik gotowy do kompilacji (-E)cc
(lub cc1
, C Compiler), kompiluje do assemblera (-S)as
, assembluje kod do kodu maszynowego platformy docelowej (-c)ld
– tworzy końcowy program z utworzonych obiektów.Każdemu etapowi tworzenia obrazu procesu w pamięci towarzyszy odpowiednie przekształcanie adresów, począwszy od etykiet i innych symboli, a skończywszy na fizycznych adresach komórek pamięci. Jeśli w programie źródłowym występują adresy, to mają one najczęściej postać symboliczną (etykiety w asemblerze) lub abstrakcyjną (wskaźniki w C). Adresy związane z lokalizacją pojawiają się na etapie translacji i są odpowiednio przekształcane aż do uzyskania adresów fizycznych.
#include <iostream>
/* to jest komentarz
kilkuwierszowy
*/
int main() {
std::cout << "Hello, world" << std::endl; // komentarz jednowierszowy
return 0;
}
Wersja dla "leniwych" ale nie polecana:
#include <iostream>
using namespace std;
/* to jest komentarz
kilkuwierszowy
*/
int main() {
cout << "Hello, world" << endl;
// "return jest automatycznie dopisywane
// przez kompilator, ale oczywiście nie jest
// błędem umieszczenie tej instrukcji
}
bool
,char / unsigned char / signed char
,int / unsigned int
,short / unsigned short
,long / unsigned long
,long long / unsigned long long
,float
,double
,long double
,Rozmiary poszczególnych typów są zależne od implementacji
(operator sizeof()
).
true, false
int: 536230
unsigned int: 536230u
int: 012300
wartość oktalnaint: 0xff, 0xab12
wartość heksadecymalnalong: 536230l
unsigned long: 536230ul
long long: 536230ll
unsigned long long: 536230ull
'a'
'\t'
tab'\n'
linefeed'\f'
form feed'\r'
carriage return'\"'
double quote'\\'
backslash'\''
single quote'\xff'
kod heksadecymalny znaku'\177'
kod oktalny znaku"to jest tekst\n"
1e1f, 2.f, .3f
1e1, .3, 0.0, 2.d
1e1l, 0.3L
Skompilowany program tworzy następujące obszary pamięci:
Zmienne są (zwykle) nazwanymi pojemnikami na pojedyncze wartości typu z jakim zostały zadeklarowane.
Wyróżniamy następujące rodzaje zmiennych:
Przy deklaracji zmiennych można jawnie podawać ich wartości początkowe. To bardzo dobra praktyka. Jeśli w deklaracji zmiennych statycznych lub globalnych nie podano ich wartość początkowej, to taka zmienna zostanie automatycznie zainicjalizowana wartością 0 odpowiedniego typu. W przypadku zmiennych lokalnych programista musi sam zadbać o zainicjalizowanie zmiennej przed jej odczytaniem.
+, −
unarny*, ⁄, %
dwuargumentowe+, −
dwuargumentowe++, −−
inkrementacja i dekrementacja (pre i postfiksowa)<, <=, >, >=, ==, !=
&&, ||
?:
operator warunkowy<<, >>
operatory przesunięcia (tylko dla typów całkowitych),~
operator dopełnienia bitowego&, |, ^
(and, or, xor)+=, −=, *=, ⁄=, &=, |=, ^=, %=, <<=,
>>=
operatory modyfikacji⁄
Dzielenie całkowite daje wynik całkowity, np. 5 ⁄ 2
daje w wyniku 2 (a nie 2.5)
%
Dla operandów całkowitych daje wynik taki, że (a ⁄ b) * b +
(a % b)
jest równe a
E1 ? E2 : E3
wartością wyrażenia jest E2
jeżeli E1
jest
true
i E3
jeżeli E1
jest
false
np. c = (a > b) ? a : b
oblicza maximum liczb a,
b
n++, n−−
postfixowy
++n, −−n
prefixowy
n = 5; m = n++;
daje m = 5
n = 5; m = ++n;
daje m = 6
Postać: E1 op= E2
jest równoważne:
E1 = (T)((E1) op (E2))
T
jest typem E1
, op
jest jednym z:
+, −, *, ⁄, %, &, |, ^, <<, >>
Dokonywane dla operandów następujących operatorów dwuargumentowych:
+, −, *, ⁄, %
<, <=, >, >=, ==, !=
&, |, ^
? :
Zasady konwersji
long double
drugi operand jest konwertowany do long double
double
drugi operand jest konwertowany do
double
float
drugi operand jest konwertowany do
float
long long
drugi operand jest konwertowany do
long long
long
drugi operand jest konwertowany do long
int
instrukcja - wyrażenie zakończone średnikiem;
blok - grupa instrukcji ujęta w nawiasy klamrowe {} (składniowo równoważna jednej instrukcji)
if (wyrażenie)
instrukcja-1
else
instrukcja-2
switch (wyrażenie) {
case stała-1: instrukcja-1
case stała-2: instrukcja-2
...
case stała-n: instrukcja-n
default: instrukcja
}
while (wyrażenie) instrukcja;
do {
instrukcja;
} while (wyrażenie);
for (wyrażenie1; wyrażenie2; wyrażenie3)
instrukcja;
Równoważne:
wyrażenie1;
while (wyrażenie2) {
instrukcja;
wyrażenie3;
}
Definicja funkcji
<type> name(<type> arg1, <type> arg2, ...) {
definitions and instructions;
...
}
Deklaracja funkcji
<type> name(<type> arg1, <type> arg2, ...);
Przykłady:
double sqr(double x);
double sqr(double x) {
return x*x;
}
int add(int a, int b);
int add(int a, int b) {
return a + b;
}
void print (char* s, double x);
void print (char* s, double x) {
printf("%s %f\n", s, x);
}
void message();
void message() {
printf("Function with empty parameter list\n");
}
Funkcja o typie różnym od void
musi mieć przynajmniej
jedną instrukcję return wyr;
, gdzie wyr
jest
wyrażeniem zgodnym z typem funkcji. Funkcja typu void
może
zawierać instrukcję return;
(bez wyrażenia). Jeżeli tej instrukcji
nie ma to funkcja kończy się po wykonaniu wszystkich jej instrukcji.
Parametry do funkcji są przekazywane przez wartość. Oznacza to, że aktualne wartości parametrów są kopiowane do obszaru roboczego funkcji, a sama funkcja nie ma dostępu do oryginału. Wniosek: Funkcja nie może zmienić oryginalnych wartości parametrów. Inne sposoby przekazywania parametrów są opisane w rozdziale o wskaźnikach.
Deklaracja tablicy
int ia[10];
const int len = 10;
char ca[len];
Tworzenie tablicy:
Inicjalizacja
int silnia[] = { 1, 1, 2, 6, 24 };
char *as = "Tablica";
Tablice muszą być indeksowane wyrażeniami całkowitymi. Tablica o długości
n
może być indeksowana od 0
do n-1
.
Tablica 2D w języku C jest tablicą 1D, której elementami są tablice 1D
(wiersze). Na przyklad tablica T a[4][3]
(T
jest
jakimś typem) może być przedstawiona następująco:
a[0] | ---> | a[0][0] | a[0][1] | a[0][2] |
a[1] | ---> | a[1][0] | a[1][1] | a[1][2] |
a[2] | ---> | a[2][0] | a[2][1] | a[2][2] |
a[3] | ---> | a[3][0] | a[3][1] | a[3][2] |
Elementy tablicy są przechowywane w pamięci wierszami, tak więc adres
elementu a[i][j]
tablicy a[m][n]
typu
T
jest wyznaczany następująco:
address(a[i][j]) = address(a[0][0]) + (i * n + j) * size(T)
K&R stworzyli zunifikowane pojęcie tablicy i wskaźnika. Ich rozwiązanie można przedstawić w postaci pięciu reguł:
N
-wymiarowa jest tablicą 1D elementów, które są tablicami
N-1
-wymiarowymi.i
jest równoważne operacji "dodaj
wskaźnikowo indeks do adresu początku tablicy i pobierz element wskazany przez
tak otrzymaną sumę"Powyższe reguły prowadzą do równania tablic. Dla tablicy 2D elementów typu
T
mamy:
T a[m][n];
a[i] = a + i * sizeof(row) // no '*' since it is an address (rule 4.)
a[i][j] = *(a[i] + j * sizeof(T))
a[i][j] = *(a + i * sizeof(row) + j * sizeof(T))
a[i][j] = *(a + (i * n + j) * sizeof(T)) // since sizeof(row) = n * sizeof(T)
&a[i][j] = a + (i * n + j) * sizeof(T)
Uzyskaliśmy więc równanie jak wyżej.
Stos (stack) to obszar pamięci w przestrzeni adresowej programu wykorzystywany do specyficznych celów. Stos obsługiwany jest w sposób automatyczny, obsługa ta nie wymaga ingerencji programisty. Na stosie dostępny jest wyłącznie element położony na jego wierzchołku, a elementy zdejmowane są ze stosu w odwrotnej kolejności niż były na nim umieszczane (rejestr LIFO).
Na stosie przechowywane są:
Sterta (heap) to obszar pamięci udostępniany przez system operacyjny wszystkim działającym programom (procesom). Na stercie przechowywane są dynamicznie przydzielane obszary pamięci
Wszystkie zmienne globalne oraz zmienne statyczne. Dodatkowo, pamięć w której przechowywany jest binarny kod wykonywalny programu również jest statycznie zaalokowana. Pamięć statyczna jest alokowana w momencie uruchomienia procesu (zawsze w tym samym miejscu).
Składnia:
int (*foo)(int);
foo
jest wskaźnikiem do funkcji posiadającej jeden argument
typu int
i zwracającej wartość typu int
.
Deklarując wskaźniki funkcyjne postepujemy jak przy deklaracji funkcji,
zamieniając nazwę funkcji (np. foo
) na (*foo)
(pamiętając o nawiasach).
Co oznacza deklaracja:
void *(*foo)(int *);
Czytamy 'od środka': najbardziej wewnętrznym elementem deklaracji jest
*foo
, reszta wygląda jak normalna deklaracja funkcji. Czyli
*foo
jest funkcją, która przyjmuje argument int*
i
zwraca void*
. W takim razie foo
jest wskaźnikiem do
takiej funkcji.
Aby zainicjalizować wskaźnik funkcyjny musimy podać adres funkcji o parametrach zgodnych z deklaracją
void my_int_func(int x) {
printf("%d\n", x);
}
int main(void) {
void (*foo)(int);
foo = &my_int_func; // the ampersand is optional
foo(2); // call my_int_func; you do not need to write (*foo)(2)
(*foo)(2); // but if you want to, you may
return 0;
}
Z powyższego przykładu wynika, że składnia wskaźników funkcyjnych jest elastyczna: można stosować konwencję 'wskaźnikową', z '*' i '&', albo ominąć tę część.
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;
}
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).
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
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;
}
Cztery podstawowe klasy we/wy przedstawiono na poniższym diagramie. Klasa
ios
jest (wirtualną) klasą bazową dla klas istream
i
ostream
, które z kolei są klasami bazowymi dla
iostream
.
Klasy te są są dostępne przez włączenie pliku
nagłówkowego <iostream
>.
Każda flaga posiada nazwę (zdefiniowaną poprzez stałą enum
).
Dla wygody zdefiniowano również pewne grupy flag (zwane polami bitowymi -
bit fields), również o zdefiniowanych nazwach.
Format-State Flags | ||
---|---|---|
Flag | Bit Field | Flag Purpose |
ios::left |
ios::adjustfield |
Left-justify all output |
ios::right |
Right-justify all output | |
ios::internal |
Left-justify sign or base indicator, and right-justify rest of number | |
ios::dec |
ios::basefield |
Display integers in base 10 (decimal) format |
ios::oct |
Display integers in base 8 (octal) format | |
ios::hex |
Display integers in base 16 (hexadecimal) format | |
ios::fixed |
ios::floatfield |
Use fixed point notation when displaying floating-point numbers (the usual convention) |
ios::scientific |
Use exponential (i.e., scientific) notation when displaying floating-point numbers | |
ios::showbase |
|
Show base indicator when displaying integer output |
ios::showpoint |
Show decimal point when displaying floating-point numbers (default is 6 significant digits) | |
ios::showpos |
Show a leading plus sign (+) when displaying positive numbers | |
ios::uppercase |
Use uppercase E when displaying exponential or hexadecimal numbers | |
ios::boolalpha |
Use textual rather than numerical format to read and write boolean values (true and false, rather than 1 and 0) | |
ios::skipws |
Skip whitespace on input | |
ios::unitbuf |
Flush output buffer after each insertion (which really has nothing to do with formatting) |
Uwagi dotyczące powyższej tabeli:
ios::skipws
i ios::dec
ustawione.ios::adjustfield
,
ios::basefield
i ios::floatfield
) odpowiednie
flagi są wzajemnie wykluczające.ios::adjustfield
, default: dosunięcfie do prawego marginesu.ios::basefield
, default: na wyjściu decimal,
na wejściu stosuje się konwencję C++ dotyczącą stałych całkowitych
(wiodące 0 - notacja oktalna; wiodące 0x lub 0X - notacja heksadecymalna).ios::floatfield
, zależnie od wartości liczby.
file.setf(ios::dec | ios::showpoint | ios::fixed)
Przy formatowaniu we/wy można również używać następujących parametrów:
width
'). Wartością
domyślną jest spacja.ios::scientific
i ios::fixed
następująco:
precision
podaje liczbę cyfr po kropce dziesiętnej.precision
podaje całkowitą liczbę cyfr niezależnie
od położenia kropki dziesiętnej.width
nie jest stała, co oznacza,
że stosowana jest tylko do najbliższej operacji we/wy
(czyli wartość parametru jest ustawiana z powrotem na 0 po każdej
operacji we/wy). W związku z tym wartość parametru musi byś podana
bezpośrednio przed operracją we/wy, której dotyczy.width
. To
zapewnia, że wszystkie przeczytane znaki (i nullbyte) zmieszczą się
w tablicy o wielkości width
.width
jest 0, co
oznacza, że wartości będą drukowane na minimalnej szerokości pola
tak, by zachować ich wartość.Zarówno flagi jak i parametry mogą być zmieniane przez funkcje składowe klas we/wy lub manipulatory strumieni.
Następujące trzy funkcje składowe mogą być używane do zmiany wartości flag we/wy:
fmtflags_value
,
i wyzeruj pozostałefmtflags_value
,
a pozostałe pozostaw niezmienione. Zwróć poprzednie wartości wszystkich flagfmtflags_value
, uprzednio zerując flagi należące
do grupy fmtflags_group
.
Pozostałe flagi pozostaw niezmienione. Zwróć poprzednie wartości wszystkich flag
Typowe użycie tej wersji funkcji jest następujące:
stream.setf(0, ios::floatfield)
Drugi z powyższych przykładów zeruje wszystkie flagi z grupy ios::floatfield
fmtflags_value
, a pozostałe pozostaw niezmienione.Kolejne trzy funkcje pozwalaja zmienić wartości parametrów we/wy.
precision
precision
i ustaw nową wartość
precyzji na podaną parametrem wywołania funkcjiwidth
width
i ustaw nową wartość
szerokości pola na podaną parametrem wywołania funkcjiManipulator jest obiektem funkcyjnym, który może być użyty jako operand operacji we/wy w celu zmiany stanu strumienia we/wy. Rozróżniamy dwa typy manipulatorów: bezparametrowe i z parametrem (-ami)
Następujące tabele zawierają listę dostępnych manipulatorów
Istotną różnicą między funkcjami składowymi a manipulatorami jest fakt, że funkcje składowe zwracają bieżące wartości parametrów, które zamierzamy zmienić. Manipulatory nie mają tej możliwości. W związku z tym, jeżeli potrzebne nam jest zachowanie poprzedniego stanu strumienia (np. żeby powrócić potem do defaultowych wartości), powinniśmy użyć funkcji składowych. Jeżeli nie jest to potrzebne, to obie metody są równoważne.
Manipulators without Parameters | |||
---|---|---|---|
Manipulators | Corresponding Format-State Flags |
Manipulator Purpose | Input/Output |
left |
ios::left |
Turn the flag on | output |
right |
ios::right |
Turn the flag on | output |
internal |
ios::internal |
Turn the flag on | output |
dec |
ios::dec |
Turns flag on | output |
oct |
ios::oct |
Turn the flag on | input/output |
hex |
ios::hex |
Turn the flag on | input/output |
fixed |
ios::fixed |
Turns the flag on | output |
scientific |
ios::scientific |
Turn the flag on | output |
showbase |
ios::showbase |
Turn the flag on/off | output |
showpoint |
ios::showpoint |
Turn the flag on/off | output |
showpos |
ios::showpos |
Turn the flag on/off | output |
uppercase |
ios::uppercase |
Turn the flag on/off | output |
boolalpha |
ios::boolalpha |
Turns flag on/off | input/output |
skipws |
ios::skipws |
Turn the flag on/off | input |
unitbuf |
ios::unitbuf |
Turn the flag on/off | output |
endl |
N/A |
Output a newline character and flush the stream |
output |
ends |
N/A |
Output a null | output |
flush |
N/A |
Flush the stream | output |
ws |
N/A |
Skip leading whitespace | input |
Manipulators with Parameters | |||
---|---|---|---|
Manipulator | Corresponding Format-State Flag(s) |
Manipulator Purpose | Input/Output |
setbase (int_radix_value) |
ios::dec , ios::oct , or ios::hex |
Set the number base to int_radix_value (10, 8 or 16) | input/output |
setiosflags (fmtflags_value) |
As specified by fmtflags_value | Turn the flags specified in fmtflags_value on/off | input/output |
Manipulator | Corresponding Format-State Parameter |
Manipulator Purpose | Input/Output |
setfill (int_char_code) |
fill character | Set the fill character to the character whose code is specified by int_char_code | output |
setprecision (int_num_digits) |
precision | Set the number of digits of precision to int_num_digits | output |
setw (int_width) |
width | Set the fieldwidth in the output to int_width, which must be >= 0; for input of strings, the maximum number of characters read in will be one less than int_width (ensures that the input will fit into a char array whose size is given by int_width) | input/output |
Przykład: funkcja ostream& flush(ostream&)
. Aby jej użyć,
musimy wykonać dwie operacje
flush(cout);
cout << x;
Możemy też zastosować następującą technikę:
class Flush {};
ostream& operator <<(ostream& os, const Flush&) {
return flush(os);
}
...
Flush FLUSH;
...
cout << FLUSH << x; // bo mamy operator << dla klasy Flush;
Można też krócej i bardziej ogólnie (tak to jest zrobione w bibliotece ios
)
typedef ostream&(*__omanip)(ostream&); // wskaźnik do funkcji (ostream) (ostream)
...
ostream& operator << (ostream& os, __omanip f) {
return f(os);
}
...
cout << flush << x;
Czyli operator <<
przyjmuje jako argument wskaźnik do
funkcji typu __omanip
. Takie podejście umożliwia bardzo proste
definiowanie swoich manipulatorów.
Niestety w ten sposób nie da się zdefiniować manipulatorów z argumentami, bo nie ma jak przekazać parametrów (umieszczenie na liście we/wy byłoby wywołaniem funkcji a nie wskaźnikiem funkcyjnym). Musimy więc wrócić do pomysłu z klasami.
ostream& setwidth(ostream& stream, int width) {
stream.width(width);
stream.precision(4);
stream.fill('*');
stream.setf(ios::scientific, ios::floatfield);
return stream;
}
class Setw {
int M_w;
public:
Setw(int n) : M_w(n) {}
friend ostream& operator<<(ostream& os, Setw f) {
setwidth(os, f.M_w);
return os;
}
};
inline Setw mysetw(int n) {
return Setw(n);
}
...
cout << mysetw(15) << 123.2345678 << endl;
Textfiles | Binary Files |
---|---|
Easy for humans to read | Impossible for humans to read |
Easy to modify | Harder to modify |
Easy to transfer | Harder to transfer |
Slower access | Faster access |
Less accurate (subject to conversion and roundoff errors) |
Data stored exactly |
Less compact storage | More compact storage |
Zanim program będzie mógł uzyskać dostęp do fizycznego pliku, musimy utworzyć
odpowiedni obiekt i połączyć go z tym plikiem. Takie połączenie nazywane jest
otwarciem pliku. Można to zrobić używając konstruktora odpowiedniej
klasy z biblioteki we/wy (czyli w momencie tworzenia obiektu) lub później,
wywołując funkcję składową open()
na rzecz tego obiektu.
Pliki mogą być otwierane z różnymi parametrami, przedstawionymi w tabeli
File Stream Modes (Values of Type openmode ) |
|
---|---|
openmode Literal |
Description |
ios::in |
Open in input (read) mode |
ios::out |
Open in output (write) mode |
ios::app |
Open in append mode |
ios::ate |
Go to end of file when opened |
ios::binary |
Open in binary mode (default is text mode) |
ios::trunc |
If file exists, delete file contents |
Istnieją też dodatkowe tryby otwarcia pliku: ios::nocreate
(jeżeli
plik nie istnieje, próba otwarcia się nie powiedzie) lub ios::noreplace
(jeżeli plik istnieje, próba otwarcia się nie powiedzie), ale nie powinny być
używane, gdyz nie są częścią standardu języka.
ios::binary
.istream
jest otwierany domyślnie w trybie wejściowym
(ios::in
).ostream
jest otwierany domyślnie w trybie wyjściowym
(ios::out
). Spowoduje to zniszczenie ewentualnej zawartości pliku.
Jeżeli chcemy tego uniknąć możemy otworzyć plik w trybie dopisania
(ios::app
).|
.fstream
jest defaultowo otwierany w trybie
input/output (ios::in | ios::out
), tzn. może być użyty zarówno
do odczytu jak i zapisu.Strumienie mogą być w różnych stanach (oznaczanych bitami stanu strumienia), przedstawionych w tabeli
Stream States (Values of Type iostate ) |
|
---|---|
iostate Literal |
Description |
ios::goodbit |
No problems with the stream |
ios::badbit |
Irrecoverable error |
ios::failbit |
Failure to read or write expected characters |
ios::eofbit |
End-of-file encountered when reading |
Klasa ios
definiuje cztery funkcje, testujące stan strumienia.
true
w przypadku braku błędu (czyli kolejne trzy funkcje
zwróciły false
)true
w przypadku napotkania końca plikutrue
jeżeli we/wy zawiodłotrue
jeżeli błąd we/wy był poważny i naprawa jest niemożliwagood()
zwraca false
, kolejne operacje na strumieniu
nie są możliwe (przynajmniej do chwili wywołania clear()
,
resetująjej flagi). Gdy bad()
zwróci true
, jest
to zwykle niemożliwe, ale gdy tylko fail()
zwraca true
można wywołać clear()
i spróbować jeszcze raz.eof()
i
fail()
zwracaja true
, eof()
ponieważ
napotkaliśmy koniec pliku, a fail()
ponieważ operacja zawiodła.
Ten stan zwykle nie wskazuje błędu, lecz fakt, że cały plik
został przeczytany.Domyślnie odczyt lub zapis do pliku odbywa się sekwencyjnie. Jest jednak możliwe by przesunąć się w pliku do dowolnej zadanej pozycji i rozpocząć operację we/wy od tej pozycji.
Następujące typy zostały zdefiniowane do pracy z funkcjami bezpośredniego we/wy:
Seek Origin for Seek Direction (Values of Type seekdir ) |
|
---|---|
seekdir Literal |
Description |
ios::beg |
Relative to beginning of stream (positive offset is absolute position) |
ios::cur |
Relative to current stream position (negative offset toward beginning of stream, positive offset toward end of stream) |
ios::end |
Relative to end of stream (negative offset toward begining of stream) |
Funkcje stosowane do bezpośredniego odczytu z pilku:
streamsize_value
znaków, umieść je w
c_string_variable
i zwróć *this
.get(), getline(), ignore(), peek(), putback(), read(),
unget()
.streamoff
). Nazwa tellg
jest skrótem od "tell get",
czyli, "tell" the position at which you may "get" a value.streamoff_value
. Domyślnie przesunięcie jest mierzone
od początku pliku (ios::beg
).
Nazwa seekg
jest skrótem od "seek get",
czyli, "seek a (new) position at which you may "get" a value.streamoff_value
w kierunku podanym przez seekdir_value
.Funkcje stosowane do bezpośredniego zapisu do pilku:
streamsize_value
znaków z
c_string_variable
i zwróć *this
.streamoff
). Nazwa tellp
jest skrótem od "tell put",
czyli, "tell" the position at which you may "put" a value.streamoff_value
. Domyślnie przesunięcie jest mierzone
od początku pliku (ios::beg
).
Nazwa seekp
jest skrótem od "seek put",
czyli, "seek a (new) position at which you may "put" a value.streamoff_value
w kierunku podanym przez seekdir_value
.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
.
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; });
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();
}
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