Podrozdziały


18.4 Operatory „specjalne”

Operatory ' =', ' ->', ' ()' i ' []' są w pewien sposób specjalne, zatem omówimy je po kolei w osobnych podrozdziałach. Wszystkie one mogą być przeciążone wyłącznie za pomocą metod — nigdy za pomocą globalnych funkcji.


18.4.1 Operator przypisania

Operator przypisania ' =' powinien być przeładowany praktycznie zawsze, gdy w klasie występują pola wskaźnikowe (należy wtedy prawie zawsze zdefiniować też destruktor i konstruktor kopiujący). W przeciwnym razie użyty zostanie domyślny, dostarczony przez system operator przypisania, który skopiuje pole po polu zawartość obiektu z prawej strony przypisania do obiektu stojącego po lewej stronie, co prawdopodobnie nie jest tym, o co nam chodzi, jeśli występują w naszej klasie składowe wskaźnikowe. Wtedy bowiem nie chcemy zwykle kopiować wskaźników, ale raczej to, na co one wskazują. To, na co one wskazują, choć jest logicznie częścią obiektu, nie należy do niego fizycznie: obiekt tylko „przechowuje” odpowiedni adres — domyślnie tylko ten adres zostanie przekopiowany.

Żaden domyślny operator przypisania nie zostanie przez system wygenerowany, jeśli klasa zawiera pola ustalone (const), referencyjne lub pola będące obiektami klasy, w której operator przypisania jest prywatny, lub, z podobnych powodów, w ogóle go nie ma.

W takich przypadkach, nawet jeśli klasa nie zawiera pól wskaźnikowych, operator przypisania trzeba przeciążyć (jeśli w ogóle zamierzamy korzystać z przypisań obiektów klasy).

Zobaczmy, jakiego rodzaju błędy mogą pojawić się, gdy operator przypisania nie został przeciążony dla klasy z polem wskaźnikowym:


P147: ovrlderr.cpp     Brak odpowiedniego operatora przypisania

      1.  #include <iostream>
      2.  #include <cstring>   // strcpy, strlen
      3.  using namespace std;
      4.  
      5.  struct A {
      6.      char* name;
      7.  
      8.      A(const char* s)
      9.          : name(strcpy(new char[strlen(s)+1],s)) {
     10.          cerr << "  ctor: " << (void*)name << endl;
     11.      }
     12.  
     13.      A(const A& k)
     14.          : name(strcpy(new char[strlen(k.name)+1],k.name)) {
     15.          cerr << "cpctor: " << (void*)name << endl;
     16.      }
     17.  
     18.      ~A() {
     19.          cerr << "  dtor: "  << (void*)name << endl;
     20.          delete [] name;
     21.      }
     22.  };
     23.  
     24.  A ob1("ob1");
     25.  
     26.  int main() {
     27.      cerr << "MAIN" << endl;
     28.      A ob2(ob1);
     29.      A ob3 = ob2; // copy-ctor
     30.  
     31.      ob1 = ob3;
     32.  
     33.      cerr << "  ob1.name: " << (void*)ob1.name << endl;
     34.      cerr << "  ob3.name: " << (void*)ob3.name << endl;
     35.  
     36.      cerr << "THE END" << endl;
     37.  }

W liniach 8-11 definiujemy konstruktor tworzący obiekt na podstawie przesłanego napisu. W prawidłowy sposób dba on, aby tworzony obiekt zawierał wskaźnik do specjalnie zaalokowanego obszaru pamięci, a napis wskazywany przez adres będący argumentem konstruktora został tam przekopiowany. Użyta w linii 9 konstrukcja jest podobna do tej, jakiej użyliśmy w programie osoba3.cpp. Całość tej konstrukcji umieściliśmy w liście inicjalizacyjnej (patrz rozdział o listach inicjalizacyjnych ). Za pomocą

       new char[strlen(s)+1]
alokujemy pamięć o rozmiarze odpowiadającym długości napisu s (plus jeden na znak ' \0'). Funkcja strcpy kopiuje napis z  s do zaalokowanej pamięci, której adres jest jej pierwszym argumentem, i zwraca tenże adres. Ten właśnie adres (zwrócony przez funkcję strcpy) jest użyty do zainicjowania składowej name tworzonego obiektu, zgodnie ze składnią właściwą liście inicjalizacyjnej (funkcje takie jak strlen czy strcpy są dokładniej omówione w rozdziale o napisach ).

