Podrozdziały


18.3 Przeciążenia za pomocą metod klasy

Operatory, tak dwu- jak i jednoargumentowe, można też przeciążać za pomocą metod klasy. W tym jednak przypadku, ponieważ są to metody, jeden z argumentów będzie przesłany niejawnie: będzie nim ten obiekt, a dokładniej wskaźnik do tego obiektu, na rzecz którego metoda została wywołana; wewnątrz metody możemy odnosić się do niego poprzez wskaźnik this. Tak więc operatory dwuargumentowe będziemy definiować jako metody jednoparametrowe, a operatory jednoargumentowe jako metody bezparametrowe.

Niektóre operatory są nieco „specjalne” i muszą być przeciążane jako metody, a nie jako funkcje globalne. Należą do nich operatory:

Tymi operatorami zajmiemy się zatem osobno w jednym z następnych podrozdziałów .


18.3.1 Operatory dwuargumentowe

Przy definiowaniu operatora (funkcji operatorowej) dwuargumentowego za pomocą metody nie podaje się pierwszego parametru: niejawnie będzie nim wskaźnik do obiektu, na rzecz którego funkcja będzie wywołana. Obiektem tym będzie zawsze ten, który znajduje się po lewej stronie operatora. Obiekt po prawej będzie argumentem metody.

Załóżmy, że w klasie Klasa zdefiniowaliśmy metodę o prototypie

       Typ operator@(Typarg);
lub
       Typ operator@(Typarg&);
Wtedy, jeśli  a jest identyfikatorem obiektu klasy Klasa, a  b jest typu Typarg, wyrażenie
       a @ b
będzie równoważne wywołaniu metody na rzecz obiektu a
       a.operator@(b)
i przesłaniu (przez wartość lub referencję) b jako argumentu. Symbol ” oznacza tu któryś z symboli operatorów dwuargumentowych wymienionych w tabeli operatorów.

Rozpatrzmy przykład klasy Modulo, której jedną wersję poznaliśmy w programie modsev.cpp. Operator dodawania dwóch obiektów klasy Modulo przeciążymy w tej nowej wersji za pomocą metody:


P140: modsev1.cpp     Przeciążanie za pomocą metody

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class Modulo {
      5.      int numb;
      6.  public:
      7.      static const int modul;
      8.      Modulo() : numb(0)
      9.      { }
     10.  
     11.      Modulo(int numb) : numb(numb%modul)
     12.      { }
     13.  
     14.      Modulo operator+(const Modulo&) const;
     15.      friend ostream& operator<<(ostream&, const Modulo&);
     16.  };
     17.  const int Modulo::modul = 7;
     18.  
     19.  inline Modulo Modulo::operator+(const Modulo& n) const {
     20.      return Modulo(numb + n.numb);
     21.  }
     22.  
     23.  ostream& operator<<(ostream& str, const Modulo& n) {
     24.      return str << n.numb;
     25.  }
     26.  
     27.  int main() {
     28.      Modulo m(5), n(6), k;
     29.  
     30.      k = m + n;
     31.      cout << m << " + " << n
     32.           << " (mod " << Modulo::modul
     33.           << ") = " << k << endl;
     34.  
     35.      k = k + 8;
     36.      cout << "k + 8 (mod " << Modulo::modul
     37.           << ") = " << k << endl;
     38.  }

W liniach 19-21 przeciążamy operator dodawania (zadeklarowany wewnątrz klasy w linii 14). Metoda ma jeden parametr typu const Modulo&, a zatem będzie wywołana zawsze, gdy napotkane zostanie w programie wyrażenie

       m + k
gdzie mk są obiektami klasy Modulo. Wywołanie będzie na rzecz obiektu  m, a  k zostanie przesłane jako pierwszy i jedyny jawny argument metody operator+ (przez referencję, bo tak zostało to zadeklarowane w linii 14 i następnie zdefiniowane). Tego typu sytuacja zachodzi w linii 30.

