O operatorach w języku C++ wspomniałem kilkukrotnie w poprzednich rozdziałach. Tutaj omówimy je sobie nieco szerzej. Operatory to podstawowe elementy składniowe w języku C++, które umożliwiają wykonywanie różnorodnych operacji na danych. W programowaniu stanowią one „budulec” algorytmów i logiki, pozwalając programiście na manipulowanie wartościami zmiennych, porównywanie ich, wykonywanie obliczeń arytmetycznych, a także operacje na wskaźnikach czy pracę z operatorami logicznymi i bitowymi. Ponadto, w C++ istnieje możliwość przeciążania operatorów, co pozwala dostosować ich działanie do specyficznych typów danych zdefiniowanych przez programistę. Dzięki temu kod staje się bardziej przejrzysty i intuicyjny, co jest istotnym aspektem przy pracy z bardziej złożonymi projektami programistycznymi.
Operatory typów prostych
Operatory typów prostych w C++ służą do wykonywania podstawowych operacji na zmiennych o typach takich jak liczby całkowite (np. int
), zmiennoprzecinkowe (np. float
, double
), znaki (char
), czy wartości logiczne (bool
). Do najważniejszych operatorów tego typu należą operatory arytmetyczne (+
, -
, *
, /
, %
), które pozwalają na wykonywanie operacji matematycznych, oraz operator przypisania (=
), stosowany do przypisywania wartości do zmiennych. W pracy z typami prostymi często wykorzystuje się także operatory porównania (==
, !=
, <
, >
, <=
, >=
), które zwracają wartość logiczną, umożliwiając tworzenie warunków sterujących w programie. Dodatkowo, operatory inkrementacji (++
) i dekrementacji (--
) pozwalają na szybkie zwiększanie lub zmniejszanie wartości zmiennej o 1, co jest przydatne w pętlach i iteracjach. C++ oferuje także operator wyłuskania (&
), który umożliwia dostęp do adresu pamięci, oraz operator dereferencji (*
), pozwalający na odwołanie się do wartości znajdującej się pod danym adresem, co jest kluczowe przy pracy ze wskaźnikami. Operatory te są niezbędne przy pracy z podstawowymi typami danych, zapewniając dużą kontrolę nad ich wartościami i działaniem programu.
Priorytety operatorów
Priorytety operatorów w C++ określają kolejność wykonywania operacji, gdy w jednym wyrażeniu występuje ich więcej niż jeden. Dzięki temu kompilator wie, które operacje wykonać jako pierwsze, co ma kluczowe znaczenie dla poprawności i wydajności działania kodu. Na przykład operatory mnożenia (*
), dzielenia (/
) i reszty z dzielenia (%
) mają wyższy priorytet niż operatory dodawania (+
) i odejmowania (-
), więc w wyrażeniu 2 + 3 * 4
najpierw wykona się mnożenie, a wynik zostanie dodany do liczby 2
. W przypadku operatorów o jednakowym priorytecie (jak +
i -
) decydująca staje się zasada łączności, która określa, czy operatorzy są wykonywane od lewej do prawej (lewa łączność), czy od prawej do lewej (prawa łączność). Na przykład operator przypisania (=
) ma prawą łączność, więc w wyrażeniu a = b = c
przypisanie rozpoczyna się od prawej strony do lewej. Poniżej tabela ilustrująca kolejność wykonywania, najważniejsze na górze.
Grupa | Wiązanie | Przykłady |
---|---|---|
17. zasięg nazwy | Lewe | Namespace::Klasa::skladowa |
16. wybór składowej, element tablicy, wywolanie funkcji, konstrukcja obiektu | Lewe | obiekt.pole lub ptr->pole ,tab[indeks] funkcja(argument) Klasa(argument) |
16. post-inkrementacja/dekrementacja rzutowanie | Prawe | i++ lub i-- static_cast<int>(v) |
15. pobranie rozmiaru, pre-incrementacja/decrementacja negacja bitowa i logiczna jednoargumentowy plus i minus pobranie adresu dereferencja allokacja i deallokacja rzutowanie zwykłe | Prawe | sizeof(type) ++i lub --i ~v lub !v +v lub -v &v *ptr new Type() lub delete ptr (int)v |
14. wskażnik do składowej | Lewe | ptr->*skladowa obiekt.*skladowa |
13. mnożenie i dzielenie reszta z dzielenia | Lewe | a * b , a / b , a % b |
12. dodawanie i odejmowanie | Lewe | a + b , a - b |
11. przesunięcie bitowe | Lewe | a << 2 , b >> c |
10. relacje | Lewe | a < b , b >= c , |
9. równe, nierówne | Lewe | d == e , f != g |
8. iloczyn bitowy (AND) | Lewe | a & b |
7. alternatywa wykluczająca bitowa (XOR) | Lewe | a ^ b |
6. alternatywa bitowa (OR) | Lewe | a | b |
5. iloczyn logiczny (AND) | Lewe | a && b |
4. alternatywa logiczna (OR) | Lewe | a || b |
3. operator tenarny | Prawe | warunek ? y : n |
2. przypisanie zmiennej, przypisanie z operacją | Prawe | a = b a += b , c *= d , e |= x |
1. zgłoszenie wyjątku | Prawe | throw exception; |
0. operator przecinka | Lewe | a, b |
Tabela ta jest dosyć obszerna i zapamietanie jej nie jest łatwe. Na szczęście nie jest to konieczne, ponieważ kolejność operacji zwykle jest tożsama z naturalnym porządkiem, który znamy z lekcji matematyki. Natomiast gdy nadal mamy wątpliwości, możemy grupować operacje za pomocą nawiasów.
Operatory typów użytkownika
W języku C++ operatorzy użytkownika (ang. user-defined operators) to funkcje dodające standardowe operatory, takie jak +
, -
, ==
, <<
i inne, dla własnych typów danych (np. klas i struktur). Umożliwiają one pisanie bardziej czytelnego i intuicyjnego kodu, np. pozwalając na dodawanie dwóch obiektów za pomocą operatora +
. Przeciążenie operatora polega na zdefiniowaniu specjalnej funkcji o składni operator<symbol>()
, która określa, jak dany operator ma się zachowywać dla konkretnego typu. Operator może być przeciążony jako metoda klasy albo jako funkcja globalna.
Operator jako funkcja klasy
Zróbmy prosty przykład klasy licznika, który posiada kilka operacji inkrementacji i zwiekszania wartości.
#include <iostream>
// definicja klasy
class Licznik
{
public:
// konstruktor, ktory pozwala ustawic wartosc poczatkowa
Licznik(int v): wartosc{v} {}
// Konstruktor kopiujacy
Licznik(const Licznik& l) {
if(&l != this) {
wartosc = l.wartosc;
}
}
// operator przypisania =, potrzebny do kopiowania
Licznik& operator=(const Licznik& l) {
if(&l != this) {
wartosc = l.wartosc;
}
return *this;
}
// operator pre-inkrementacji ++l
Licznik& operator++() {
wartosc += 1;
return *this;
}
// operator post-inkrementacji l++, UWAGA:
// - typ int w argumencie jest dla odróżnienia od pre-incrementacji
// - musimy zwrocic kopie obiektu sprzed inkrementacji
Licznik operator++(int) {
Licznik kopia = *this; // robimy kopie
wartosc += 1; // modyfikujemy nasz opbiekt
return kopia; // zwracamy kopie
}
// dodaj wartosc v do licznika i zapisz wynik w obiekcie
Licznik& operator+=(int v) {
wartosc += v; // tu modyfikujemy wlasny obiekt
return *this;
}
// dodaj wartosc v do licznika
Licznik operator+(int v) const {
Licznik kopia = *this; // robimy kopie
kopia += v; // modyfikujemy kopie, korzystamy z operatora += powyzej
return kopia; // zwracamy kopie
}
// dodaj wartosc licznika z innej klasy tego samego typu
Licznik& operator+=(const Licznik& l) {
wartosc += l.wartosc;
return *this;
}
// dodaj wartosc licznika z innej klasy tego samego typu
Licznik operator+(const Licznik& l) const {
Licznik kopia = *this; // robimy kopie
kopia += l; // modyfikujemy kopie, korzystamy z operatora += powyzej
return kopia; // zwracamy kopie
}
// zwroc aktualną wartość
int getWartosc() const { return wartosc; }
private:
int wartosc;
};
// przyklad uzycia
int main()
{
Licznik a = 0; // inicjujemy licznik na 0
a++; // operator post-inkrementacji
++a; // operator pre-inkrementacji
Licznik b = a + 5; // operator+(int)
b += 5; // operator+=(int)
Licznik c = a + b; // operator+(Licznik)
c += a; // operator+=(Licznik)
Licznik d = a + b + c + 6; // 3x operator+(Licznik i operator+(int)
std::cout << a.getWartosc() << " " << b.getWartosc() << " " << c.getWartosc() << "\n";
}
Operator jako funkcja globalna
A teraz przykład robiący to samo, przy czym operatory będą umieszczone poza ciałem klasy.
#include <iostream>
// definicja klasy
class Licznik
{
public:
// konstruktor, ktory pozwala ustawic wartosc poczatkowa
Licznik(int v): wartosc{v} {}
// Konstruktor kopiujacy
Licznik(const Licznik& l) {
if(&l != this) {
wartosc = l.wartosc;
}
}
// operator przypisania =, potrzebny do kopiowania
Licznik& operator=(const Licznik& l) {
if(&l != this) {
wartosc = l.wartosc;
}
return *this;
}
// zwroc aktualną wartość
int getWartosc() const { return wartosc; }
// ustaw nowa wartosc
void setWartosc(int v) { wartosc = v; }
private:
int wartosc;
};
// dodaj wartosc v do licznika i zapisz wynik w obiekcie
Licznik& operator+=(Licznik& l, int v) {
l.setWartosc(l.getWartosc() + v);
return l;
}
// dodaj wartosc v do licznika
Licznik operator+(const Licznik& l, int v) {
Licznik kopia = l; // robimy kopie
kopia += v; // modyfikujemy kopie, korzystamy z operatora += powyzej
return kopia; // zwracamy kopie
}
// dodaj wartosc licznika z innej klasy tego samego typu
Licznik& operator+=(Licznik& l, const Licznik& o) {
l.setWartosc(l.getWartosc() + o.getWartosc());
return l;
}
// dodaj wartosc licznika z innej klasy tego samego typu
Licznik operator+(const Licznik& l, const Licznik& o) {
Licznik kopia = l; // robimy kopie
kopia += o; // modyfikujemy kopie, korzystamy z operatora += powyzej
return kopia; // zwracamy kopie
}
// operator pre-inkrementacji ++l
Licznik& operator++(Licznik& l) {
l.setWartosc(l.getWartosc() + 1);
return l;
}
// operator post-inkrementacji l++, UWAGA:
// - typ int w argumencie jest dla odróżnienia od pre-incrementacji
// - musimy zwrocic kopie obiektu sprzed inkrementacji
Licznik operator++(Licznik& l, int) {
Licznik kopia = l; // robimy kopie
l += 1; // modyfikujemy nasz opbiekt
return kopia; // zwracamy kopie
}
// przyklad uzycia
int main()
{
Licznik a = 0; // inicjujemy licznik na 0
a++; // operator post-inkrementacji
++a; // operator pre-inkrementacji
Licznik b = a + 5; // operator+(int)
b += 5; // operator+=(int)
Licznik c = a + b; // operator+(Licznik)
c += a; // operator+=(Licznik)
Licznik d = a + b + c + 6; // 3x operator+(Licznik i operator+(int)
std::cout << a.getWartosc() << " " << b.getWartosc() << " " << c.getWartosc() << "\n";
}
Czym sie różnią oba te podejścia? Przykład pierwszy jest bardziej czytelny, mamy pełny dostęp do składowych klasy, Nadaje się idealnie w sytuacji, gdy poruszamy się w obrębie jedneg typu danych i nie mamy dodatkowych zależności. Przykład drugi natomiast pozwala odzielić implementację operatorów od samej klasy, możemy te funkcje trzymać w osobnych plikach nagłówkowych i osobnych jednostkach translacji. Programista może je włączać do swojego kodu w zależności od tego czy ich potrzebuje, czy nie. Definiowane operatorów jako funkcji globalnych ma tez swoją cenę, ponieważ nie mamy dostępu do składowych prywatnych i możemy się posługiwac tylko interfejsem publicznym. Aby móc to zaimplementować, w klasie Licznik
należało wprowadzić dodatkowa metodę setWartosc()
. Choć drugie podejście jest mniej wygodne, to nabiera sensu w większych projektach, gdzie operatory zawierają bardziej złożone operacje i istotna jest kontrola zależności. W ten sposób na przykład są zaimplementowane operatory <<
i >>
w bibliotece standardowej dla klas std::iostream
oraz std::string
, ale o tym planuje osobny rozdział.
Podsumowanie
Przeciążanie operatorów dla klas i struktur pozwala nam robić naprawdę fajne rzeczy. Dzięki temu powstają nowe abstrakcje, jak iterator, w którym operator++
pozwala przeskakiwać na następny element w kolekcji, a operator<<
pozwala wypisywać zmienne na ekran lub do pliku w formie tekstowej, inteligetne wskażniki (smart pointers), które przeciążają operator ->
. Należy natomiast pamiętać, że jest kilka operatorów, których nie można przeciążać, są to m.in. .
, ::
, .*
, ?: