23.5 Osztály- és operátortervezési labor

Ez a labor a fejezet elveit fordítja át ellenőrizhető gyakorlatokká. Két típust építünk fel, mindkettőnél ugyanazzal a három lépéses módszerrel: előbb szándékosan rossz változatot adunk, azt interfészreview alá vetjük, majd a javítást fordítási és futásidejű feltételekkel igazoljuk. A két típus két tipikus hibatípust fed le: az fd_handle egy erőforrás-burkot (ownership), a byte_count egy erős domain értéket (konverziók és operátorok).

A laborkörnyezet: ISO C++20, explicit -std=c++20, rögzített GCC- vagy Clang-kiadás, x86-64 System V, Linux. Az fd_handle a POSIX <unistd.h> ::close/::read/::socket függvényeit használja; más platformon ezt a megfelelő primitívre kell cserélni. Ajánlott fordítás:

g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -O2 \
    -fsanitize=address,undefined lab.cpp -o lab

A példák viselkedését a leírás a C++20 szabályai alapján adja meg; a tényleges lefuttatás a tanuló laborjának feladata, a jegyzet nem tartalmaz mérési eredményt.

23.5.1 Mit vizsgál az interfészreview

Az alábbi definíciók adják a labor ellenőrzőlistáját.

Erős domain típus (strong domain type): olyan osztály, amelynek reprezentációja és megengedett műveletei egy fogalmat írnak le, nem pedig egy általános gépi típust (például std::size_t), és amelynek nincsenek olyan implicit konverziói, amelyek fogalomhatárt lépnek át.

Kölcsönzött accessor (borrowed accessor): olyan tagfüggvény, amely a belső erőforrás nem tulajdonló handle-jét adja vissza. A visszaadott értéket a hívó nem zárhatja le, nem szabadíthatja fel, és nem tárolhatja a burkoló élettartamán túl.

Baleseti konverzió (accidental conversion): implicit átalakítás, amely egy értéket más jelentésű típusba visz anélkül, hogy a forráskódban bármi jelezné a szándékot. Ellentéte a szándékos konverzió, amely nevesített művelet: bc.value(), static_cast<int>(x), explicit konstruktorhívás.

A review négy kérdése minden publikus tagra: (1) átad-e tulajdonjogot, és ha igen, kiderül-e a szignatúrából; (2) enged-e implicit konverziót, és az melyik irányba visz; (3) noexcept-e ott, ahol a standard tároló erre épít; (4) konzisztens-e az egyenlőség, a rendezés és a hash.

23.5.2 Az fd_handle ellenpéldája és javítása

A rossz változat mindössze annyit tesz, hogy a destruktorban lezárja az fd-t. A hiba nem a destruktorban van, hanem abban, amit a fordító helyette legenerál.

// BAD — tanítási ellenpélda, nem termelési kód.
#include <unistd.h>

class fd_handle_bad {
public:
    explicit fd_handle_bad(int fd) : fd_(fd) {}
    ~fd_handle_bad() { if (fd_ >= 0) ::close(fd_); }
    int get() const { return fd_; }          // bárki lezárhatja
    // nincs másolás/mozgatás deklarálva:
    // a fordító mindkettőt hallgatólagosan legenerálja
private:
    int fd_ = -1;
};

void demo_bad(int raw_fd) {
    fd_handle_bad h1{raw_fd};
    fd_handle_bad h2 = h1;   // hallgatólagos másolás: ugyanaz az fd
}                            // h2 és h1 destruktora is lezárja ugyanazt

Három külön hiba:

  1. Dupla lezárás. A hallgatólagos másolás két „tulajdonost” hoz létre ugyanarra az fd-re. A destruktorok sorrendje miatt kétszer hívódik a ::close. A második hívás legalább EBADF, de több szál esetén a szám újra kiosztható, és egy másik szál élő fájlját zárja le — ez már nem EBADF, hanem csendes adatvesztés.
  2. A get() nem kölcsönzés, hanem nyitott ajtó. A hívó ::close(h1.get())-et írhat, majd a destruktor újra lezár.
  3. Mozgatás nélkül a konténer másol. std::vector<fd_handle_bad> feltöltése másolással jár, mert a fordító a másoló konstruktort választja, ha nincs noexcept mozgató.