Zauważmy, że jeśli przeciążamy operator dwuargumentowy za pomocą metody, to odpowiednia funkcja zostanie wywołana, gdy po lewej stronie operatora pojawia się obiekt klasy. Zatem nie można w ten sposób przeładować operatora '<<', bo dla niego po lewej stronie mamy obiekt strumieniowy klasy ostream i to w niej musielibyśmy dokonać tego przeciążenia. Dlatego przeciążenia operatora '<<' dokonaliśmy za pomocą zaprzyjaźnionej funkcji globalnej (linie 23-25).

Tajemnicze zjawisko zachodzi w linii 35 (' k=k+8'). Po lewej stronie operatora dodawnia jest obiekt klasy Modulo, ale po prawej mamy po prostu liczbę typu int. Takie dodawanie byłoby „obsłużone” przez metodę w klasie Modulo o prototypie

       Modulo operator+(int);
ale jej nie dostarczyliśmy (choć, oczywiście, moglibyśmy to zrobić). Tymczasem błędu nie ma; wynik programu to
    5 + 6 (mod 7) = 4
    k + 8 (mod 7) = 5
co wygląda prawidłowo, bo 4 + 8 = 12≡5(mod 7). Dlaczego wszystko zadziałało? Wyjaśni się to w rozdziale o konwersjach . W skrócie: w takiej sytuacji kompilator postara się przekonwertować liczbę całkowitą na obiekt klasy Modulo i dopiero wtedy wykonać dodawania. Jak się przekonamy, taka konwersja jest możliwa dzięki istnieniu konstruktora przyjmującego jeden argument typu int (linie 11-12).


Jako drugi przykład rozpatrzmy klasę opisującą listę, trochę inną niż ta z programu simplista.c. Obiekt klasy List ma tylko jedną składową wskaźnikową, head, której wartość to adres pierwszego elementu listy. Same elementy tej listy są obiektami wewnętrznej klasy (struktury) Node — linie 6-13. Klasa ta jest zdefiniowana w sekcji prywatnej klasy List, a więc nie jest widoczna z zewnątrz. Obiekty klasy Node zawierają dane (w postaci jednej liczby typu int) oraz wskaźnik do następnego elementu listy.


P141: lista.cpp     Listy z przeciążonymi operatorami

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class List {
      5.  
      6.      struct Node {
      7.          int    elem;
      8.          Node* next;
      9.  
     10.          Node(int elem, Node* next = 0)
     11.              : elem(elem), next(next)
     12.          { }
     13.      };
     14.  
     15.      Node* head;
     16.  
     17.  public:
     18.      List()
     19.          : head(0)
     20.      { }
     21.  
     22.      List& operator+(int elem) {
     23.          Node* w = new Node(elem);
     24.          if (head) {
     25.              Node *h = head;
     26.              while (h->next) h = h->next;
     27.              h->next = w;
     28.          } else {
     29.              head = w;
     30.          }
     31.          return *this;
     32.      }
     33.  
     34.      List& operator-(int elem) {
     35.          head = new Node(elem,head);
     36.          return *this;
     37.      }
     38.  
     39.      int operator!() const {
     40.          int cnt = 0;
     41.          for (Node* h = head; h ; h = h->next, ++cnt);
     42.          return cnt;
     43.      }
     44.  
     45.      ~List() {
     46.          Node *prev, *curr = head;
     47.          while (curr) {
     48.              prev = curr;
     49.              curr = curr->next;
     50.              cerr << "usuwanie: " << prev->elem << endl;
     51.              delete prev;
     52.          }
     53.      }
     54.  
     55.      friend ostream& operator<<(ostream&, const List&);
     56.  };
     57.  
     58.  ostream& operator<<(ostream& s, const List& L) {
     59.          for(List::Node* h = L.head ; h ; h = h->next)
     60.              s << h->elem << " ";
     61.          return s;
     62.  }
     63.  
     64.  int main() {
     65.      List list;
     66.  
     67.      list + 1;
     68.      list + 2 - 0 - (-1);
     69.      cout <<  list+3  << endl;
     70.      cout << "List ma " << !list << " elementow" << endl;
     71.  }