Podobnie jest zbudowany konstruktor kopiujący, zdefiniowany w liniach 13-16 (patrz rozdział konstruktorach kopiujących ).

W liniach 18-21 definiujemy destruktor. Wydaje się, że klasa jest kompletna. Jednak uruchamiając program widzimy, że coś jest źle:

      ctor: 0x804b008
    MAIN
    cpctor: 0x804b018
    cpctor: 0x804b028
      ob1.name: 0x804b028
      ob3.name: 0x804b028
    THE END
      dtor: 0x804b028
      dtor: 0x804b018
      dtor: 0x804b028
Po pierwsze, po wyjścu z funkcji main wywoływany jest trzy razy destruktor; to nas nie dziwi, bo utworzone zostały trzy obiekty, ale kłopot w tym, że dwa z nich zwalniają pamięć pod tym samym adresem 0x804b028. Natomiast pamięć pod adresem 0x804b008 która pierwotnie zawierała napis związany z obiektem ob1 w ogóle nie została zwolniona!

Łatwo widać, dlaczego tak się stało. Podczas przypisywania w linii 31 zawartość obiektu ob3 została przekopiowana do obiektu ob1. W szczególności została przekopiowana wartość składowej name, czyli adres napisu zaalokowanego w konstruktorze kopiującym podczas tworzenia obiektu ob3 (linia 29). Zatem po tym przypisaniu obiekty ob1ob3 są, co prawda, rozłączne, ale zawierają ten sam adres napisu w swoich składowych name (jak widać z wydruku, wynosi on tu 0x804b028). Co gorsza, adres pierwotnie pamiętany w składowej name obiektu obj1 (w tym przykładzie 0x804b008) uległ zamazaniu i jest już nie do odzyskania, zatem pamięć wskazywana przez ten adres nigdy nie będzie mogła być zwolniona! W momencie gdy obiekty są usuwane (w naszym przypadku po zakończeniu wykonywania funkcji main), uruchamiany jest destruktor zwalniający pamięć przydzieloną na napis wskazywany przez składową name. Zatem destruktor zostanie u nas wywołany dwa razy z tym samym adresem: raz przy usuwaniu obiektu ob3, a potem przy usuwaniu obiektu ob1. Może to spowodować załamanie programu (choć nie musi; tak czy owak jest to błąd o nieprzewidywalnych skutkach).

A zatem musimy tak przedefiniować operator przypisania, aby uniknąć tego rodzaju kłopotów. Zrobić to można wyłącznie za pomocą metody, nigdy funkcji globalnej lub statycznej funkcji składowej.

Zadaniem przypisania jest sensowne przepisanie zawartości obiektu po prawej stronie znaku ' =' do obiektu po lewej, którym będzie *this, bo metoda definiująca przypisanie będzie wywołana, jak zwykle dla operatorów dwuargumentowych, właśnie na rzecz obiektu stojącego po lewej stronie operatora.

W ciele metody musimy zwykle zadbać, aby dynamicznie zaalokowane obiekty (jak tablice, czyli również C-napisy), do których wskaźniki są składowymi obiektu, zostały we właściwy sposób przekopiowane. Ponieważ przypisanie a=a jest zawsze legalne, należy przy tym uważać, żeby nie zlikwidować obiektów (tablic, napisów) wskazywanych przez składowe wskaźnikowe przed ich kopiowaniem. Aby z kolei możliwe było kaskadowe przypisanie (czyli a=b=c), operator powinien zwracać referencję do obiektu po lewej stronie, czyli tego, na rzecz którego został wywołany (return *this). Argument (czyli obiekt po prawej stronie przypisania) jest często przekazywany przez referencję, zwykle ustaloną; unika się wtedy niepotrzebnego kopiowania. Zatem dla klasy A metoda przeciążająca operator przypisania miałaby nagłówek

       A& operator=(const A&);
Jako przykład rozpatrzmy następujący program:


