C++/Przeciążanie operatorów

Przeładowanie (przeciążanie) operatorów polega na nadaniu im nowych funkcji.

Trochę teorii na wstępie

edytuj

Przeładowywanie operatorów, jest to definiowanie operatorów dla własnych typów. Można tego dokonać w większości przypadków jako metodę składową lub jako metodę globalną. Przeładowywać możemy następujące operatory: (pełne i wyczerpujące zestawienie operatorów)

+ // operator dodawania, może być jedno lub dwuargumentowy
- // operator odejmowania, może być jedno lub dwuargumentowy
* // operator mnożenia (dwuargumentowy) lub operator
/
% // operator modulo (dwuargumentowy)
^
& // operator and logiczne (dwuargumentowy), lub operator uzyskania adresu (jednoargumentowy) gdy go nie zdefiniujemy robi to za nas kompilator
~
!
= // operator przypisania, gdy go nie zdefiniujemy robi to za nas kompilator
<
>
+=
-=
*=
/=
%=
^=
&=
|=
<<
>>
>>=
<<=
==
!=
<=
>=
&&
||
++
--
,  // przecinek, gdy go nie zdefiniujemy robi to za nas kompilator
->*
->
() // operator wywołania funkcji (ile-chcemy-argumentowy)
[]
new      // ponizsze operatory gdy ich nie zdefiniujemy robi to za nas kompilator
new[]
delete
delete[]

Nie można przeładowywać:

.  // odniesienie do składowej klasy
.* // tak wybieramy składnik wskaźnikiem
:: // operator zakresu
?: // operator zwracający wartość zależnie od spełnienia warunku
static_cast, dynamic_cast, reinterpret_cast, const_cast
sizeof // pobranie rozmiaru danego typu, lub jego wewnętrznej składowej

Parę uwag co do operatorów: Nie można zmienić ich priorytetów, argumentowości, argumenty operatorów nie mogą być domniemane, redefiniować operatory można gdy co najmniej jeden argument jest typu zdefiniowanego przez użytkownika. operatory =, [], (), -> muszą być niestatycznymi funkcjami składowymi w danej klasie.

Nie wszystkie operatory mogą być zdefiniowane jako oddzielna funkcja, oto operatory, które mogą być zdefiniowane wyłącznie jako metody:

=
[]
->

Użycie

edytuj

Nie możemy przeładowywać operatorów dla typów wbudowanych (int, char, float).

Przeciążanie polega na zdefiniowaniu tzw. funkcji operatorowej. Identyfikatorem funkcji operatorowej jest zawsze słowo kluczowe operator, bezpośrednio po którym następuje symbol operatora

typ_zwracany operator@ (argumenty)
{
// operacje 
}

np.: operator+, operator-, operator<< itd. Co najmniej jeden argument tej funkcji musi być obiektem danej klasy.


Przykład zastosowania

edytuj

Mamy daną klasę Student

 class Student {  
   int nr_indeksu;
   float srednia_ocen; 
   public:
      Student(int nr=0, float sr=0) : nr_indeksu(nr), srednia_ocen(sr) {}
 };

i chcemy przeładować operator wyjścia <<


Robimy to w następujący sposób:

 class Student {   
    int nr_indeksu;
    float srednia_ocen; 
    public:
       Student(int nr=0, float sr=0) : nr_indeksu(nr), srednia_ocen(sr) {}
       friend ostream & operator<< (ostream &wyjscie, const Student &s);
 };
 
 ostream & operator<< (ostream &wyjscie, const Student &s) {
   return wyjscie << "Nr indeksu: " <<s.nr_indeksu << endl << "Srednia ocen: " <<s.srednia_ocen<<endl;
 }

Aby zobaczyć, jak to działa, wystarczy że funkcja main() będzie miała następującą postać:

 int main() {
 
   Student st, stu(10,5);
   cout << st; // wypisze nr indexu = 0, srednia ocen=0,
               // ponieważ są to wartosci domyślne konstruktora :)
  
   cout << stu; // wypisze nr indexu = 10, srednia ocen=5
   
   return 0;
 }

W powyższym przykładzie wprowadzone zostało także nowe pojęcie - zaprzyjaźnianie (friend). Funkcję F() deklarujemy jako zaprzyjaźnioną z klasą X, jeśli chcemy, aby F() miała dostęp do prywatnych lub chronionych danych składowych klasy X.

Ale weźmy przykład nieco prostszy. Chcemy sprawdzić czy to jest ten sam student - przeciążamy operator:

 class Student {
    //...
    public:
       bool operator==(const Student &q) {return nr_indeksu==q.nr_indeksu;}
       bool operator==(const int &q) {return nr_indeksu==q;}
 };

