Jak przeładowywać operatory w mojej klasie?

Ten post opisuje podstawy. Jeśli implementujesz DSL1 i/lub expression templates to poniższe rady nie są skierowane do Ciebie. Ponadto, powinny być Ci znane.

Kiedy i które operatory przeładowywać?

Dość przewrotnie odpowiem: kiedy zajdzie taka potrzeba. Podstawą jest zachowanie logiki kodu (Samochod + Samochod nie ma zbyt sensu, ale Currency + Currency już tak) oraz konwencji języka (lub frameworka), np. w przypadku operatora <<.

Wyjście i wejście ze strumienia, czyli operatory << i >>

Tutaj nie ma wielkiej filozofii. Dobrze by było osiągnąć pełną serializację, t.j. zagwarantować poprawność kodu z listingu poniżej, ale w praktyce jest to więcej roboty niż to warte dla większości klas. Szczególnie, że nawet biblioteka standardowa nie zachowuje się w ten sposób, np. dla klasy std::string.

foo bar = make_foo(), baz;
std::stringstream str;
str << bar;
str >> baz;
assert(bar == baz);

Wobec tego dla przykładowej klasy fraction:

struct fraction
{
    int num;
    int den;
};

operatory << i >> mogą wyglądać następująco:

std::ostream& operator<<(std::ostream& o, fraction const& f)
{
    return o << f.num << "/" << f.den;
}
 
std::istream& operator>>(std::istream& i, fraction& f)
{
    return i >> f.num >> f.den;
}

W przypadku klas z bardziej restrykcyjną kontrolą dostępu, sensowne może być zadeklarowanie tych operatorów jako friend:

class fraction
{
    int num, den;
    // ...
public:
    // ...
 
    friend std::ostream& operator<<(std::ostream& o, fraction const& f)
    {
        return o << f.num << "/" << f.den;
    }
 
    friend std::istream& operator>>(std::istream& i, fraction& f)
    {
        return i >> f.num >> f.den;
    }
};

Ku pełnej poprawności należałoby uwzględnić fakt, że std::ostream jest tak naprawdę konkretyzacją szablonu std::basic_ostream<char, std::char_traits<char>> (i analogicznie jest z std::istream, który jest konkretyzacją szablonu std::basic_istream). Deklaracje takich przeładowań przedstawione są poniżej. Jednak od siebie dodam, że nie zdarzyło mi się profesjonalnie korzystać z żadnych wide- lub innych niestandardowych streamów.

template <typename CharT, Traits = std::char_traits<CharT>>
std::basic_ostream<CharT, Traits>&
operator<<(std::basic_ostream<CharT, Traits>& o, fraction const& f);
 
template <typename CharT, Traits = std::char_traits<CharT>>
std::basic_istream<CharT, Traits>&
operator<<(std::basic_istream<CharT, Traits>& o, fraction& f);

Dwuargumentowe operatory arytmetyczne, logiczne i binarne (+, *, |, ||, etc.)

Każdy z tych operatorów należy implementować w oparciu o analogiczny operator operacji z przypisaniem. Pozwala to uniknąć powtarzania implementacji. Przykładowo, implementacja a @ b (gdzie @ oznacza dowolny z operatorów) powinna wyglądać następująco:

foo operator@(foo l, foo const& r)
{
    return l @= r;
}

Należy zauważyć, że jeden z parametrów jest kopią (operator dwuargumentowy i tak zwraca nową wartość) i to właśnie ta kopia jest zwracana z funkcji. Można zastosować (prawie2) analogiczny kod poniżej, tylko po co?

foo operator@(foo const& l, foo const& r)
{
    foo result{l};
    result @= r;
    return result;
}

Dlaczego nie implementować ich jako niestatycznych funkcji klasy?

Przykładowo:

struct fraction
{
    int num, den;
    fraction(int num, int den = 1): num{num}, den{den} {}
    fraction(fraction const&) = default;
    fraction& operator=(fraction const&) = default;
 
    fraction& operator*=(fraction const& o) {
        num *= o.num;
        den *= o.den;
        int div = gcd(num, den);
        num /= div;
        den /= div;
        return *this;
    }
 
    fraction operator*(fraction const& o) const {
        fraction result{*this};
        return result *= o;
    }
};

Powyższy kod działa poprawnie, ale ma jeden podstawowy problem: wymaga aby lewy operand mnożenia koniecznie był typu fraction. Inaczej mówiąc:

fraction{1,2} * fraction{2,3}; // ok, wynik fraction{1,3}
fraction{1,4} * 2; // ok, 2. argument za pomocą jednoargumentowego 
                   // konstruktora tworzy fraction{2,1}, wynik fraction{1,2}
2 * fraction{1,4}; // error, nie ma przeładowania operatora* dla int, fraction

Dwuargumentowy operator zdefiniowany poza klasą nie ma tego problemu:

fraction operator*(fraction l, fraction const& r) {
    return l *= r;
}
 
2 * fraction{1,3}; // ok, fraction{2,3}

Jeśli jednak nie chcemy zezwalać na niejawne konwersje (np. konstruktor jest explicit), to nie ma znaczenia jak będzie wyglądała deklaracja tych operatorów.

Operatory przypisania

Najlepiej wcale i pozostawić implementację domyślną. Ewentualnie, zgodnie z rule of 5 defaults3, można je zadeklarować jako domyślne (=default):

struct fraction
{
    int num, den;
    fraction(int num, int den = 1): num{num}, den{den} {}
    fraction(fraction const&) = default;
    fraction(fraction&&) = default;
    fraction& operator=(fraction const&) = default;
    fraction& operator=(fraction&&) = default;
};