A javított változat szándékosan mindent kikényszerít, amit a rossz engedett:

#include <unistd.h>
#include <utility>

class fd_handle {
public:
    fd_handle() noexcept = default;
    explicit fd_handle(int fd) noexcept : fd_(fd) {}
    ~fd_handle() { reset(); }

    fd_handle(const fd_handle&)            = delete;   // egy owner
    fd_handle& operator=(const fd_handle&) = delete;

    fd_handle(fd_handle&& o) noexcept : fd_(o.release()) {}

    fd_handle& operator=(fd_handle&& o) noexcept {
        if (this != &o) reset(o.release());            // önmozgatás ellen véd
        return *this;
    }

    int  get() const noexcept { return fd_; }          // kölcsönzés
    explicit operator bool() const noexcept { return fd_ >= 0; }

    int  release() noexcept {                          // tulajdonjog átadása
        int tmp = fd_; fd_ = -1; return tmp;
    }
    void reset(int fd = -1) noexcept {                 // hibaágak egy helyen
        if (fd_ >= 0) ::close(fd_);
        fd_ = fd;
    }
    void swap(fd_handle& o) noexcept { std::swap(fd_, o.fd_); }

private:
    int fd_ = -1;
};

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

A szerződés táblázatban:

Művelet Szerződés Indok
fd_handle(int) explicit, átveszi a tulajdonjogot a nyers fd ne maradjon a hívónál
~fd_handle() lezár, ha nem üres RAII
másoló műveletek = delete két owner → dupla lezárás
mozgató műveletek noexcept, forrás üres lesz konténer-átrakás másolás nélkül
get() kölcsönzés, nem zárható le a POSIX API nyers int-et vár
release() tulajdonjog a hívóhoz kerül nem RAII határ, pl. dup2
reset(int) lezárja a régit, átveszi az újat egyetlen hibakezelési pont
swap() noexcept std::swap és konténerbarát

A fordító által igazolható feltételek:

#include <type_traits>
static_assert(!std::is_copy_constructible_v<fd_handle>);
static_assert(!std::is_copy_assignable_v<fd_handle>);
static_assert( std::is_nothrow_move_constructible_v<fd_handle>);
static_assert( std::is_nothrow_move_assignable_v<fd_handle>);
static_assert( std::is_nothrow_destructible_v<fd_handle>);
static_assert(!std::is_convertible_v<fd_handle, int>);   // nincs baleseti konverzió

Futásidejű feltétel (a <sys/socket.h>-t is include-olva):

fd_handle a{::socket(AF_UNIX, SOCK_STREAM, 0)};
fd_handle b{std::move(a)};
assert(b.get() >= 0);
assert(a.get() == -1);   // a forrás üres: a destruktor nem zárja le újra

23.5.3 A byte_count: baleseti konverzió és overload-ambiguitás

A rossz verzió két irányban is nyit: nyers számból lesz byte_count, és byte_count-ból nyers szám.

// BAD — tanítási ellenpélda.
#include <cstddef>

struct byte_count_bad {
    byte_count_bad(std::size_t v) : value(v) {}                    // implicit be
    operator std::size_t() const { return value; }                 // implicit ki
    operator std::ptrdiff_t() const {                                // implicit ki
        return static_cast<std::ptrdiff_t>(value);
    }
    std::size_t value;
};

Két következmény, mindkettő a labor egy-egy tesztje.

Baleseti konverzió. Mivel a konstruktor implicit, egy nyers int átcsúszik egy másik jelentésű függvénybe:

void set_retry_count(byte_count_bad);
int n = 5;
set_retry_count(n);   // int -> std::size_t -> byte_count_bad: lefordul

Az implicit konverziós sorozat szabálya ezt megengedi (standard + egy felhasználói + standard lépés), a forráskódban mégsem látszik, hogy darabszám-fogalomba nyers egész lépett be.

Overload-ambiguitás. A két konverziós operátor két egyenrangú jelöltet teremt:

void schedule(std::size_t);
void schedule(std::ptrdiff_t);
schedule(byte_count_bad{1024});   // mindkettő felhasználói konverzió: ambiguus

A javított típus csak a szándékos alakot engedi:

#include <compare>
#include <cstddef>
#include <functional>

class byte_count {
public:
    explicit constexpr byte_count(std::size_t v = 0) noexcept : v_(v) {}
    constexpr std::size_t value() const noexcept { return v_; }   // szándékos

    friend constexpr byte_count operator+(byte_count a, byte_count b) noexcept {
        return byte_count{a.v_ + b.v_};
    }
    friend bool operator==(byte_count a, byte_count b) = default;
    friend auto operator<=>(byte_count a, byte_count b) = default;

private:
    std::size_t v_;
};

A value() szándékosan nem konverziós operátor: a hívás helye a forráskódban látszik, így a review elkapja, ha valaki nyers méretet kever a domain típussal. Ugyanezt kényszeríti ki a fordító:

static_assert(!std::is_convertible_v<int, byte_count>);            // nincs be
static_assert(!std::is_convertible_v<byte_count, std::size_t>);    // nincs ki
static_assert( std::is_constructible_v<byte_count, std::size_t>);  // explicit igen

A gyakorlatban ez azt jelenti, hogy a schedule(1024) a std::size_t túlterhelést választja (nincs rejtett byte_count), a schedule(byte_count{1024}) pedig pontos illeszkedés — nincs ambiguitás, mert csak egy jelölt életképes.

23.5.4 Egyenlőség, rendezés és hash konzisztencia

Az Egyenlőség, spaceship és rendezési kategóriák (23.4) alfejezetben rögzített szerződés itt is kötelező: két egyenlő értéknek ekvivalensnek kell lennie a rendezésben, és azonos hash-t kell adnia, különben a std::unordered_set/unordered_map elveszíti a beszúrt elemet.

template <> struct std::hash<byte_count> {
    std::size_t operator()(const byte_count& b) const noexcept {
        return std::hash<std::size_t>{}(b.value());
    }
};

A = default comparisons a v_ tagra bontják a műveletet, ezért az ==, a <=> és a fenti hash automatikusan összhangban marad, amíg a hash ugyanazt az egyetlen tagot használja. Ellenőrzés:

#include <cassert>
#include <unordered_set>

byte_count a{1024}, b{1024};
assert(a == b);
assert(!(a < b) && !(b < a));                                   // ekvivalens
assert(std::hash<byte_count>{}(a) == std::hash<byte_count>{}(b));
std::unordered_set<byte_count> s{a};
assert(s.contains(b));

Ha valaha második tag kerül az osztályba (például mértékegység), a = default comparisons azt is bevonják, de a hash specializációt kézzel kell frissíteni — ezt a review-nak külön ki kell kérnie.

23.5.5 Copy/move/destruction nyomkövetés

Az ownership-szerződést a legkönnyebbinstrumentált típussal látni. A nyomkövető minden életeseményt kiír; nem a formázás a lényeg, hanem hogy a műveletek sorrendje megfigyelhető legyen.

#include <cstdio>

struct tracked {
    int id;
    explicit tracked(int i) : id(i) { std::printf("ctor %d\n", id); }
    tracked(const tracked& o) : id(o.id) { std::printf("copy %d\n", id); }
    tracked(tracked&& o) noexcept : id(o.id) { o.id = -1; std::printf("move %d\n", id); }
    tracked& operator=(const tracked& o) { id = o.id; std::printf("copy= %d\n", id); return *this; }
    tracked& operator=(tracked&& o) noexcept { id = o.id; o.id = -1; std::printf("move= %d\n", id); return *this; }
    ~tracked() { std::printf("dtor %d\n", id); }
};

