23.3 Operátortúlterhelés és konverziós operátor

Az operátortúlterhelés (operator overloading) C++-specifikus eszköz: C-ben nincs rá lehetőség. A lényeg nem az, hogy „szép szintaxist” adjunk, hanem hogy a típus viselkedése kiszámítható maradjon. Egy jól megtervezett operátor ugyanazt a jelentést hordozza, mint a beépített megfelelője: a + b ne módosítsa a-t, a += b igen; ++it adja vissza az új értéket, it++ a régit; az if (x) csak akkor működjön, ha a típusnak van értelmes „igaz/hamis” állapota. Az erőforrás-életciklust kezelő típusokat különösen óvatosan kell kezelni: az operátor ne rejtsen el allokációt, lezárást vagy más, a felhasználó számára meglepő mellékhatást.

23.3.1 Az operátor mint szerződés: definíció és feltételek

Operátortúlterhelésről akkor beszélünk, amikor egy operator@ nevű függvényt deklarálunk, ahol @ a túlterhelhető operátor jele. Pontos feltételek:

A következő táblázat a leggyakoribb, konvencionális választásokat foglalja össze.

Operátor Ajánlott forma Visszatérés Megjegyzés
+=, -=, *= tag T& módosítja a bal oldalt, *this-t ad vissza
+, -, * nem tag vagy hidden friend T új értéket ad, ne referenciát
[] tag T& / const T& általában nem ellenőriz boundsot
() tag tetszőleges callable objektum
++ prefix tag T& először növel, az új értéket adja
++ postfix tag T a régi értéket adja vissza
= tag T& csak tag lehet
swap nem tag + tag swap void lehetőleg noexcept
konverzió tag nincs visszatérési típus implicit csak indokolt esetben

23.3.2 Tag- vagy nem tag operátor?

A döntés vezérelve: ha az operátor a bal oldali operandust módosítja, és a bal oldal mindig a saját típusunk, akkor tagfüggvény a természetes választás. Ilyen a +=, a -=, a prefix ++, az [] és a (). Ha viszont szimmetrikus műveletről van szó, ahol mindkét oldal szerepelhet eltérő típusból, vagy a bal oldali operandus nem a mi típusunk, akkor nem tag függvény kell. Például std::cout << x esetén a bal oldali operandus std::ostream, ezért az operator<< nem lehet tagja a saját típusunknak.

A nem tag operátor lehetővé teszi, hogy a bal oldali operanduson is történjen felhasználói konverzió. Ez hasznos lehet, de veszélyes is: ha túl sok implicit konverzió van, az overload resolution nehezen követhetővé válik. A „hidden friend” olyan friend függvény, amelyet a class belsejében definiálunk. Nem tagja a classnak, de hozzáfér a privát tagokhoz, és csak argumentumfüggő kereséssel (ADL) található meg. Ez csökkenti a globális névtér szennyezését, és szimmetrikus operátoroknál gyakran jó választás.

A friend önmagában még nem tesz egy függvényt taggá; csak hozzáférési jogot ad. A hidden friend előnye, hogy a definíció a class mellett van, mégsem vesz részt a szokásos, nem ADL-alapú névkeresésben.

23.3.3 Aritmetika, összetett értékadás és swap

A bevett minta: a módosító műveletet (+=) tagfüggvényként írjuk meg, a bináris aritmetikát (+) pedig arra építjük. A + bal oldali operandusát érték szerint vesszük át, így a függvényen belül módosíthatjuk, majd értékként visszaadjuk. Ez elkerüli a kézi duplikálást, és jól viselkedik akkor is, ha a művelet kivételt dob: az eredeti objektum változatlan marad.

#include <iostream>
#include <utility>

class Money {
public:
    using cents_t = long long;

    constexpr Money() noexcept : cents_(0) {}
    constexpr explicit Money(cents_t cents) noexcept : cents_(cents) {}

    constexpr cents_t cents() const noexcept { return cents_; }

    constexpr Money& operator+=(const Money& rhs) noexcept {
        cents_ += rhs.cents_;
        return *this;
    }

    constexpr Money& operator-=(const Money& rhs) noexcept {
        cents_ -= rhs.cents_;
        return *this;
    }

    Money& operator++() noexcept {          // prefix
        ++cents_;
        return *this;
    }

    Money operator++(int) noexcept {        // postfix
        Money old = *this;
        ++*this;
        return old;
    }

    void swap(Money& other) noexcept {
        std::swap(cents_, other.cents_);
    }

private:
    cents_t cents_;
};

constexpr Money operator+(Money lhs, const Money& rhs) noexcept {
    lhs += rhs;
    return lhs;
}

