17 listopada 2013

[C++11] Przeciążenia operatorów

Zbiór notatek które zebrałem studiując tematykę operatorów po lekturze "C++ Primer". Najczęściej przeciążanym (overload) operatorem, jest chyba operator nawiasowy () który stosuje się w celu tworzenia funktorów. C++11 sprawi, że w jego miejsce częściej będzie pojawiać się lambda. W wielu przypadkach nadal, warto korzystać z obiektów funkcyjnych dostarczonych wraz z biblioteką standardową (pozytywnie wypowiadał się na ten temat także Stephan T. Lavavej na GoingNative 2013 - Don’t Help the Compiler). Nie tylko potrafią być krótsze od lambd, ale potrafią też poprawnie pracować ze wskaźnikami.

Kiedy tego nie robić?

Przeciążenie (overload) operatora ma tylko sens, jeżeli programista nie będzie zaskoczony jego działaniem.
Standard nie gwarantuje w jakiej kolejności będą wołane operandy. Np. dla f() + g(), nie wiemy, która z funkcji zostanie zawołana jako pierwsza. Mamy za to pewność że priorytety operatorów będą zachowane (czyli np. mnożenie będzie przed dodawaniem) [C++Prime, s. 138].
Nie należy przeciążać operatora kropki (.), pobrania adresu (&), logicznego AND (&&) oraz logicznego OR (||).
#include <iostream>

using namespace std;

struct Num {
    Num(int v) : value(v) { }
    Num() : Num(0) { }

    int value;
};

Num operator+(const Num& first, const Num& second) {
    return Num(first.value + second.value);
}

Num operator*(const Num& first, const Num& second) {
    return Num(first.value * second.value);
}

int main()
{
    Num a(2);
    Num b(2);
    Num c(2);

    cout << (a + b).value << endl;
    cout << (a * b).value << endl;
    cout << (a * b + c).value << endl;
    cout << (a + b * c).value << endl;
}
Wynik:
4
4
6
6

Gdzie tworzyć deklaracje?

Kiedy już decydujemy się na przeciążenie operatora, stajemy przed dylematem, czy stworzyć go jako element składowy klasy, czy jako funkcję poza klasą. Pomocne mogą być poniższe wskazówki:
  • operatora przypisania (=), nawisu kwadratowego ([]), zawołania (()) oraz strzałki (->) muszą być zdefiniowane jako składniki klasy
  • operatory mieszane z przypisaniem (+=, -=, *=, /= itp.) generalnie powinny być składowymi klasy, ale w przeciwieństwie do samego operatora przypisania (=) nie jest to wymagane
  • operatory które zmieniają stan obiektu, albo są z nim ściśle powiązane np. inkrementacja (++), dekrementacja (--), dereferencja (*) zazwyczaj powinny być składowymi klasy
  • operatory symetryczne (takie które mogą konwertować którykolwiek z operandów) tj. arytmetyczne (+, -), równości (==), relacji (<, >, <=, <=) i bitowe (|, &, ^, <<, >>), zazwyczaj powinny być definiowane jako funkcje poza klasą

Szczegóły dla konkretnych operatorów

Operatory I/O - wejścia, wyjścia - (<<, >>), powinny być funkcjami poza klasą i powinny drukować zawartość z bardzo minimalnym formatowaniem. Jako, że czasami muszą mieć dostęp do niepublicznych danych, powinny być zadeklarowane jako przyjaciele (friend) klasy. Operator wejściowy powinien radzić sobie z sytuacją, gdy "wejście" zawiedzie, operator wyjściowy generalnie się tym nie przejmuje.

Dla operatora nawiasu kwadratowego ([]) warto zrobić wersję const oraz non-const

Dla operatorów inkrementacji (++) i dekrementacji (--) istnieją dwie wersje: prefiksowa i postfiksowa. Wersja postfiksowa dla rozróżnienia bierze jako argument dodatkowy nieużywany parametr typu int, kompilator wstawia tam zero.
#include <iostream>
using namespace std;

struct Counter {
    double d;
    Counter() : d(0.12) { }
    Counter operator++() {
        d += 1.0;
        cout << "prefix:  ++obj" << endl;
        return *this;
    }

    Counter operator++(int) {
        Counter tmp = *this;
        d += 1.0;
        cout << "postfix: obj++" << endl;
        return tmp;
    }
};

int main() {
    Counter c;
    c++;
    ++c;
}
Wynik:
postfix: obj++
prefix:  ++obj

Konwersje typów

Konwersje z jednego typu na drugi mogą być bardzo mylące. Czasami lepiej stworzyć odpowiednią metodą, która lepiej poinformuje nas o intencji danej konwersji. Operatory konwersji powinny być składowymi klasy, nie powinny określić typu zwracanego i posiadać pustą listą parametrów. Funkcja zazwyczaj powinna być const-owa.

We wcześniejszej wersji języka konwersja na typ bool była problematyczna, ponieważ bool jest typem arytmetycznym. Obiekt klasy która pozwala na konwersję na bool, mógł zostać użyty we wszystkich miejscach, gdzie wymagany jest typ arytmetyczny. Gdyby std::cin dało się konwertować na typ bool, możliwa była by operacja "std::cin << 34" - w rezultacie doszło by do przesunięcia bitowego (bool zostałby jeszcze skonwertowany na int).

Nowy standard wprowadza konwersje explicit. Powinna się ona ograniczyć raczej tylko do bool i zazwyczaj z intencją sprawdzania wyniku w jakimś warunku.
Jeżeli konwersja jest explicit, możemy jej używać ale tylko z rzutowaniem. Wyjątkiem jest niejawne (implicit) korzystanie z konwersji dla:
  • warunków w if, while oraz do-while
  • warunków w for
  • operatorów dla logicznego NOT (!), OR (||) oraz AND (&&)
  • warunków dla ?:
#include <iostream>
using namespace std;

struct SmallInt {
    double val;
    SmallInt(double v) : val(v) { }

    explicit operator int() {
        return val;
    }

    explicit operator bool() {
        return val > 3.0;
    }
};

int main() {
    SmallInt si(3.14);
//  cout << si + 1 << endl; // ERROR wymagana jest niejawna konwersja,
                            // ale operator rzutowania na int jest explicit
    cout << static_cast<int>(si) + 1 << endl;

    if (si)
        cout << "Bigger than PI" << endl;

    return 0;
}
Wynik:
4
Bigger than PI

Brak komentarzy:

Prześlij komentarz