P148: ovrldeq.cpp     Przeciążanie operatora przypisania

      1.  #include <iostream>
      2.  #include <cstring>   // strcpy, strlen
      3.  using namespace std;
      4.  
      5.  struct A {
      6.      char* name;
      7.  
      8.      A() : name(new char[1]) {
      9.          cerr << "dfctor: " << (void*)name << endl;
     10.          name[0] = '\0';
     11.      }
     12.  
     13.      A(const char* s)
     14.          : name(strcpy(new char[strlen(s)+1],s)) {
     15.          cerr << "  ctor: " << (void*)name << endl;
     16.      }
     17.  
     18.      A(const A& k)
     19.          : name(strcpy(new char[strlen(k.name)+1],k.name)) {
     20.          cerr << "cpctor: " << (void*)name << endl;
     21.      }
     22.  
     23.      A& operator=(const A& k) {
     24.  
     25.          if (this == &k) return *this;
     26.  
     27.          cerr << "delete: " << (void*)name << endl;
     28.          delete [] name;
     29.          name = strcpy(new char[strlen(k.name)+1],k.name);
     30.          cerr << "   op=: " << (void*)name << endl;
     31.          return *this;
     32.      }
     33.  
     34.      ~A() {
     35.          cerr << "  dtor: "  << (void*)name << endl;
     36.          delete [] name;
     37.      }
     38.  };
     39.  
     40.  A ob1("ob1");
     41.  
     42.  int main() {
     43.      cerr << "MAIN" << endl;
     44.      A ob2(ob1);
     45.      A ob3 = ob2; // copy-ctor
     46.  
     47.      ob1 = ob3;
     48.  
     49.      cerr << "  ob1.name: " << (void*)ob1.name << endl;
     50.      cerr << "  ob3.name: " << (void*)ob3.name << endl;
     51.  
     52.      cerr << "THE END" << endl;
     53.  }

Ta klasa jest w zasadzie taka sama jak ta z poprzedniego programu. Dopisaliśmy tu konstruktor bezargumentowy (domyślny), aby klasa była bardziej kompletna (linie 8-11). Zauważmy, że nawet alokując napis pusty, czyli składający się z jednego znaku (znaku ' \0'), alokujemy ten jeden bajt w postaci tablicy, a nie pojedynczej zmiennej. Przyczyną jest postać destruktora, który używa zawsze formy tablicowej (z nawiasami kwadratowymi) do zwalniania pamięci.

W liniach 23-32 definiujemy metodę przeciążającą operator przypisania. Zauważmy konstrukcję tej funkcji, gdyż jest ona typowa:

Program główny nie różni się od poprzedniego. Wynik jest jednak nieco inny:
      ctor: 0x804b008
    MAIN
    cpctor: 0x804b018
    cpctor: 0x804b028
    delete: 0x804b008
       op=: 0x804b008
      ob1.name: 0x804b008
      ob3.name: 0x804b028
    THE END
      dtor: 0x804b028
      dtor: 0x804b018
      dtor: 0x804b008
Teraz w czasie przypisania z linii 47 (' ob1=ob3') najpierw zwolniona została pamięć dotychczas zajmowana przez napis związany z obiektem ob1 (pod adresem 0x804b008), a następnie został przydzielony nowy obszar pamięci na napis. W tym przypadku system zaalokował ten sam obszar, który przed chwilą został zwolniony, ale nie jest to istotne —  równie dobrze mógł to być obszar pod zupełnie innym adresem. Ważne jest, że teraz nie ma żadnych wycieków pamięci —  każdy zaalokowany przez new segment pamięci jest zwalniany. Nie pojawia się też problem zwalniania pamięci już raz zwolnionej — wszystkie destruktory wywołują delete z adresami obszarów jeszcze nie zwolnionych.

Przypomnijmy tu na marginiesie, że wyrażenie

       A a = b;
nie oznacza przypisania; jest to inna forma deklaracji/definicji nowej zmiennej klasy z wywołaniem konstruktora kopiującego, a więc równoważna ' A a(b)'. Aby operator ' =' oznaczał przypisanie, po lewej stronie musi stać wyrażenie będące l-wartością i oznaczające istniejącą już wcześniej zmienną.

Inny przykład przeciążenia operatora przypisania znajdujemy w poniższym programie:


