Zagadnienia

8. Operatory

Operatory

8.1. Wprowadzenie

8.1.1. Motywacja

  • Klasy definiowane przez użytkownika muszą być co najmniej tak samo dobrymi typami jak typy wbudowane. Oznacza to, że:

    • muszą dać się efektywnie zaimplementować,

    • muszą dać się wygodnie używać.

  • To drugie wymaga, by twórca klasy mógł definiować operatory.

  • Definiowanie operatorów wymaga szczególnej ostrożności.

Ostrzeżenie
Operatory definiujemy przede wszystkim po to, by móc czytelnie i wygodnie zapisywać programy. Jednak bardzo łatwo można nadużyć tego narzędzia (np. definiując operację + na macierzach jako odejmowanie macierzy). Dlatego projektując operatory (symbole z którymi są bardzo silnie związane pewne intuicyjne znaczenia), trzeba zachować szczególną rozwagę.

8.1.2. Opis

  • Większość operatorów języka C++ można przeciążać, tzn. definiować ich znaczenie w sposób odpowiedni dla własnych klas.

  • Przeciążanie operatora polega na zdefiniowaniu metody (prawie zawsze może to też być funkcja) o nazwie składającej się ze słowa operator i nazwy operatora (np. operator=).

  • Poniższe operatory można przeciążać:

    • +, -, *, /, \item\lstC<, >, >=, <=, ==, !=,@

    • =, +=, -=, *=, /=, \item\lstC++, –,!

    • , , ->*, ->,

    • (), [],

    • new, delete.

  • Dla poniższych operatorów można przeciążać zarówno ich postać jedno- jak i dwuargumentową:

    • +, -, *, &.

  • Tych operatorów nie można przeciążać:

    • ., .*, ::, ?:, sizeof (ani symboli preprocesora # i ##)

  • Operatory new i delete mają specyficzne znaczenie i nie odnoszą się do nich przedstawione tu reguły.

  • Tak zdefiniowane metody (funkcje) można wywoływać zarówno w notacji operatorowej:

    	a = b + c;
       
    

  • jak i funkcyjnej (tej postaci praktycznie się nie stosuje):

    	a.operator=(b.operator+(c));
       
    

8.1.3. Uwagi dotyczące definiowania operatorów

  • Definiując operator nie można zmieniać jego priorytetu, łączności ani liczby argumentów. Można natomiast dowolnie (p. nast. punkt) ustalać ich typy, jak również typ wyniku.

  • Jeśli definiujemy operator jako funkcję, to musi ona mieć co najmniej jeden argument będący klasą bądź referencją do klasy. Powód: chcemy, żeby 1+3 zawsze znaczyło 4, a nie np. -2.

  • Operatory =, (), [] i -> można deklarować jedynie jako (niestatyczne) metody.

  • Metody operatorów dziedziczą się (poza wyjątkiem kopiującego operatora przypisania, który jest bardziej złożonym przypadkiem).

  • Nie ma obowiązku zachowywania równoważności operatorów występujących w przypadku typów podstawowych (np. ++a nie musi być tym samym co a+=1).

  • Operator przeciążony nie może mieć argumentów domyślnych.

8.1.4. Operatory jednoargumentowe

  • Operator jednoargumentowy (przedrostkowy) @ można zadeklarować jako:

    • (niestatyczną) metodę składową bez argumentów:
      typ operator@()
      i wówczas @a jest interpretowane jako:
      a.operator@()

    • funkcję przyjmującą jeden argument:
      typ1 operator@(typ2)
      i wówczas @a jest interpretowane jako:
      operator@(a).

  • Jeśli zadeklarowano obie postacie, to do określenia z której z nich skorzystać używa się standardowego mechanizmu dopasowywania argumentów.

  • Operatorów ++ oraz można używać zarówno w postaci przedrostkowej jak i przyrostkowej. W celu rozróżnienia definicji przedrostkowego i przyrostkowego ++ () wprowadza się dla operatorów przyrostkowych dodatkowy parametr typu int (jego wartością w momencie wywołania będzie liczba 0).

class X{
 public:
  X operator++();		// przedrostkowy ++a
  X operator++(int);	// przyrostkowy a++
};

// Uwaga: ze względu na znaczenie tych operatorów
// pierwszy z nich raczej definiuje się jako:
//        X& operator++();

int main(){
 X a;
 ++a;  // to samo co: a.operator++();
 a++;  // to samo co: a.operator++(0);
}

8.1.5. Operatory dwuargumentowe

  • Operator dwuargumentowy @ można zadeklarować jako:

    • (niestatyczną) metodę składową z jednym argumentem:
      typ1 operator@(typ2)
      i wówczas a @ b jest interpretowane jako:
      a.operator@(b)

    • funkcję przyjmującą dwa argumenty:
      typ1 operator@(typ2, typ3)
      i wówczas a @ b jest interpretowane jako:
      operator@(a, b).

  • Jeśli zadeklarowano obie postacie, to do określenia z której z nich skorzystać używa się standardowego mechanizmu dopasowywania argumentów.

8.1.6. Kiedy definiować operator jako funkcję, a kiedy jako metodę?

  • Najlepiej jako metodę.

  • Nie zawsze można:

    • gdy operator dotyczy dwa klas,

    • gdy istotne jest równe traktowanie obu argumentów operatora.

Bardziej naturalne jest definiowanie operatorów jako metod, gdyż operator jest częścią definicji klasy, zatem także tekstowo powinien znajdować się w tej definicji. Są jednak sytuacje wymuszające odstępstwa od tej reguły:

  • Czasem operator pobiera argumenty będące obiektami dwu różnych klas, wówczas nie widać, w której z tych klas miałby być zdefiniowany (ze względów składniowych musiałby być zdefiniowany w klasie, z której pochodzi pierwszy argument). Co więcej czasami definiując taki operator mamy możliwość modyfikowania tylko jednej z tych klas, i może to akurat być klasa drugiego argumentu operatora (np. operator<<).

  • Czasami zamiast definiować wszystkie możliwe kombinacje typów argumentów operatora, definiujemy tylko jedną jego postać i odpowiednie konwersje.

Oto przykład:

  class Zespolona{
   //
   public:
    Zespolona(double);	// Konstruktor ale i konwersja
    Zespolona operator+(const Zespolona&);
  };
 

Przy przedstawionych deklaracjach można napisać:

  Zespolona z1, z2;
  z1 = z2 + 1;	// Niejawne użycie konwersji
 

ale nie można napisać:

  z1 = 1 + z2;
 

co jest bardzo nienaturalne. Gdybyśmy zdefiniowali operator + jako funkcję, nie było by tego problemu.

8.1.7. Kopiujący operator przypisania

  • Kopiujący operator przypisania jest czymś innym niż konstruktor kopiujący!

  • O ile nie zostanie zdefiniowany przez użytkownika, to będzie zdefiniowany przez kompilator, jako przypisanie składowa po składowej (więc nie musi to być przypisywanie bajt po bajcie). Język C++ nie definiuje kolejności tych przypisań.

  • Zwykle typ wyniku definiuje się jako X&, gdzie X jest nazwą klasy, dla której definiujemy operator=.

  • Uwaga na przypisania x = x, dla nich operator= też musi działać poprawnie!

  • Jeśli uważamy, że dla definiowanej klasy operator= nie ma sensu, to nie wystarczy go nie definiować (bo zostanie wygenerowany automatycznie). Musimy zabronić jego stosowania. Można to zrobić na dwa sposoby:

    • zdefiniować jego treść jako wypisanie komunikatu i przerwanie działają programu (kiepskie, bo zadziała dopiero w czasie wykonywania programu),

    • zdefiniować go (jako pusty) w części private (to jest dobre rozwiązanie, bo teraz już w czasie kompilacji otrzymamy komunikaty o próbie użycia tego operatora poza tą klasą).

    • Automatycznie definiowany kopiujący operator przypisania w podklasie wywołuje operator przypisania z nadklasy, jeśli samemu definiujemy ten operator, to musimy sami o to zadbać.

    • Można zdefiniować także inne (niż kopiujący) operatory przypisania.

8.1.8. Operator wywołania funkcji

Wywołanie:

   wyrażenie_proste( lista_wyrażeń )
 
uważa się za operator dwuargumentowy z wyrażeniem prostym jako pierwszym argumentem i, być może pustą, listą wyrażeń jako drugim. Zatem wywołanie:
	x(arg1, arg2, arg3)
 
interpretuje się jako:
	x.operator()(arg1, arg2, arg3)
 

8.1.9. Operator indeksowania

Wyrażenie:

  wyrażenie_proste [ wyrażenie ]
 
interpretuje się jako operator dwuargumentowy. Zatem wyrażenie:
  x[y]
 
interpretuje się jako:
  x.operator[](y)
 

8.1.10. Operator dostępu do składowej klasy

Wyrażenie:

	wyrażenie_proste -> wyrażenie_proste
 
uważa się za operator jednoargumentowy. Wyrażenie:
	x -> m
 
interpretuje się jako:
	(x.operator->())->m
 
Zatem operator->() musi dawać wskaźnik do klasy, obiekt klasy albo referencję do klasy. W dwu ostatnich przypadkach, ta klasa musi mieć zdefiniowany operator -> (w końcu musimy uzyskać coś co będzie wskaźnikiem).

8.1.11. Konwersje typów

  • W C++ możemy specyfikować konwersje typów na dwa sposoby:

    • do definiowanej klasy z innego typu (konstruktory),

    • z definiowanej klasy do innego typu (operatory konwersji).

  • Oba te rodzaje konwersji nazywa się konwersjami zdefiniowanymi przez użytkownika.

  • Są one używane niejawnie wraz z konwersjami standardowymi.

  • Konwersje zdefiniowane przez użytkownika stosuje się jedynie wtedy, gdy są jednoznaczne.

  • Przy liczeniu jednej wartości kompilator może użyć niejawnie co najwyżej jednej konwersji zdefiniowanej przez użytkownika.

Na przykład:

	class X { /* ... */  X(int); };
	class Y { /* ... */  Y(X); };
	Y a = 1;
	// Niepoprawne, bo Y(X(1)) zawiera już dwie konwersje użytkownika
 

Uwaga: ponieważ kompilator stosuje konwersje niejawnie trzeba być bardzo ostrożnym przy ich definiowaniu. Definiujmy je dopiero wtedy, gdy uznamy to za absolutnie konieczne i naturalne

8.1.12. Operatory konwersji

  • Są metodami o nazwie: operator nazwa_typu

  • nie deklarujemy typu wyniku, bo musi być dokładnie taki sam jak w nazwie operatora,

  • taka metoda musi instrukcją return przekazywać obiekt odpowiedniego typu.

8.1.13. Operatory new i delete

Jeśli zostaną zdefiniowane, będą używane przez kompilator w momencie wywoływania operacji new i delete do (odpowiednio) przydzielania i zwalniania pamięci. Opis zastosowania tych metod nie mieści się w ramach tego wykładu.

8.1.14. Operatory czytania i pisania

Operatory << i >> służą (m.in.) do wczytywania i wypisywania obiektów definiowanej klasy. Zostaną omówione wraz ze strumieniami.

Treść automatycznie generowana z plików źródłowych LaTeXa za pomocą oprogramowania wykorzystującego LaTeXML.

Projekt współfinansowany przez Unię Europejską w ramach Europejskiego Funduszu Społecznego.

Projekt współfinansowany przez Ministerstwo Nauki i Szkolnictwa Wyższego i przez Uniwersytet Warszawski.