Do dodawania elementów do listy użyliśmy tu przeciążenia operatorów dodawania i odejmowania. Operator ' +' opisany funkcją operator+ (linie 22-32) tworzy nowy element listy na podstawie liczby będącej prawym argumentem operatora, czyli pierwszym i jedynym argumentem metody. Utworzony element jest następnie „doczepiany” na koniec listy (kod w liniach 25-27). Zauważmy, że metoda zwraca przez referencję obiekt, na rzecz którego została wywołana (' *this'). Tak więc na przykład, jeśli list jest obiektem klasy List, opracowanie wyrażenia

       list + 5
spowoduje: W ten sposób wartością całego tego wyrażenia jest referencja do listy, a zatem możliwe jest kaskadowe dodanie do niej następnego elementu; wyrażenie
       list + 5 + 7
to dodanie najpierw elementu 5 do listy i następnie do tak zmodyfikowanej listy dodanie jeszcze elementu 7. Ta konstrukcja użyta została w liniach 68-69 programu.

Podobnie przeciążony jest operator odejmowania (linie 34-37). Różnica polega tylko na tym, że teraz nowy element dodawany jest na początek listy, czyli staje się jej „głową”.

W liniach 39-43 przeciążony został operator ' !'. Jest on jednoargumentowy, a więc definiująca go metoda jest bezparametrowa. Jak widać z definicji, zwraca on liczbę elementów listy — użycie jego widzimy w linii 70 programu (patrz też następny podrozdział). Wydruk pokazuje działanie tego programu:

    -1 0 1 2 3
    List ma 5 elementow
    usuwanie: -1
    usuwanie: 0
    usuwanie: 1
    usuwanie: 2
    usuwanie: 3
Na wydruku widzimy teź ślad działania destruktora, który usunął wszystkie utworzone węzły po wyjściu sterowania z funkcji main. Sam wydruk listy został sporządzony za pomocą przeciążonego operatora wstawiania do strumienia; funkcja przeciążająca (linie 58-62) została zaprzyjaźniona z klasą, gdyż potrzebuje dostępu nie tylko do składowej head ale i do nazwy Node zadeklarowanej w sekcji prywatnej klasy List. Ta funkcja, jak już mówiliśmy, musi być funkcją globalną, a nie metodą.


18.3.2 Operatory jednoargumentowe

Operatory jednoargumentowe definiowane są przez metody bezparametrowe. Argumentem jest niejawnie przesyłany przez wskaźnik obiekt na rzecz którego wywoływana jest metoda, czyli na który działa operator. Tak więc dla operatorów jednoargumentowych (prefiksowych) zapis

       @a
oznacza wywołanie
       a.operator@()
a deklaracja metody definiującej ten operator ma postać
       Typ operator@();
Przykład takiego przeciążenia widzieliśmy w już programie lista.cpp. Pojawienie się wyrażenia ' !list' (linia 70) spowodowało wywołanie na rzecz obiektu list metody operator! (zdefiniowanej w liniach 39-43).

Jednoargumentowe operatory ' ++' i ' -' wymagają specjalnego omówienia, gdyż występują w dwóch odmianach —  przyrostkowej i przedrostkowej. Jeśli przeładujemy je tak jak zwykłe operatory jednoargumentowe, to domyślnie będą to operatory przedrostkowe, a więc prawidłowy zapis wyrażeń z ich użyciem będzie miał postać

       ++a; --b;
Takie operatory powinny być definiowane w ten sposób, aby miały coś wspólnego ze zwiększeniem (zmniejszeniem) argumentu i zwracały l-wartość, bo tak jest dla ich standardowej implementacji dla liczb. Deklaracja metody definiującej przeciążenie preinkrementacji będzie wyglądała tak:
       Typ operator++();
Jak w takim razie zdefiniować metodę przeciążającą taki operator w jego wersji przyrostkowej? Nazwa musiałaby być taka sama i również musiałaby to być metoda bezparametrowa! Aby kompilator mógł odróżnić sytuację, gdy chcemy przeciążyć operator przyrostkowy, definiując metodę dodajemy „sztuczny” (pozorny) argument typu int, którego nie używamy w ciele funkcji definiującej przeciążenie. Zatem w deklaracji/definicji nie trzeba mu nawet nadawać nazwy; unikniemy w ten sposób ostrzeżeń kompilatora o nieużywanych zmiennych.