P149: op.cpp     Klasa tablicowa z przeciążonym operatorem przypisania

      1.  #include <iostream>
      2.  #include <cstring>   // memcpy
      3.  using namespace std;
      4.  
      5.  class Tabint {
      6.      static int ID;
      7.      int        id;
      8.      int      size;
      9.      int      *tab;
     10.  public:
     11.      Tabint(const int *t, int size)
     12.          : id(++ID), size(size),
     13.            tab((int*)memcpy(new int[size], t,
     14.                             size*sizeof(int))) {
     15.          cout << "Konstruktor: id = " << id << endl;
     16.      }
     17.  
     18.      Tabint(const Tabint& t)
     19.          : id(++ID), size(t.size),
     20.            tab((int*)memcpy(new int[size], t.tab,
     21.                             size*sizeof(int))) {
     22.          cout << "Konstr. kop: " << t.id << "-->" << id
     23.                                                   << endl;
     24.      }
     25.  
     26.      ~Tabint() {
     27.          cout << "Usuwamy:    id = " << id << endl;
     28.          delete [] tab;
     29.      }
     30.  
     31.      Tabint& operator=(const Tabint& t) {
     32.          cout << "Przypisanie: " << id << "<--" << t.id
     33.                                                 << endl;
     34.          if (this != &t) {
     35.              delete [] tab;
     36.              size = t.size;
     37.              tab = (int*)memcpy(new int[size], t.tab,
     38.                                 size*sizeof(int));
     39.          }
     40.          return *this;
     41.      }
     42.  };
     43.  int Tabint::ID = 0;
     44.  
     45.  int main() {
     46.      int tab[] = {1,2,3};
     47.      int size  = sizeof(tab)/sizeof(int);
     48.  
     49.      Tabint* pt1 = new Tabint(tab,size);
     50.      Tabint   t2 = *pt1;
     51.      Tabint   t3(t2);
     52.  
     53.      *pt1 = t2;
     54.  }

Zamiast funkcji strcpy kopiującej C-napisy użyliśmy tu funkcji memcpy o podobnym działaniu. Pozwala ona kopiować duże obszary pamięci „za jednym zamachem”, bez użycia pętli kopiującej tablice element po elemencie. Z wydruku

    Konstruktor: id = 1
    Konstr. kop: 1-->2
    Konstr. kop: 2-->3
    Przypisanie: 1<--2
    Usuwamy:    id = 3
    Usuwamy:    id = 2
zauważamy, że element pierwszy (utworzony w linii 49) nie jest usuwany. Dzieje się tak dlatego, że jako jedyny został utworzony na stercie (przez operator new) i jawnie nie usunięty. Pozostałe obiekty zostały utworzone na stosie, a więc będą usunięte automatycznie, co będzie się oczywiście wiązało z wywołaniem destruktora.


18.4.2 Operator indeksowania

Operator ' []' (indeksowania) jest dwuargumentowy: w wyrażeniu ' a[i]' nazwa a jest nazwą pierwszego argumentu, a  i drugiego. Funkcja przeciążająca ten operator musi być metodą jednoparametrową; będzie wywoływana na rzecz  a, które musi zatem być nazwą obiektu klasy, dla której definiujemy przeciążenie, natomiast indeks  i będzie przesłany jako argument. Argument ten (indeks) nie musi być typu całościowego, choć najczęściej jest.

Deklaracja metody przeciążającej operator ' []' powinna mieć postać

       Typ operator[](Typ_arg);
gdzie Typ jest typem funkcji (wartości zwracanej), a  Typ_arg jest typem indeksu. Metoda ta będzie wywołana na rzecz obiektu a, gdy pojawi się wyrażenie
       a[i]
i a będzie nazwą obiektu klasy, w której dokonaliśmy przeciążenia, a typ i jest zgodny z typem Typ_arg. Wywołanie to będzie formalnie miało postać
       a.operator[](i)
Funkcja przeciążająca operator ' []' może wykonywać dowolne zadanie, ale rozsądne jest, jeśli robi coś pojęciowo podobnego do wyłuskania wartości poprzez indeks. Ważne jest wtedy takie zdefiniowanie przeładowania, by obiekt zwracany był l-wartością (co możemy uzyskać zwracają go przez referencję), czyli by było możliwe przypisanie do niego, a zatem aby mógł występować również po lewej stronie przypisania — takie jest bowiem normalne zachowanie operatora indeksowania. Zatem oba poniższe wyrażenia powinny być poprawne:
       x = arr[i];
       arr[j] = y;

Prosta klasa Litera z poniższego programu demonstruje zarówno przeciążanie operatora indeksowania, jak i przeciążanie operatora przypisania, konstruktor kopiujący i destruktor: a więc komplet tego co jest wymagane przy istnieniu składowych wskaźnikowych.


P150: litera.cpp     Przeciążanie operatora indeksowania

      1.  #include <iostream>
      2.  #include <cstring>
      3.  using namespace std;
      4.  
      5.  class Litera {
      6.      char* napis;
      7.  public:
      8.      Litera(const Litera& k)
      9.          : napis(strcpy(new char[strlen(k.napis)+1],
     10.                         k.napis))
     11.      { }
     12.  
     13.      Litera(const char* napis)
     14.          : napis(strcpy(new char[strlen(napis)+1],
     15.                         napis))
     16.      { }
     17.  
     18.      Litera& operator=(const Litera&);
     19.      char& operator[](int);
     20.  
     21.      ~Litera() { delete [] napis; }
     22.  
     23.      friend ostream& operator<<(ostream&,const Litera&);
     24.  };
     25.  
     26.  char& Litera::operator[](int n) {
     27.      int len = strlen(napis);
     28.      if ( n < 0 || n >= len )
     29.          // zwracamy odnośnik do NUL jeśli zły indeks
     30.          return napis[len];
     31.      else
     32.          return napis[n];
     33.  }
     34.  
     35.  Litera& Litera::operator=(const Litera& k) {
     36.      if (this == &k ) return *this;
     37.      delete [] napis;
     38.      napis = strcpy(new char[strlen(k.napis)+1],k.napis);
     39.      return *this;
     40.  }
     41.  
     42.  ostream& operator<<(ostream& str, const Litera& k) {
     43.      return str << k.napis;
     44.  }
     45.  
     46.  int main() {
     47.      Litera a("Kasia");
     48.      cout << "a=" << a << endl;
     49.  
     50.      char c = a[100];
     51.      if (c == '\0') cout << "Zly zakres!" << endl;
     52.      else           cout << "znak: " << c << endl;
     53.  
     54.      c = a[0];
     55.      if (c == '\0') cout << "Zly zakres!" << endl;
     56.      else           cout << "znak: " << c << endl;
     57.  
     58.      a[0] = 'B';
     59.      cout << "a=" << a << endl;
     60.  }

Jak widać z definicji w liniach 26-33, działanie operatora indeksowania polega tu na dostarczeniu, przez referencję, znaku napisu o odpowiednim indeksie (pozycji). Metoda ta jest o tyle sensowna, że sprawdza zakres i w przypadku, gdy indeks jest za duży albo za mały, zwraca referencję do znaku ' \0', zamiast zwracać jakąś przypadkową wartość. Wydruk z tego programu to

    a=Kasia
    Zly zakres!
    znak: K
    a=Basia
Widzimy, że rzeczywiście indeksowanej nazwy obiektu klasy Litera możemy używać tak jak nazwy tablicy: zarówno po prawej, jak i po lewej stronie przypisania.

Spójrzmy na przykład, w którym indeks nie jest całkowity:


P151: zakr.cpp     Indeksowanie liczbą rzeczywistą

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class Zakresy {
      5.      int    licz[3];
      6.      double min, max;
      7.  public:
      8.      Zakresy(double min, double max)
      9.          : min(min), max(max) {
     10.          licz[0]=licz[1]=licz[2]=0;
     11.      }
     12.  
     13.      int& operator[](double x) {
     14.          int i;
     15.  
     16.          if      ( x >  max ) i = 2;
     17.          else if ( x >= min ) i = 1;
     18.          else                 i = 0;
     19.  
     20.          return licz[i];
     21.      }
     22.  
     23.      friend ostream& operator<<(ostream&, const Zakresy&);
     24.  };
     25.  
     26.  ostream& operator<<(ostream& str, const Zakresy& z) {
     27.      return str << "Zakres = [" << z.min << ", " << z.max
     28.                 << "]:  " << "Ponizej " << z.licz[0]
     29.                 << "; w zakr. " << z.licz[1] << "; Powyzej "
     30.                 << z.licz[2];
     31.  }
     32.  
     33.  int main() {
     34.      Zakresy zakres(3.0, 5.5);
     35.      double x;
     36.      cout << "Podaj liczby (zero konczy)" << endl;
     37.  
     38.      while ( ( cin >> x) && x ) zakres[x]++;
     39.  
     40.      cout << zakres << endl;
     41.      cout << "x = 4.7 -> licz = " << zakres[4.7] << endl;
     42.  }

Klasa Zakresy definiuje składowe min, max oraz trzyelementową tablicę liczb całkowitych licz, której zadaniem jest zliczanie „danych” poniżej zakresu [min, max], wewnątrz tego zakresu oraz powyżej tego zakresu. Operator indeksowania jest przeciążony za pomocą metody o nazwie operator[] pobierającej argument typu double. Działanie jej polega na tym, że jeśli zakres jest obiektem klasy Zakresy, to dla x typu double wyrażenie zakres[x] jest referencją do odpowiedniego elementu tablicy licz będącej składową tego obiektu: licz[0], jeśli wartość x leży poniżej min, licz[1], jeśli wartość ta leży w zakresie definiowanym przez minmax, a  licz[2], jeśli wypada powyżej max. A zatem użyte w linii 38 wyrażenie zakres[x]++ zwiększa odpowiedni element tablicy licz w zależności od wartości x. Natomiast wartość zakres[4.7] w linii 41 to wartość tego elementu tablicy licz, którego indeks odpowiada podanej wartości (4.7 mieści się w zakresie [3,5.5], wobec tego jest to aktualna wartość licz[1]). Wydruk z tego programu

    Podaj liczby (zero konczy)
    6  8.9  1  3.4  5  3.1
    1    2  3    4  5    6  4.6  2.8  0
    Zakres = [3, 5.5]:  Ponizej 4; w zakr. 7; Powyzej 3
    x = 4.7 -> licz = 7
potwierdza prawidłowość przeciążenia. W linii 38 w warunku zakończenia pętli while użyta została koniunkcja wyrażeń logicznych, dzięki której wczytywanie zakończy się zarówno wtedy gdy stan strumienia cin przejdzie w bad (na skutek błędu lub dojścia do końca pliku), jak i wtedy, gdy wczytana zostanie liczba 0 (zero). Indeksowanie w tym programie zwraca referencję do składowej prywatnej. Nie jest to praktyka godna polecenia: tu została użyta wyłącznie w celach dydaktycznych.


18.4.3 Operator wywołania

Operator wywołania ' ()' jest jedynym, który może mieć dowolną liczbę argumentów. Przeciążać go można tylko za pomocą metody. Metoda ta ma nazwę operator() — tu nawiasy są częścią nazwy, za którą to nazwą w zwykły sposób umieszczamy listę, być może pustą, parametrów ujętą w następną parę nawiasów. Deklaracja takiej metody ma więc postać

       Typ operator()(Typ_arg1, Typ_arg2, Typ_arg3);
przy czym liczba i typ parametrów są dowolne. Wywołanie metody będzie miało miejsce po napotkaniu wyrażenia
       obj(a,b,c)
gdzie obj jest nazwą pewnego obiektu klasy, w której dokonaliśmy przeciążenia, a  a, bc są typów zgodnych z typami parametrów metody. Wywołanie będzie na rzecz obiektu obj i będzie miało postać
       obj.operator()(a,b,c)
Zauważmy, że postać wyrażenia obj(a,b,c) jest taka sama jak postać normalnego wywołania funkcji, z tą tylko różnicą, że obj jest tu nazwą obiektu, a nie funkcji. Takie obiekty można zatem „wywoływać” jak funkcje — nazywamy je zatem obiektami funkcyjnymi (ang. callable object, functor) lub obiektami wywoływalnymi. Wywołanie nie musi zwracać l-wartości, tak jak nie zwraca l-wartości większość „normalnych” funkcji (zwracających wynik przez wartość). Równie dobrze może zwrócić odniesienie (referencję), która jest l-wartością.

W poniższym przykładzie dzięki przeciążeniu operatora ' ()' uzyskana została prosta implementacja dynamicznie alokowanych tablic trzywymiarowych (dynamicznie, a więc których wymiary mogą być ustalone dopiero podczas wykonania). W tym przypadku typem zwracanym jest odnośnik (referencja) do liczby typu int (elementu macierzy) tak, że metoda zwraca l-wartość; wywołanie z trzema argumentami zastępuje potrójne indeksowanie tablicy trzywymiarowej.


P152: arr3dim.cpp     Przeciążanie operatora wywołania

      1.  #include <iostream>
      2.  #include <cstring>
      3.  #include <iomanip>
      4.  using namespace std;
      5.  
      6.  class Arr3D {
      7.      int  dim1, dim2, dim3;
      8.      int* arr;
      9.  public:
     10.      Arr3D() : dim1(1),dim2(1),dim3(1),
     11.                arr(new int[1])
     12.      { }
     13.  
     14.      Arr3D(int dim1, int dim2, int dim3)
     15.          : dim1(dim1), dim2(dim2), dim3(dim3),
     16.            arr(new int[dim1*dim2*dim3])
     17.      { }
     18.  
     19.      Arr3D(const Arr3D& t)
     20.          : dim1(t.dim1), dim2(t.dim2), dim3(t.dim3),
     21.            arr((int*)memcpy(new int[dim1*dim2*dim3],
     22.                             t.arr,
     23.                             dim1*dim2*dim3*sizeof(int)))
     24.      { }
     25.  
     26.      ~Arr3D() { delete [] arr; }
     27.  
     28.      Arr3D& operator=(const Arr3D&);
     29.      int& operator()(int,int,int);
     30.  };
     31.  
     32.  int& Arr3D::operator()(int n1, int n2, int n3)  {
     33.      return *(arr + n1*dim2*dim3 + n2*dim3 + n3);
     34.  }
     35.  
     36.  Arr3D& Arr3D::operator=(const Arr3D& t) {
     37.      if ( this != &t ) {
     38.          delete [] arr;
     39.          dim1 = t.dim1;
     40.          dim2 = t.dim2;
     41.          dim3 = t.dim3;
     42.          arr  = (int*)memcpy(new int[dim1*dim2*dim3],
     43.                              t.arr,
     44.                              dim1*dim2*dim3*sizeof(int));
     45.      }
     46.      return *this;
     47.  }
     48.  
     49.  int main() {
     50.      int dim1 = 1000, dim2 = 1000, dim3 = 50;
     51.  
     52.      Arr3D T1(dim1,dim2,dim3), T2;
     53.  
     54.      for (int i = 0; i < dim1; i++)
     55.          for (int j = 0; j < dim2; j++)
     56.              for (int k = 0; k < dim3; k++)
     57.                  T1(i,j,k) = i+j+k;
     58.      T2 = T1;
     59.  
     60.      cout << "T2(999, 999 , 2) = "
     61.           << setw(4) << T2(999,999,2) << endl;
     62.  
     63.      cout << "T2(  0,    0, 9) = "
     64.           << setw(4) << T2(  0,  0,9) << endl;
     65.  }

Zauważmy, że w klasie tej zdefiniowaliśmy konstruktor kopiujący, destruktor i przeciążyliśmy operator przypisania: jest to konieczne, bo klasa zawiera pole wskaźnikowe. Obiekty tej klasy reprezentują tablice trzywymiarowe (o trzech indeksach). Tak naprawdę, jak widzimy z definicji klasy, tablica arr jest wskaźnikiem do tablicy jednowymiarowej o wymiarze równym iloczynowi wymiarów dim1, dim2dim3. Dostęp do tej tablicy z zewnątrz mamy wyłącznie za pomocą metody operator() (linie 32-34), a więc za pomocą wyrażeń typu T(i,j,k), gdzie T jest nazwą obiektu klasy Arr3D. Metoda traktuje argumenty jak indeksy i wylicza na ich podstawie odpowiadający żądanemu elementowi indeks w tablicy jednowymiarowej arr (jak widać, do obliczenia tego indeksu nie jest potrzebny pierwszy wymiar dim1 tablicy trzywymiarowej). Ponieważ zwracana jest referencja do odpowiedniego elementu tablicy, wyrażenie T(i,j,k) wygląda i zachowuje się jak element T[i][j][k] „normalnej” tablicy trzywymiarowej, w szczególności jest l-wartością, a zatem może występować również po lewej stronie przypisania, jak w linii 57.

W programie tworzymy obiekt T1 klasy Arr3D odpowiadający trzywymiarowej tablicy o wymiarach 1000×1000×50 oraz drugi obiekt T2, tworzony za pomocą konstruktora domyślnego, a więc o wymiarach 1×1×1 (linia 52). Wewnątrz obiektu T1 tablica jest reprezentowana jako jednowymiarowa tablica 50 milionów elementów, ale ponieważ jest to składowa prywatna, użytkownik klasy nie musi o tym wiedzieć. W liniach 54-57 zapełniamy tę tablicę — każdy element jest sumą odpowiadających mu indeksów. W linii 58 dokonujemy przypisania dwóch obiektów klasy Arr3D, aby sprawdzić prawidłowość przeciążenia operatora przypisania. Następnie drukujemy dwie przykładowe wartości elementów

    T2( 999, 999 , 2) = 2000
    T2(   0,    0, 9) =    9
aby przekonać się, że wszystko przebiega poprawnie i wartości T(i,j,k) można rzeczywiście traktować jak elementy trzywymiarowej tablicy. Na marginesie zauważmy, że operowanie na dużych macierzach wymaga uwagi i pewnego doświadczenia; na przykład zmieniając w liniach 54-56 kolejność pętli otrzymalibyśmy równoważny program, który jednak na większości maszyn wykonywałby się 4-6 razy wolniej.