Két megfigyelendő pont: a mozgatott objektum Forrása -1 azonosítójú lesz (a forrás „üres”), és hogy a std::vector<tracked> újrafoglalásakor a mozgatás történik, nem másolás — utóbbi feltétele a noexcept, swap és container-reallocation (22.5) alfejezetben tárgyalt noexcept mozgató konstruktor. Ugyanezt a sémát fd_handle-re alkalmazva a printf helyett a reset/release hívások számlálhatók.

23.5.6 Negatív fordítási példa — külön jelölve

Az alábbi blokk szándékosan nem fordul le; nem javítandó, hanem a hibaüzenet olvasását gyakorolja.

// NEGATÍV FORDÍTÁSI PÉLDA — nem fordul le, ez a cél.
#include <cstddef>

struct byte_count_bad {
    byte_count_bad(std::size_t v) : value(v) {}
    operator std::size_t() const { return value; }
    operator std::ptrdiff_t() const { return static_cast<std::ptrdiff_t>(value); }
    std::size_t value;
};

void schedule(std::size_t);
void schedule(std::ptrdiff_t);

void demo() { schedule(byte_count_bad{1024}); }

A várt diagnosztika jellege: a schedule(byte_count_bad) hívás ambiguus, mert mindkét jelölt azonos rangú felhasználói konverziót igényel. A javítás nem az overload-halmaz átírása, hanem a típus szűkítése: implicit konverziós operátor nélkül a hívás egyértelműen nem fordul le, ami itt a helyes eredmény — a programozó kénytelen kiírni a szándékát.

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

23.5.8 Feladatok

1. feladat (könnyű). Add hozzá az fd_handle-hez egy borrowed() nevű const noexcept tagot, amely ugyanazt adja vissza, mint a get(), de a neve is jelzi a szerződést. Igazold static_assert-tel, hogy a fd_handle továbbra sem konvertálható int-té, és hogy mozgatása noexcept.

2. feladat (közepes). Egészítsd ki a byte_count-ot operator+=-vel, amely csak byte_count-ot fogad el. Mutasd meg egy külön, jelölt negatív példában, hogy bc += 1 nem fordul le, majd egy működő példában, hogy bc += byte_count{1} igen. Indokold, miért nem elég a value()-t használni a belső művelethez.

3. feladat (diagnosztikai). Készíts egy checked_byte_count változatot, amely operator+-ban std::size_t-túlcsordulást ellenőriz (a.value() > SIZE_MAX - b.value()), és ilyenkor std::overflow_error-t dob. Írj hozzá három esetet: normál összeadás, pontosan SIZE_MAX, és túlcsordulás. Egészítsd ki egy review-mondattal arról, hogy a dobó művelet milyen exception guarantee-t ad.

23.5.9 Megoldások

1. megoldás. A borrowed() a get() átnevezett változata; a szerződés nem változik, csak a hívási hely lesz önmagyarázó.

class fd_handle {
public:
    // ...
    int get() const noexcept { return fd_; }
    int borrowed() const noexcept { return fd_; }   // ugyanaz, beszélő névvel
};
static_assert(!std::is_convertible_v<fd_handle, int>);
static_assert(std::is_nothrow_move_constructible_v<fd_handle>);

Ellenőrzési feltétel: a két accessor bitre azonos értéket ad, és a static_assert-ek változtatás nélkül átmennek. Megjegyzés: ha get()-et megtartod, a két név egyike felesleges — termelési kódban egyiket töröld, és a hívási helyeket igazítsd.

2. megoldás. Az operator+= szándékosan tagfüggvény, hogy a bal oldal típusa adott legyen:

class byte_count {
public:
    // ...
    byte_count& operator+=(byte_count o) noexcept { v_ += o.v_; return *this; }
};