Deklaracja metody przeciążającej operator postinkrementacji będzie więc miała postać:

      Typ operator++(int);
Jeśli w ogóle potrzebny jest nam operator postdekrementacji lub postinkrementacji, to naturalne będzie zdefiniowanie go tak, aby zwracana wartość była identyczna z argumentem, a efekt zwiększenia/zmniejszenia, cokolwiek by to miało znaczyć w danym kontekście, był efektem ubocznym. Innymi słowy, preinkrementacja to „zwiększ i pobierz”, a postinkrementacja to „pobierz i zwiększ”. Wartość zwracana przez operatory przyrostkowe nie powinna być l-wartością.


Zwróćmy uwagę, że operatory '-' i '+' występują też w wersji jednoargumentowej: operator '+' to operator „no-op”, czyli operator, który nic nie robi, a operator '-' to operator zmiany znaku. Te wersje też można przeciążyć: ponieważ są jednoargumentowe, więc przeciążamy je oczywiście za pomocą metod bezargumentowych.

W poniższym programie przeciążone są wszelkiego rodzaju operatory „z minusem”: jedno- i dwuargumentowe operatory '-' i operatory -; te ostatnie zarówno w wersji przedrostkowej, jak i przyrostkowej:


P142: minus.cpp     Przeciążenia operatorów z „minusem”

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class A {
      5.      int data;
      6.  public:
      7.      A(int data = 0) : data(2*(data/2)) { }
      8.  
      9.      const A operator-() const {
     10.          return A(-data);
     11.      }
     12.  
     13.      const A operator-(A& a) const {
     14.          return A(data - a.data);
     15.      }
     16.  
     17.      A& operator--() {
     18.          ----data;
     19.          return *this;
     20.      }
     21.  
     22.      const A operator--(int) {
     23.          A x(data);
     24.          ----data;
     25.          return x;
     26.      }
     27.  
     28.      friend ostream& operator<<(ostream&,A);
     29.  };
     30.  
     31.  ostream& operator<<(ostream& strum, A d) {
     32.      return strum << d.data;
     33.  }
     34.  
     35.  int main() {
     36.      A data(7);
     37.  
     38.      cout << "a.   data    = " <<   data   << endl;
     39.      cout << "b.   data--  = " <<   data-- << endl;
     40.      cout << "c.   data    = " <<   data   << endl;
     41.      cout << "d. --data    = " << --data   << endl;
     42.      cout << "e.   data    = " <<   data   << endl;
     43.      cout << "f.  -data    = " <<  -data   << endl;
     44.      cout << "g.   data    = " <<   data   << endl;
     45.  }

W klasie A jest tylko jedno pole data typu int. Przechowywana tam liczba jest zawsze parzysta: jedyny konstruktor dba o to, aby tak było. Operatory zwiększenia i zmniejszenia są tak zdefiniowane, że dodają do lub odejmują od danej przechowywanej w składowej obiektu zawsze 2. Widzimy w tym programie przeciążenie:

Prócz tego, poprzez zaprzyjaźnioną funkcję globalną przeciążamy operator wstawiania do strumienia '<<' (linie 31-33). Wydruk tego programu
    a.   data    = 6
    b.   data--  = 6
    c.   data    = 4
    d. --data    = 2
    e.   data    = 2
    f.  -data    = -2
    g.   data    = 2
wskazuje, że wszystkie przeciążenia działają zgodnie z przewidywaniem.


Jako jeszcze jeden przykład rozpatrzmy program