constexpr Money operator-(Money lhs, const Money& rhs) noexcept {
    lhs -= rhs;
    return lhs;
}

inline void swap(Money& a, Money& b) noexcept {
    a.swap(b);
}

int main() {
    Money a(1000);
    Money b(250);
    Money c = a + b;       // 1250
    c += Money(50);        // 1300
    Money d = c++;         // d = 1300, c = 1301
    ++c;                   // c = 1302
    std::cout << d.cents() << ' ' << c.cents() << '\n';
}

Fordítási példa:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 money_demo.cpp -o money_demo

A példa szándékosan nem használ implicit double konverziót. A pénzösszeg belső reprezentációja egész cent, így nincs lebegőpontos kerekítési hiba. A swap tagfüggvény és a nem tag swap együtt biztosítja, hogy az ADL-alapú swap(a, b) a saját, hatékony változatunkat találja meg. A swap csak akkor legyen noexcept, ha a benne végzett műveletek valóban nem dobnak; hamis noexcept esetén a program std::terminate-hez juthat.

23.3.4 Indexelés és hívás

Az operator[] tipikusan tagfüggvény, és referenciát ad vissza. Mutable objektumon T&, const objektumon const T& a helyes visszatérési típus. A bounds-ellenőrzés nem automatikus: a std::vector::operator[] például nem ellenőriz, a .at() igen. Saját konténer esetén dokumentálni kell az előfeltételt: i < size(), és a megsértése nem definiált viselkedés, ha a mögöttes tároló így viselkedik.

#include <cstddef>
#include <vector>

class IntBuffer {
public:
    explicit IntBuffer(std::size_t n) : data_(n) {}

    int& operator[](std::size_t i) noexcept { return data_[i]; }
    const int& operator[](std::size_t i) const noexcept { return data_[i]; }
    std::size_t size() const noexcept { return data_.size(); }

private:
    std::vector<int> data_;
};

A operator() callable objektumot készít. Állapotot tárolhat, és a const minősítés itt is számít: ha a hívás nem módosítja az objektumot, legyen const.

struct Scale {
    double factor;
    double operator()(double x) const noexcept { return factor * x; }
};

Használat: Scale s{2.0}; double y = s(3.0);. A call operátort könnyű túlhasználni; ha a műveletnek nem természetes a „függvényhívás” jelentése, gyakran beszédesebb egy névvel ellátott tagfüggvény.

23.3.5 Növelés és csökkentés

A prefix ++ visszatérése T&: a művelet után az objektum új állapotára mutató referenciát ad. A postfix ++ szignatúrája T operator++(int): a dummy int különbözteti meg a prefix változattól, és a visszatérési típus érték, nem referencia. A postfixnek a módosítás előtti értéket kell visszaadnia, ezért jellemzően egy másolatot készít, majd megnöveli az eredetit. Emiatt a postfix drágább lehet; generikus kódban ezért preferált a ++it.

A postfix T&-et visszaadó változata tipikus hiba: ha lokális old objektum referenciáját adja vissza, a hívó dangling referenciát kap. Ha az eredeti objektumot adja vissza, akkor nem a régi értéket képviseli, hanem az újat, ami szintén meglepő.

23.3.6 Értékadás, összetett értékadás és kivételbiztonság

A copy assignment és a move assignment hagyományos szignatúrája:

T& operator=(const T& other);
T& operator=(T&& other) noexcept;

Mindkettő tagfüggvény, és *this-t ad vissza. A láncolhatóság (a = b = c) és a generikus algoritmusok miatt a void visszatérés kerülendő. Önmagára értékadásnál (a = a) az implementációnak helyesen kell működnie; copy-and-swap esetén ez általában automatikusan teljesül. A move assignment után a forrás „moved-from” állapotba kerül: érvényes, de nem specifikált állapotú; csak destruálás vagy újabb értékadás biztonságos vele kapcsolatban. Erről bővebben a Move constructor, moved-from állapot és std::move rész szól.

A compound assignment (+=, -=) tagfüggvényként, T&-et visszaadva a legtermészetesebb. A bináris + erre épülhet, ahogy a Money példában. A swap nem tag függvénye ugyanabban a névtérben legyen, mint a típus, hogy az ADL megtalálja; a std::swap specializálása helyett ez a javasolt minta. A kivételbiztonsági garanciákról és a copy-and-swapról a noexcept, swap és container-reallocation rész ad részletesebb képet.

23.3.7 Konverziós operátorok és explicit bool

A konverziós operátor tagfüggvény, nincs visszatérési típusa, és a neve operator T. Például:

struct FdView {
    int fd = -1;

    explicit operator bool() const noexcept {
        return fd >= 0;
    }
};

Az explicit operator bool a „safe bool” modern megfelelője. Kontextuális konverzióként működik if, while, !, &&, ||, ?: feltételében és static_assert-ben, de nem enged meg olyan implicit átalakítást, mint bool b = v; vagy int n = v;. Ez pontosan az, amit egy „érvényes/érvénytelen” állapotú típusnál szeretnénk. A régi operator void* trükk helyett C++11 óta ezt kell használni.

Általános szabály: implicit konverziós operátort csak akkor adjunk, ha a konverzió tényleg veszteségmentes és a típus természetes része. Ellenkező esetben explicit legyen, vagy adjunk nevesített hozzáférőt (to_int(), celsius()). Az implicit operator int vagy operator double könnyen váratlan overload resolutionhöz és több lépéses konverziós lánchoz vezet. Egy erőforrás-tulajdonos típusnál az implicit bool különösen veszélyes, mert nem mondja meg, hogy „érvényes”-e vagy „nem nulla”-e a jelentés.

23.3.8 Precedencia, rövidzárlat és az operátorok korlátai

Az operátortúlterhelés nem változtatja meg a precedenciát és az asszociativitást. Ha a + b * c szerepel, a * továbbra is erősebb, akkor is, ha saját operator+-t és operator*-ot definiálunk. Nem lehet „jobbról balra asszociatív” operator+-t csinálni, és nem lehet megváltoztatni az operandusok számát sem.

A && és || túlterhelése különösen veszélyes, de két különböző okból, amelyeket érdemes szétválasztani.

// operator_order.cpp — C++17/C++20: sorrend megmarad, rövidzár elvész
#include <cstdio>

struct Flag { bool value; };

bool operator&&(Flag a, Flag b) { return a.value && b.value; }   // kerülendő
Flag operator,(Flag, Flag b) { return b; }                       // kerülendő

int counter = 0;

Flag left()  { ++counter; std::puts("left"); return Flag{false}; }
Flag right() { std::printf("right: counter=%d\n", counter); return Flag{true}; }

int main() {
    counter = 0;
    bool r = left() && right();   // operátorszintaxis: left előbb, right is fut
    std::printf("&& -> %d\n", r);

    counter = 0;
    Flag f = (left(), right());   // ugyanígy a vesszőnél
    std::printf(", -> %d\n", f.value);

    // Explicit függvényhívás-szintaxisnál az argumentumok sorrendje
    // nem meghatározott: operator&&(left(), right())
}

Buildjegyzőkönyv (lab-gcc profil, -std=c++20). A kimenet: left, right: counter=1, && -> 0, majd ugyanez a vesszőre , -> 1. A jobb operandus látja a bal mellékhatását, és a hamis bal oldal ellenére is lefut. (A GCC -std=c++14 módban is ezt a sorrendet adta, de ott ez nem garantált.)

Logikai rövidzárlatot igénylő kódban ezért ne terheljük túl a &&, || és , operátorokat.

Ha mégis szükség van logikai műveletre, használjunk nevesített függvényt (both(), either()), vagy hagyjuk a beépített operátort érvényesülni explicit operator bool segítségével.

23.3.9 Tipikus hibák és javításuk

23.3.10 Összefoglaló

Az operátortúlterhelés akkor jó, ha a típus viselkedése a beépített operátorok természetes analógiáját követi. A módosító műveletek tagfüggvényként T&-et adnak vissza; a bináris aritmetika értéket ad; az indexelés referenciát; a postfix ++ a régi értéket. A swap nem tag függvényként, ADL-lel találhatóan, lehetőleg noexcept legyen. A konverziós operátorokat explicit-té kell tenni, különösen a bool-t. Az operátorok nem változtatják meg a precedenciát, és a túlterhelt &&/|| nem őrzi meg a rövidzárlatot. Az osztálytervezésben ezért az operátor a publikus szerződés része, nem trükk.

23.3.11 Feladatok

23.3.11.1 1. feladat — Tervezési döntés

Adott egy Temperature értéktípus, amely Celsius-fokot tárol double-ként. Tervezd meg a += és + operátorokat, és indokold, melyik legyen tag, melyik nem tag. A hőmérséklet kiolvasására ne implicit double konverziót használj, hanem nevesített hozzáférőt; ha mégis konverziót adsz, indokold, miért explicit.

23.3.11.2 2. feladat — Kódolás