Negatív példa (nem fordul le, mert nincs implicit int → byte_count):

// NEGATÍV — a byte_count explicit konstruktora miatt nem fordul le.
byte_count bc{1024};
bc += 1;              // hiba: nincs implicit konverzió

Működő példa:

byte_count bc{1024};
bc += byte_count{1};  // helyes: a szándék látszik
assert(bc == byte_count{1025});

Indoklás: ha a belső műveletet v_ += o.value()-ként írnánk, az csak a reprezentáción belül azonos; a különbség abban van, hogy az operator+= a publikus felületen kényszeríti ki a típust, nem a belső kódban. A value() használata a hívónál megengedné a nyers szám keverését, amit épp ki akarunk zárni.

3. megoldás. Az ellenőrzött összeadás:

#include <cstddef>
#include <limits>
#include <stdexcept>

class checked_byte_count {
public:
    explicit constexpr checked_byte_count(std::size_t v = 0) noexcept : v_(v) {}
    constexpr std::size_t value() const noexcept { return v_; }

    friend checked_byte_count operator+(checked_byte_count a, checked_byte_count b) {
        if (a.v_ > std::numeric_limits<std::size_t>::max() - b.v_)
            throw std::overflow_error{"byte_count overflow"};
        return checked_byte_count{a.v_ + b.v_};
    }
    friend bool operator==(checked_byte_count, checked_byte_count) = default;
    friend auto operator<=>(checked_byte_count, checked_byte_count) = default;

private:
    std::size_t v_;
};

Tesztesetek:

assert((checked_byte_count{1} + checked_byte_count{2}) == checked_byte_count{3});
constexpr auto m = std::numeric_limits<std::size_t>::max();
assert((checked_byte_count{m - 1} + checked_byte_count{1}).value() == m);   // pontosan MAX
bool threw = false;
try { (void)(checked_byte_count{m} + checked_byte_count{1}); }
catch (const std::overflow_error&) { threw = true; }
assert(threw);

Review-mondat: az operator+ nem ad noexcept-ot, mert hiba esetén dob; az erős exception guarantee teljesül, mivel a művelet vagy új értéket ad vissza, vagy dob, a bemeneteit nem módosítja. A byte_count egyszerű összeadása ehelyett noexcept és unsigned wrappolással túlcsordul — a két változat szándékosan más szerződést képvisel, és a választást a hívó kontextusa indokolja.

23.5.10 Összefoglaló

A labor két típussal mutatta meg ugyanazt a mintát. Az fd_handle-nél a = delete másoló műveletek, a noexcept mozgatás és a nevesített release() teszik kényszeríthetővé az egy-owner szerződést; a get()/borrowed() kölcsönzés marad, mert a POSIX API nyers int-et vár. A byte_count-nál az explicit konstruktor és a konverziós operátor elhagyása szünteti meg a baleseti konverziót és az overload-ambiguitást; a value() a szándékos kilépőpont. Mindkettőnél a = default comparisons és egyetlen tagot használó hash adja az ==/<=>/hash konzisztenciát. Az interfészreview tehát nem esztétikai kérdés: minden implicit konverzió és minden hiányzó noexcept egy potenciális hibát enged be a publikus felületen, amelyet a fordító static_assert-tel, a futtatás pedig nyomkövető típussal tesz láthatóvá.

Forrás és ellenőrzési korlát. Ehhez az egységhez nem állt rendelkezésre feldolgozott forrásjegyzet, ezért a szöveg a C++20 nyelvi szabályaira és a bemutatott kód saját szerkezetére támaszkodik; konkrét szabványparagrafust vagy mérési eredményt nem állít. A tanuló laborjában a C++20 munkatervezet (N4950), a hivatalos GCC/Clang dokumentáció és a C++ Core Guidelines megfelelő szakaszai ellenőrizhetik a részleteket; a fejezettervben megadott URL-ek kiindulópontok, nem bizonyítékok.