Zaznacz stronę

Podstawy języka C++ – część 6 – operatory 

maj 12, 2025 | Nowości

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.

GrupaWiązaniePrzykłady
17. zasięg nazwyLeweNamespace::Klasa::skladowa
16. wybór składowej,
element tablicy,
wywolanie funkcji,
konstrukcja obiektu
Leweobiekt.pole lub ptr->pole,
tab[indeks]
funkcja(argument)
Klasa(argument)
16. post-inkrementacja/dekrementacja
rzutowanie
Prawei++ 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
Prawesizeof(type)
++i lub --i
~v lub !v
+v lub -v
&v
*ptr
new Type() lub delete ptr
(int)v
14. wskażnik do składowejLeweptr->*skladowa
obiekt.*skladowa
13. mnożenie i dzielenie
reszta z dzielenia
Lewea * b, a / b,
a % b
12. dodawanie i odejmowanieLewea + b, a - b
11. przesunięcie bitoweLewea << 2, b >> c
10. relacjeLewea < b, b >= c,
9. równe, nierówneLewed == e, f != g
8. iloczyn bitowy (AND)Lewea & b
7. alternatywa wykluczająca bitowa (XOR)Lewea ^ b
6. alternatywa bitowa (OR)Lewea | b
5. iloczyn logiczny (AND)Lewea && b
4. alternatywa logiczna (OR)Lewea || b
3. operator tenarnyPrawewarunek ? y : n
2. przypisanie zmiennej,
przypisanie z operacją
Prawea = b
a += b, c *= d, e |= x
1. zgłoszenie wyjątkuPrawethrow exception;
0. operator przecinkaLewea, 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.

C++
#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.

C++
#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. ., ::, .*, ?: