Podrozdziały


18.2 Przeciążenia za pomocą funkcji globalnych

Operator przeciążać można za pomocą zdefiniowanych globalnie funkcji. Definicja taka wygląda jak normalna definicja funkcji: specjalna jest tylko nazwa tej funkcji. Rozpatrzmy osobno przeciążanie operatorów dwu- i jedno argumentowych.


18.2.1 Operatory dwuargumentowe

Globalną funkcję przeładowującą operator dwuargumentowy definiujemy jako funkcję dwóch argumentów, z których co najmniej jeden musi być typu zdefiniowanego w programie (a nie typu wbudowanego). Nazwą tej funkcji musi być 'operator', gdzie ” jest symbolem operatora, a więc jednym z symboli wymienionych w tabeli operatorów będącym symbolem któregoś z operatorów dwuargumentowych, jak na przykład symbolem ' +' lub '<<'. Zauważmy, że nazwa tej funkcji jest specjalna: po pierwsze słowo ' operator' jest tu słowem kluczowym i musi być tak właśnie napisane, a po drugie symbol nie będący literą, cyfrą lub znakiem podkreślenia normalnie nie byłby dozwolony w identyfikatorze (nazwie) funkcji.

Tak więc deklaracja takiej funkcji ma postać

       Typ operator@(Typ1, Typ2);
gdzie co najmniej jeden z typów parametrów jest zdefiniowany przez nas w programie (nie jest typem wbudowanym), a więc jego nazwa jest nazwą zdefiniowanej przez nas klasy/struktury. Typ Typ wartości zwracanej może być dowolny. Zamiast symbolu ” powinien oczywiście być użyty symbol tego operatora, który chcemy przeciążyć. Funkcja ta często powinna być zaprzyjaźniona z naszą klasą, jeśli chcemy, aby miała dostęp do jej prywatnych lub chronionych składowych. Nie jest to jednak konieczne.

Tak zdefiniowaną funkcję można wywołać jawnie, „po nazwie”, ale zwykle się tego nie robi, bo cały sens przeładowywania operatorów polega na tym, by używać tych operatorów zamiast jawnego wywoływania funkcji. Funkcja ta wywołana będzie „samoczynnie”, gdy napotkane będzie wyrażenie

       a @ b
i zarówno symbol ”, jak i typy zmiennych a i b będą odpowiadać deklaracji/definicji funkcji. Wyrażenie to jest zatem równoważne jawnemu wywołaniu naszej funkcji:
       operator@(a,b)
W poniższym przykładzie definiujemy bardzo prostą klasę Modulo:


P136: modsev.cpp     Przeciążenie operatora dodawania

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

Obiekty tej klasy reprezentują liczby całkowite modulo 7 (każda liczba jest reprezentowana resztą, jaką daje przy dzieleniu przez 7; na przykład 11 i 32 z tego punktu widzenia są równe, bo przy dzieleniu przez 7 dają resztę 4). Istnieje zatem tylko siedem różnych liczb tej klasy [0,..., 6]. Jeśli dodajemy tego rodzaju liczby, to wynik też powinien mieć taką postać; na przykład

\begin{displaymath}
5 + 6 \equiv 4\pmod{7}
\end{displaymath}

Przedefiniowujemy więc dodawanie liczb typu Modulo. W liniach 17-19 definiujemy funkcję o nazwie operator+, z parametrami typu Modulo. Funkcja ta będzie wywołana, jeśli w programie pojawi się operator ' +', a po obu jego stronach będą wyrażenia o wartościach typu Modulo. Tak jest w linii 24. Zatem wywołana zostanie nasza funkcja i zwróci obiekt klasy Modulo reprezentujący sumę argumentów, bo tak została zdefiniowana.

W linii 29 widzimy, że jeśli się uprzemy, to możemy naszą funkcję wywołać normalnie, „po nazwie”. W obu przypadkach dostaniemy ten sam rezultat

    5 + 6 (mod 7) = 4
    5 + 6 (mod 7) = 4
W przykładzie tym przeładowanie odejmowania, zwiększenia czy mnożenia byłoby jak najbardziej naturalne; nie zrobiliśmy tego, aby przykład był krótszy. Zauważmy, że typem zwracanym przy dodawaniu jest Modulo, a więc rezultat jest zwracany przez wartość i nie jest l-wartością (w zgodzie ze zwykłym rozumieniem dodawania liczb typów wbudowanych).

W podobny sposób można przeładować inne operatory dwuargumentowe, jak operator dzielenia, reszty z dzielenia, odejmowania itd.


Jednym z najczęściej przeciążanych operatorów jest operator '<<'. Jaki jest typ argumentów tego operatora? Po lewej stronie mamy identyfikator obiektu klasy ostream reprezentującego strumień wyjściowy. Po prawej zaś obiekt typu int, double itd. W tych przypadkach kompilator znajdzie odpowiednią funkcję, bo dla typów wbudowanych są one częścią biblioteki. Jeśli prawym argumentem będzie obiekt typu przez nas zdefiniowanego, to odpowiedniej funkcji nie będzie i wystąpi błąd ... chyba że taką funkcję dostarczymy. Jaki powinien być jej typ zwracany? Jeśli mamy zamiar pisać tylko

       cout << zzz;
gdzie zzz jest identyfikatorem obiektu naszej klasy, to w zasadzie typ zwracany nie ma znaczenia. Ale jeśli chcielibyśmy używać operatora '<<' kaskadowo
       cout << zzz << " " << yyy;
to wartość wyrażenia 'cout << zzz' powinna być referencją do obiektu strumienia, tego samego, który stoi po lewej stronie operatora.

Tak więc, jeśli chcemy „nauczyć” program, jak wypisywać obiekty naszej klasy Klasa do strumienia wyjściowego (związanego z ekranem komputera lub plikiem) za pomocą operatora '<<', powinniśmy zdefiniować funkcję o prototypie

       ostream& operator<<(ostream&, const Klasa&);
Pierwszy parametr, typu ostream&, nie może być ustalony (const), bo podczas zapisu stan obiektu strumieniowego zmienia się. Nie może też być typu ostream, bo przekazywanie argumentu przez wartość wymagałoby kopiowania, a konstruktor kopiujący w klasie ostream jest prywatny; z tego samego powodu rezultat musi być zwracany przez referencję a nie przez wartość.

Obiekt klasy Klasa (drugi parametr) może być przesłany przez wartość lub referencję. W pierwszym przypadku trzeba zastanowić się, czy potrzebny jest odpowiedni konstruktor kopiujący, aby przekazanie przez wartość (związane z odkładaniem kopii na stosie) miało sens. W drugim przypadku typ parametru nie musi wprawdzie, ale może i najczęściej powinien, być ustalony, bo funkcja będzie miała dostęp do oryginału, a jej zadaniem jest przecież zwykle wypisanie informacji o obiekcie, a nie jego zmiana.

Jeśli przy wypisywaniu informacji o obiekcie potrzebny jest dostęp do prywatnych lub chronionych składowych, to funkcję tę należy też zadeklarować jako zaprzyjaźnioną wewnątrz klasy:

       class Klasa {
           // ...
           friend ostream& operator<<(ostream&, const Klasa&);
       }
Funkcja powinna zwracać, poprzez wykonanie instrukcji return, swój pierwszy argument, czyli referencję do obiektu reprezentującego strumień wyjściowy, do którego nastąpił zapis (nie musi to być cout; może to być dowolny obiekt klasy ostream lub klasy z niej dziedziczącej).

Rozpatrzmy zatem przykład:


P137: opwyj.cpp     Przeciążenie operatora wstawiania do strumienia

      1.  #include <iostream>
      2.  #include <string>
      3.  using namespace std;
      4.  
      5.  class Osoba {
      6.      string nazw;
      7.      int    wiek;
      8.  public:
      9.      Osoba(string nazw, int wiek)
     10.          : nazw(nazw), wiek(wiek)
     11.      { }
     12.  
     13.       // ... inne skladowe
     14.  
     15.      friend ostream& operator<<(ostream&, const Osoba&);
     16.  };
     17.  
     18.  ostream& operator<<(ostream& str, const Osoba& k) {
     19.      return str << k.nazw << " (" << k.wiek << " lat)";
     20.  }
     21.  
     22.  int main() {
     23.      Osoba t[] = {  Osoba("Ola",18), Osoba("Ula",26),
     24.                     Osoba("Ala",35), Osoba("Ela",11)  };
     25.  
     26.      for (int i = 0; i < 4; i++)
     27.          cout << t[i] << endl;
     28.  }

W linii 15, wewnątrz definicji klasy Osoba, deklarujemy zaprzyjaźnioną funkcję przeciążającą operator '<<' dla obiektów tej klasy. Dzięki temu funkcja ta, nie będąc składową klasy, będzie miała bezpośredni dostęp do prywatnych składowych nazwwiek, o czym przekonuje nas wydruk z tego programu

    Ola (18 lat)
    Ula (26 lat)
    Ala (35 lat)
    Ela (11 lat)
Sama funkcja przeciążająca jest zdefiniowana w liniach 18-20 zgodnie z zasadami, o jakich mówiliśmy wyżej. Jest to funkcja globalna, nie będąca składową klasy, więc oczywiście nie jest wywoływana na rzecz obiektu: obiekt, o który nam chodzi, jest przesyłany przez argument (jest to obiekt występujący po prawej stronie operatora '<<'). Wewnątrz funkcji nie istnieje wobec tego wskaźnik this. Zauważmy, że rezulatatem całego wyrażenia w instrukcji return jest obiekt-strumień str, który właśnie powinien zostać zwrócony: dlatego cała treść funkcji mieści się tu w tej jednej instrukcji return.


Operator dwuargumentowy możemy przeciążyć tak, aby oba jego argumenty były typu zdefiniowanego przez nas, przy czym każdy z nich może być innego typu. W poniższym przykładzie operator dodawania przeciążony jest tak, aby móc dodawać wektory (obiekty klasy Vector) do wektorów — wynikiem jest wtedy wektor — i wektory do punktów: wynikiem jest wtedy punkt. W tym celu dostarczamy dwóch definicji przeciążonego operatora dodawania: właściwy zostanie wybrany przez kompilator na podstawie typów argumentów dodawania (linie 50 i 53):


P138: vecpoin.cpp     Przeciążanie operatorów binarnych

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class Point;
      5.  
      6.  class Vector {
      7.      double x, y, z;
      8.  public:
      9.      Vector(double x = 0, double y = 0, double z = 0)
     10.          : x(x), y(y), z(z)
     11.      { }
     12.      friend Point    operator+(const Point&,  const Vector&);
     13.      friend Vector   operator+(const Vector&, const Vector&);
     14.      friend ostream& operator<<(ostream&, const Vector&);
     15.  };
     16.  
     17.  class Point {
     18.      double x, y, z;
     19.  public:
     20.      Point(double x = 0, double y = 0, double z = 0)
     21.          : x(x), y(y), z(z)
     22.      { }
     23.      friend Point operator+(const Point&, const Vector&);
     24.      friend ostream& operator<<(ostream&, const Point&);
     25.  };
     26.  
     27.  Point operator+(const Point&  p, const Vector& v) {
     28.      return Point(p.x+v.x, p.y+v.y, p.z+v.z);
     29.  }
     30.  
     31.  Vector operator+(const Vector& v1, const Vector& v2) {
     32.      return Vector(v1.x+v2.x, v1.y+v2.y, v1.z+v2.z);
     33.  }
     34.  
     35.  ostream& operator<<(ostream& str, const Point& p) {
     36.      return str << "P(" << p.x << "," << p.y
     37.                 << ","  << p.z << ")";
     38.  }
     39.  
     40.  ostream& operator<<(ostream& str, const Vector& v) {
     41.      return str << "V[" << v.x << "," << v.y
     42.                 << ","  << v.z << "]";
     43.  }
     44.  
     45.  int main() {
     46.  
     47.      Vector v1(1,1,1), v2(2,2,2);
     48.      Point  p1(1,2,3);
     49.  
     50.      Vector v = v1 + v2;
     51.      cout << "v: " << v << endl;
     52.  
     53.      Point  p = p1 + v;
     54.      cout << "p: " << p << endl;
     55.  }

Ponieważ funkcje przeciążające operatory dodawania i wstawiania do strumienia korzystają z bezpośredniego dostępu do prywatnych składowych klas VectorPoint, zostały w definicji klas zadeklarowane jako zaprzyjaźnione. Zauważmy, że deklaracja zapowiadająca w linii 4 jest tu konieczna, bo w definicji klasy Vector jest używana nazwa Point i odwrotnie. Obie funkcje przeciążające dodawanie zwracają wynik przez wartość; w ten sposób zapewniamy, że wynik nie jest l-wartością: jest to zachowanie, jakiego właśnie spodziewamy się po dodawaniu.


18.2.2 Operatory jednoargumentowe

Analogicznie można przeciążać operatory jednoargumentowe: funkcja (globalna) o prototypie

       Typ operator@(Typarg);
definiuje przeciążenie funkcji o jednym argumencie (którym musi być obiekt klasy zdefiniowanej w programie). Symbol ” musi zatem odpowiadać symbolowi któregoś z operatorów jednoargumentowych, na przykład ' &' czy ' !'. Wyrażenie
       @a
gdzie a jest identyfikatorem obiektu (lub referencji do obiektu) naszej klasy, będzie wtedy równoważne wywołaniu
       operator@(a)
W poniższym programie przeciążamy jednoargumentowy operator ' !'. Klasa AClass ma pole typu napisowego i jak widzimy z definicji funkcji w liniach 13-15, operator ' !' będzie zwracał wartość typu logicznego równą true, jeśli napis ten jest dłuższy niż pięcioznakowy.


P139: oneargop.cpp     Przeciążenie operatora jednoargumentowego

      1.  #include <iostream>
      2.  #include <string>
      3.  using namespace std;
      4.  
      5.  struct AClass {
      6.      string name;
      7.  
      8.      AClass(string name)
      9.          : name(name)
     10.      { }
     11.  };
     12.  
     13.  bool operator!(const AClass& c) {
     14.      return c.name.size() > 5;
     15.  }
     16.  
     17.  int main() {
     18.      AClass t[] = { AClass("Marlon"), AClass("Henry"),
     19.                     AClass("Dave"),   AClass("Horatio"),
     20.                     AClass("Sue"),    AClass("Alice")   };
     21.  
     22.      for (int i = 0; i < 6; ++i)
     23.          if ( !t[i] ) cout << t[i].name << endl;
     24.  }

W linii 23 drukujemy imiona odpowiadające poszczególnym obiektom z tablicy  t; zastosowanie operatora ' !' powoduje, że wydrukowane zostaną tylko te imiona, które liczą ponad pięć liter:

    Marlon
    Horatio

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