Jeśli jednak z jakiegoś powodu niezbędna jest ich ręczna implementacja, powinny zwracać referencję do swojego obiektu po przypisaniu (return *this), a zadeklarowany zwracany typ powinien być referencją do typu, którego elementem jest ten operator. Czyli fraction& dla typu fraction.

Operatory z przypisaniem (@=)

Tutaj nie ma specjalnej filozofii, powinno być to zależne od logiki klasy. W typowym przypadku zwracana powinna być referencja do tej klasy. W wyżej wymienionej klasie fraction, mnożenie wygląda tak:

fraction& operator*=(fraction const& o) {
    num *= o.num;
    den *= o.den;
    int div = experimental::gcd(num, den);
    num /= div;
    den /= div;
    return *this;
}

Operatory jednoargumentowe (+4, 4, !, ~)

Ponownie nic specjalnie zaskakującego, na przykładzie w fraction:

struct fraction
{
    int num, den;
    fraction(int num, int den = 1): num{num}, den{den} {}
    fraction(fraction const&) = default;
    fraction& operator=(fraction const&) = default;
 
    fraction operator-() const {
        return fraction{-num, den};
    }
};

Inkrementacja i dekrementacja

Należy pamiętać, że preinkrementacja i predekrementacja modyfikują i zwracają ten sam obiekt, a postinkrementacja i postdekrementacja zwracają kopię poprzedniej wartości, więc (celowo obrazowo) dla klasy integer ich implementacja mogła by wyglądać tak:

struct integer
{
    int value;
 
    // pre
    integer& operator++() { value = value + 1; return *this; }
    integer& operator--() { value = value - 1; return *this; }
 
    // post
    integer operator++(int) { integer ret{*this}; value = value + 1; return ret; }
    integer operator--(int) { integer ret{*this}; value = value - 1; return ret; }
};

Operatory porównania

Powinny zwracać bool. Nie powinny modyfikować parametrów. Jeśli chcemy zezwolić na konwersję lewego parametru nie powinny być deklarowane wewnątrz klasy.

struct fraction
{
    int num, den;
    // ...
};
 
bool operator==(fraction const& l, fractooin const& r) {
    return std::tie(l.num, l.den) == std::tie(r.num, r.den);
}
 
bool operator<(fraction const& l, fraction const& r) {
    return double{l.num}/l.den < double{r.num}/r.den;
}
 
bool operator<=(fraction const& l, fraction const& r) {
    // bezpośrednia sztuczka z doublami może nie wyjść przez niedokładność
    // przybliżenia binarnego w liczbach zmiennoprzecinkowych
    return l == r || l < r;
}

Wywołanie funkcji

Jeśli jesteś nowicjuszem i chcesz zaimplementować operator(), to poważnie zastanów się nad użyciem lambdy. operator() to tak naprawdę zwykła funkcja (lub szablon), wszystko zależy co ma robić. Nie ma tutaj żadnych ogólnych rad.

Dostęp do elementu ([])

Przydatne głównie podczas implementacji kontenerów lub klas opakowujących zasoby. Czyli raczej nie robota dla newbie. Należy pamiętać/rozważyć implementacje dla mutable i const this:

struct my_vector
{
    int* data;
 
    int& operator[](size_t index) { return data[index]; }
    int const& operator[](size_t index) const { return data[index]; }
};

Taka duplikacja kodu nie jest zbyt pożądana (szczególnie jeśli implementacja jest dłuższa), dlatego pojawiają się głosy[citation needed], aby implementować wersję mutowalną poprzez wywołanie wersji const i const_castować rezultat:

struct my_vector
{
    int* data;
 
    int const& operator[](size_t index) const { return data[index]; }
 
    int& operator[](size_t index) {
        return const_cast<int&>(static_cast<my_vector const&>(*this)[index]);
    }
};

Osobiście nie jestem co do tego przekonany. No i dodatkowo w tym krótkim przypadku wygląda to kuriozalnie.

Dereferencja (*)

Jak wyżej, głównie dla klas opakowujących zasoby, czyli raczej nie dla świeżaków. Również tutaj wersje const i mutable this powinny być rozważone:

struct my_optional_int
{
    bool is_set;
    int value;
 
    int& operator*() { assert(is_set); return value; }
    int const& operator*() const { assert(is_set); return value; }
};

Nie będę powtarzał argumentu za wywołaniem funkcji const z non-const. Tak samo nie jestem przekonany, tak samo wygląda to kuriozalnie dla mniejszych funkcji.

Pobranie adresu (&)

Pomyłka komisji standaryzacyjnej. Nie przeładowuj tego operatora. Przez to, że jego przeładowanie jest legalne, naprawdę generyczny kod musi teraz używać std::addressof, ponieważ & może wywoływać jakieś dziwne akcje.

operator,

Tak jak wyżej, pomyłka komisji standaryzacyjnej. Jedyne w miarę sensowne zastosowania widziałem w Boost.Assign i geordim.

1domain-specific language. Na przykład obsługa operatorów w Boost.Spirit.
2Przed C++17 NRVO i RVO nie były zdefiniowane przez standard, więc kompilatory mogły trochę różnie to implementować. Od C++17 chyba już tak nie jest. Dodatkowo czas życia argumentów funkcji jest inaczej określony. W praktyce nie ma to znaczenia.
3Rozszerzenie rule of zero zaproponowane przez Scotta Meyersa.
4mowa tutaj o operatorach widzianych w wyrażeniu -2 (minus dwa) a nie 5-2 (pięć odjąć dwa)

2 thoughts on “Jak przeładowywać operatory w mojej klasie?

  1. Czy przypadkiem const_cast w celu ograniczenia duplikacji to nie jest trick stosowany i zalecany przez Meyersa właśnie? To tak a’propos „citation needed”

Leave a Reply

Your email address will not be published.