Implementálj egy Counter osztályt, amelynek van prefix ++ és postfix ++ operátora. A postfix adja vissza a módosítás előtti értéket. Írj rövid main-t, amely ellenőrzi a viselkedést, és add meg a visszatérési típusokat. Magyarázd el, miért nem adhat a postfix Counter&-et vissza.

23.3.11.3 3. feladat — Diagnosztika

Az alábbi kód több operátorral kapcsolatos hibát tartalmaz. Találj legalább négyet, indokold őket, és írd meg a javított változatot.

struct Box {
    int v;
    operator bool() const { return v != 0; }
    Box& operator+(const Box& other) { v += other.v; return *this; }
    Box& operator++(int) { Box old = *this; ++v; return old; }
};

bool operator&&(Box a, Box b) { return a.v && b.v; }

23.3.12 Megoldások

23.3.12.1 1. megoldás

A += módosítja a bal oldali operandust, ezért tagfüggvény, és Temperature&-et ad vissza. A + szimmetrikus, új értéket állít elő, ezért nem tag függvény vagy hidden friend, és Temperature-t ad vissza értékként. A nevesített celsius() hozzáférő egyértelműbb, mint az implicit double konverzió; ha mégis kell konverzió, az explicit operator double() const legyen, hogy csak static_cast<double>(t) alakban lehessen használni.

class Temperature {
public:
    explicit Temperature(double celsius) noexcept : celsius_(celsius) {}

    double celsius() const noexcept { return celsius_; }

    Temperature& operator+=(const Temperature& rhs) noexcept {
        celsius_ += rhs.celsius_;
        return *this;
    }

private:
    double celsius_;
};

inline Temperature operator+(Temperature lhs, const Temperature& rhs) noexcept {
    lhs += rhs;
    return lhs;
}

Ellenőrzési feltétel: Temperature t{20.0}; t += Temperature{2.0}; auto u = t + Temperature{3.0}; után t.celsius() == 22.0 és u.celsius() == 25.0. A double d = u; ne forduljon le, ha nincs implicit konverzió; static_cast<double>(u) csak akkor működjön, ha az explicit konverziót külön hozzáadtuk.

23.3.12.2 2. megoldás

class Counter {
public:
    constexpr Counter() noexcept : value_(0) {}
    constexpr explicit Counter(int value) noexcept : value_(value) {}

    constexpr int value() const noexcept { return value_; }

    Counter& operator++() noexcept {          // prefix
        ++value_;
        return *this;
    }

    Counter operator++(int) noexcept {        // postfix
        Counter old = *this;
        ++*this;
        return old;
    }

private:
    int value_;
};

A postfix azért ad vissza értéket, mert a módosítás előtti állapotot kell reprezentálnia. Ha Counter&-et adna vissza, vagy a lokális old-ra mutató dangling referenciát adna, vagy az eredetit adná vissza, ami már az új értéket képviseli. Ellenőrzési feltétel: Counter c; Counter a = c++; Counter b = ++c; után a postfix eredménye a régi, a prefix eredménye az új érték; a c állapota mindkét művelet után megfelelően nő.

23.3.12.3 3. megoldás

A hibák:

  1. Az operator bool implicit, ezért váratlan bool, int és egyéb konverziókhoz vezethet. Legyen explicit operator bool() const noexcept.
  2. Az operator+ módosítja a bal oldali operandust, és referenciát ad vissza. A bináris + ne módosítson, és értéket adjon vissza; a módosítást += végezze.
  3. A postfix operator++ Box&-et ad vissza lokális old-ra, ami dangling referencia. Helyesen Box operator++(int).
  4. A túlterhelt operator&& elveszíti a rövidzárlatot. Törölni kell; az explicit operator bool mellett a beépített && használható.
  5. A Box v tagja inicializálatlan lehet; adjunk neki alapértéket.
struct Box {
    int v = 0;

    explicit operator bool() const noexcept { return v != 0; }

    Box& operator+=(const Box& other) noexcept {
        v += other.v;
        return *this;
    }

    Box& operator++() noexcept {
        ++v;
        return *this;
    }

    Box operator++(int) noexcept {
        Box old = *this;
        ++*this;
        return old;
    }
};

inline Box operator+(Box lhs, const Box& rhs) noexcept {
    lhs += rhs;
    return lhs;
}

Ellenőrzési feltétel: Box a{1}, b{2}; Box c = a + b; után a.v változatlan, c.v == 3; auto d = c++; esetén d.v a régi, c.v az új érték; if (a) és a && b a beépített rövidzárlatos logikát használja; bool x = a; nem fordul le. A javított kódban a + nem módosít mellékhatásként, a postfix nem ad vissza dangling referenciát, és a logikai operátorok viselkedése nem változik meg.