I niby wszystko jest pięknie, ale tu zaczynają się schody... My, jako twórcy klasy wiemy, że porównanie dotyczy tylko i wyłącznie numeru indeksu. Przy różnych średnich i tych samych indeksach dostaniemy wynik pozytywny. A nuż niech ktoś sobie ubzdura, że == odnosi się do wszystkich składowych...

Dalsze zamieszanie wprowadzą kolejne zaproponowane przeze mnie operatory.

 class Student {
    //...
    public:
      bool operator< ( Student const &q) const {return srednia_ocen < q.srednia_ocen;}
      bool operator< (int const &q) const {return srednia_ocen < q;};
  // itd dla kolejnych operatorów.
 };

Samo w sobie nie jest groźne. Dopiero przy konfrontacji z poprzednim operatorem zaczyna wprowadzać zamieszanie. Wszystko jest dobrze kiedy pamiętamy, jakie operacje dane operatory wykonują. Ale pamiętajmy: pamięć ludzka jest ulotna i ktoś inny (albo my) może spędzić kilka dni zanim dojdzie do tego, dlaczego to działa nie tak jak powinno.

Ale powyższy przykład wygląda naprawdę blado w porównaniu z tym:

 class Student {
    //...
    public:
       int operator+ ( Student &q) {return (srednia + q.srednia +11) };
       int operator+ ( int &q) {return (srednia - q / 30) };
 };

Jak widzicie operator + wcale nas nie zmusza do wykonywania operacji dodawania. Możemy równie dobrze wewnątrz odejmować. A przy odejmowaniu porównywać. Lecz takie postępowanie nie jest intuicyjne. Takie postępowanie jest dozwolone jedynie w przypadkach, kiedy startujecie w konkursie na najbardziej nieczytelny kod.

Ale dość już straszenia. Teraz należy pokazać jak prawidłowo przeciążać operatory jednoargumentowe (++, --) oraz jak prawidłowo zwracać obiekt np. przy dodawaniu.


Oprócz operatorów arytmetycznych oraz działających na strumieniach można przeciążać również operatory logiczne.

Operatory "bool" i "!"

edytuj

W języku C++ jest również możliwość przeciążania operatorów bool i !. Dzięki temu możemy w instrukcji warunkowej używać nazwy obiektu do testowania, czy spełnia on jakieś określone kryteria. Poniżej znajduje się prosty przykład, który to ilustruje:

#include <iostream>
using namespace std;
template <typename T, int el>
class Tablica {
       public:
         Tablica() : L_elementow(el) {}
         operator bool() const {return (L_elementow != 0);}
         bool operator!() const {return (L_elementow == 0);}

       private:
         T Tab[el];
         size_t L_elementow;
    };

int main() {
  const int n = 5;
  Tablica <short, n> tab;

  if(tab)
    cout << "Tablica nie jest pusta." << endl;
  if(!tab)
    cout << "Tablica jest pusta." << endl;

  Tablica <short, 0> tab2;

  if(tab2)
    cout << "Tablica nie jest pusta." << endl;
  if(!tab2)
    cout << "Tablica jest pusta." << endl;

  return 0;
}

W efekcie na ekranie otrzymamy dwa wpisy:


Tablica nie jest pusta.
Tablica jest pusta.

W pierwszym przypadku tablica zawierała niezerową liczbę elementów i prawdę zwrócił operator bool. W drugim natomiast liczba elementów wynosiła zero i prawdę zwrócił operator !.

Oczywiście ładniej by było, gdybyśmy sprawdzali rozmiar przy użyciu if-else, zamiast dwóch if-ów, ale chciałem pokazać wywołanie obu operatorów.

Operator "[]"

edytuj

W niektórych przypadkach bardzo przydatny jest operator indeksu []. Można go przeciążyć oczywiście w dowolny sposób, ale chyba najbardziej intuicyjne jest przypisanie mu funkcji dostępu do konkretnego elementu np. w tablicy.

Posługując się przykładem klasy Tablica możemy dopisać do niej następujące dwa operatory:

  T & operator[](size_t el) {return Tab[el];}
  const T & operator[](size_t el) const {return Tab[el];}

oraz testową funkcję main

int main() {
  const n = 5;
  TablicaInt <short, n> tab;

  for(int i = 0; i < n; ++i) {
      tab[i] = i;
      cout << tab[i] << endl;
    }
  return 0;
}