P143: tabinc.cpp     Przeciążanie operatora zwiększenia

      1.  #include <iostream>
      2.  #include <cstring>  // memcpy
      3.  using namespace std;
      4.  
      5.  class Tablica {
      6.      int size;
      7.      int* tab;
      8.  public:
      9.      Tablica(int size, const int* t)
     10.          : size(size),
     11.            tab((int*)memcpy(new int[size], t,
     12.                             size*sizeof(int)))
     13.      { }
     14.  
     15.      Tablica(const Tablica& t)
     16.          : size(t.size),
     17.            tab((int*)memcpy(new int[size], t.tab,
     18.                             size*sizeof(int)))
     19.      { }
     20.  
     21.      ~Tablica() { delete [] tab; }
     22.  
     23.      Tablica& operator++();
     24.      Tablica operator++(int);
     25.      void showTab(const char* nap);
     26.  };
     27.  
     28.  Tablica& Tablica::operator++() {
     29.      for (int i = 0; i < size; ++i)
     30.          ++tab[i];
     31.      return *this;
     32.  }
     33.  
     34.  Tablica Tablica::operator++(int) {
     35.      Tablica t(*this);
     36.      ++*this;
     37.      return t;
     38.  }
     39.  
     40.  void Tablica::showTab(const char* nap) {
     41.      cout << nap;
     42.      for (int i = 0; i < size; i++)
     43.          cout << tab[i] << " ";
     44.      cout << endl;
     45.  }
     46.  
     47.  int main() {
     48.      int tab[] = {1,2,3,4};
     49.  
     50.      Tablica T(4,tab);
     51.      T.showTab("Tablica wyjsciowa T: ");
     52.      Tablica t = ++T;
     53.      t.showTab("  Po t = ++T t jest: ");
     54.      T.showTab("           a T jest: ");
     55.  
     56.      Tablica S(4,tab);
     57.      S.showTab("Tablica wyjsciowa S: ");
     58.      Tablica s = S++;
     59.      s.showTab("  Po s = S++ s jest: ");
     60.      S.showTab("           a S jest: ");
     61.  }

Zdefiniowana w tym programie klasa Tablica ma pole wskaźnikowe. Składową jest tu wskaźnik do alokowanej dynamicznie tablicy liczb całkowitych (dlatego musieliśmy zadbać o zwalnianie zaalokowanego obszaru pamięci w destruktorze). W ten sposób obiekt jest niewielki, a dla każdej tablicy alokujemy tylko tyle miejsca w pamięci, ile trzeba, ale nie więcej. Drugą składową każdego obiektu jest wymiar tablicy.

Zauważmy sposób kopiowania tablic w konstruktorach (linie 11 i 17). Kopiujemy tu cały obszar pamięci, a nie element po elemencie —  taki sposób kopiowania tablic jest dla typów prostych znacznie efektywniejszy, ale może nie działać jeśli elementami tablicy są obiekty klas!

Zauważmy, że przyrostkowy operator zwiększania (postinkrementacji), który jest zdefiniowany w liniach 34-38, zwraca obiekt identyczny z argumentem, ale sprzed modyfikacji. Zwiększenie jest tu efektem ubocznym, widocznym dopiero przy następnym użyciu obiektu. W definicji postinkrementacji, w linii 36, wykorzystaliśmy wcześniej przeciążony (linie 28-32) przedrostkowy operator zwiększania (preinkrementacji). W linii 35, za pomocą konstruktora kopiującego, tworzymy kopię całego obiektu, następnie wywołujemy na rzecz *this operator preinkrementacji, po czym zwracamy (linia 37) utworzoną wcześniej kopię.

Program drukuje:

    Tablica wyjsciowa T: 1 2 3 4
      Po t = ++T t jest: 2 3 4 5
               a T jest: 2 3 4 5
    Tablica wyjsciowa S: 1 2 3 4
      Po s = S++ s jest: 1 2 3 4
               a S jest: 2 3 4 5
W klasie tej, mimo że zawiera pola wskaźnikowe, nie przeciążyliśmy operatora przypisania tak, aby przypisanie było głębokie, a więc nie dotyczyło wskaźnika zawartego w obiekcie, ale danych we wskazywanej przez ten wskaźnik tablicy. W tego typu klasie takie przeciążenie powinno być zwykle zdefiniowane —  dokładniej przeciążaniem operatora ' =' zajmiemy się w następnym podrozdziale.

T.R. Werner, 25 lutego 2017; 22:31