18.4.4 Operator wyboru składowej przez wskaźnik

Operator wyboru składowej przez wskaźnik (' ->') jest przeciążany jako operator jednoargumentowy: musi być zatem przeciążany jako bezparametrowa metoda klasy. Niejawnym argumentem jest więc obiekt, którego nazwa pojawia się po lewej stronie operatora — na rzecz tego właśnie obiektu metoda zostanie wywołana, bez przekazywania żadnych jawnych argumentów. Zauważmy, że po lewej stronie operatora ' ->' stoi w tym przypadku obiekt, a nie, jak normalnie, wskaźnik (przypomnijmy, że operator bezpośredniego wyboru składowej, operator „kropka”, w ogóle nie może być przeciążany)

Wartością zwracaną przez metodę przeciążającą musi być wartość wskaźnikowa (adres), która następnie zostanie użyta jako lewy operand „zwykłego” operatora ' ->'. Oczywiście, możliwa jest też sytuacja, że zwrócony zostanie obiekt, dla którego znowu przeciążony jest operator ->, który z kolei zwraca wskaźnik (lub znowy taki obiekt...).

Tak więc, jeśli obj jest nazwą obiektu klasy, w której przeciążony został operator ' ->', to wyrażenie

       obj->b
jest równoważne
       temp = obj.operator->(), temp->b
Innymi słowy:

W poniższym przykładzie dla obiektu AB klasy Segment wyrażenie AB->x zwraca współrzędną x-ową jednego z dwu punktów — obiektów klasy Point —  określających odcinek. Typem zwracanym przez metodę operator-> w klasie Segment jest wskaźnik do ustalonego obiektu klasy Point (a nie klasy Segment):


P153: ovrlskl.cpp     Przeciążanie operatora wyboru składowej

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  struct Point {
      5.      int x, y;
      6.  
      7.      Point(int x = 0, int y = 0) : x(x), y(y)
      8.      { }
      9.  
     10.      double r2() const { return x*x + y*y; }
     11.  };
     12.  
     13.  struct Segment {
     14.      Point A, B;
     15.  
     16.      Segment(Point A = Point(), Point B = Point())
     17.          : A(A), B(B)
     18.      { }
     19.  
     20.      const Point* operator->() const {
     21.          return (A.r2() < B.r2()) ? &A : &B;
     22.      }
     23.  };
     24.  
     25.  ostream& operator<<(ostream& str, const Point& A) {
     26.      return str << "P[" << A.x << "," << A.y << "]";
     27.  }
     28.  
     29.  ostream& operator<<(ostream& str, const Segment& AB) {
     30.      return str << AB.A << "--" << AB.B;
     31.  }
     32.  
     33.  int main() {
     34.  
     35.      Point    A(1,0),  B(8,6),  C(4,3);
     36.      Segment AB(A,B), BC(B,C), CA(C,A);
     37.  
     38.      cout << "AB = " << AB << ": AB->x = "
     39.                      << AB->x << endl;
     40.  
     41.      cout << "BC = " << BC << ": BC->y = "
     42.                      << BC->y << endl;
     43.  
     44.      cout << "CA = " << CA << ": CA->x = "
     45.                      << CA->x << endl;
     46.  }

Metoda przeciążająca zwraca wskaźnik do tego z końców odcinka, który leży bliżej początku układu współrzędnych (linia 21). Z obiektu (punktu) wskazywanego przez ten wskaźnik, za pomocą „zwykłego” operatora ' ->', wybierana jest składowa  x lub  y. Widzimy to w liniach 38-45, które drukują wynik:

    AB = P[1,0]--P[8,6]: AB->x = 1
    BC = P[8,6]--P[4,3]: BC->y = 3
    CA = P[4,3]--P[1,0]: CA->x = 1
Na przykład BC w liniach 41-42 jest nazwą obiektu klasy Segment reprezentującego odcinek o końcach opisywanych przez obiekty BC klasy Point. W klasie Segment operator ' ->' został przeciążony (linie 20-22) i zwraca adres jednego z określających ten odcinek punktów. To właśnie z tego obiektu klasy Point wybrana zostanie składowa y. Zauważmy, że w klasie Segment, której obiektem jest BC, składowej o nazwie y w ogóle nie ma. Ponadto BC jest tu nazwą obiektu, a nie wskaźnika, więc normalnie trzeba by tu było użyć kropki a nie „strzałki”.

T.R. Werner, 23 lutego 2019; 23:59