Operatory nie muszą wykonywać dokładnie tych samych czynności. Można sobie wyobrazić przykład w którym operator do zapisu zapisuje coś do tablicy, a w przypadku braku miejsca alokuje dodatkową pamięć. Operator stały nie będzie posiadał takiej funkcjonalności ponieważ nie może zmieniać obiektu na rzecz którego został wywołany. Zwróć uwagę na słowo kluczowe const w definicji funkcji składowej klasy: " operator[] (int el) const ". Modyfikator const stanowi część sygnatury funkcji. const zapewnia nam nie tylko właściwości funkcji składowej const, ale również umożliwia przeładowanie (przeciążenie) operatora.

Operator "()"

edytuj

Ten operator służy do tworzenia tzw. funktorów czyli klas które naśladują funkcje:

class Foo {
public:
    int operator() (int a, int b) {
        return (a+b);
    }
};

Nie jest to może najmądrzejszy przykład gdyż jest dostępny do przeciążania operator "+" ale oddaje zasadę działania. Trzeba zaznaczyć że ten operator może zwracać dowolną wartość oraz przyjmować dowolną liczbę parametrów dowolnego typu. Niestety musi być zadeklarowany jako niestatyczna metoda klasy (gdyż inne operatory które mogą być statyczne zwracają obiekt (&Foo operator...), choć możliwość udawania przez klasę funkcji z pewnością to wynagrodzi.

New i delete

edytuj

Są to operatory traktowane jako metody statyczne klas (niezależnie czy napiszemy słówko static czy nie), przykład:

class Foo {
  int i;
public:
    void * operator new(size_t rozmiar) { // słówka static nie muszę umieszczać, zrobi to za mnie kompilator
      return (new char[rozmiar]); // zawarte tutaj new skorzysta z GLOBALNEGO (dla wszystkiego tworzonego za jego pomocą) operatora new
    }
    void operator delete(void* wsk) {
      delete wsk;
    }
    Foo(int j = 0) : i(j) {}
    void* operator new[](size_t rozmiar) {
      return (new char[rozmiar]); // należy pamiętać aby nasz obiekt miał koniecznie konstruktor bezargumentowy
    }
    void operator delete[](void* wsk) {
      delete[] wsk;
    }
};

Kilka uwag: 1. Operatory new i new[], oraz delete i delete[] nie są przemienne, czyli jak zdefiniujemy własny new to new[] jest nadal domyślny. 2. Mimo zdefiniowania własnego operatora new i delete możemy nadal używać globalnych wersji:

Foo *f1 = new Foo(); // wywoła naszą wersję
Foo *f2 = ::new Foo(); // wywoła wersję globalną

Kiedy może się nam przydać własny operator new i delete? M. in. jak chcemy alokować za pierwszym razem większą pamięć a potem tylko zwracać wskaźnik do następnego jej fragmentu. Może być też przydatne gdy chcemy mieć kontrolę utworzenia jednej instancji obiektu.


W powyższym przykładzie pokazałem jak przeciążyć ten operator dla własnej klasy, natomiast jest jeszcze możliwość przeciążenia globalnego, czyli dla każdego obiektu w programie od jego uruchomienia do wyłączenia -odpowiedzialność jest więc wielka. Musimy się też liczyć z tym że w obrębie definicji nie możemy zrobić wszystkiego, czyli m.in. nie możemy używać strumieni cout (one korzystają z operatora new), oczywiście kompilator nie zaprotestuje natomiast podczas wykonania programu pojawi się problem. Przykład:

#include <cstdlib> // biblioteka zawierająca funkcje malloc() i free()

void* operator new(size_t rozmiar) {
  void* wsk = malloc(rozmiar);
  return wsk;
}
void operator delete(void* wsk) {
  free(wsk);
}
void* operator new[](size_t rozmiar) {
  return wsk;
}
void operator delete[](void* wsk) {
  free(wsk);
}

Autor "Symfonii C++", na której się opieram pisząc o new i delete, stanowczo odradza przeciążanie tego operatora globalnie

Operatory post- i pre- inkrementacji, oraz dekrementacji

edytuj

Tutaj umieszczę same przykłady:

class Foo {
  int i;
public:
  Foo(int j): i(j) {}
  
  Foo & operator++() { // preinkrementacje, czyli najpierw  zwiększamy a potem zwracamy
    ++i;
    return *this;
  }
  Foo operator++(int) {  // specjalny zapis do postinkrementacji
    Foo kopia = (*this);
    ++i;
    return kopia; // zwracamy kopię, a nie oryginał
  }
};

Operatory dekrementacji analogicznie do inkrementacji. Należy mieć na uwadze, że przy własnych operatorach potrzebny jest "działający jak chcemy" operator= lub konstruktor kopiujący, jeśli go nie napiszemy kompilator wygeneruje go automatycznie, natomiast jeśli nasza klasa ma wewnątrz siebie wskaźniki tak w skopiowanym obiekcie będą one wskazywały na ten sam adres co wskaźniki oryginału