23.2 Értéktípus, kompozíció és immutable interfész

A fejezet kiindulópontja egy kellemetlen felismerés: a legtöbb DevOps-környezetben keletkező hiba nem attól lesz hiba, hogy rossz az algoritmus, hanem attól, hogy egy változó jelentése nem hordozza magában az érvényességi feltételét. Egy unsigned port érték lehet 0, lehet 70 000, és a fordító mindkettőt ugyanolyan szívesen elfogadja. Az értéktípus (value type) fegyelme ezt a rést zárja be: a típus nem „egy doboz a bájtok körül”, hanem állítás arról, hogy a benne lévő állapot minden megfigyelhető pillanatban kielégíti a szerződését. Ebben az alfejezetben portot, időtartamot, bájtszámot és erőforrás-azonosítót modellezünk, és azt nézzük meg, hogyan csökkenti a kompozíció a rejtett élettartam-függőségeket.

23.2.1 Értéktípus mint érvényességi szerződés

Értéktípusnak nevezünk egy C++ osztályt, ha (1) másolása értelmes és megfigyelhetően egyenértékű állapotot állít elő, (2) nincs önálló identitása, tehát két azonos állapotú példány felcserélhető, és (3) minden publikus művelet után az objektum kielégíti az osztály invariánsát: azt a predikátumot, amely az összes adattagra és azok kapcsolatára egyszerre igaz.

Az invariáns-alapú tervezés három feltétele:

  1. Teljes konstrukció. Minden konstruktor vagy érvényes állapotot állít elő, vagy megszakad; „félig inicializált, majd javítgatott” objektum nem létezhet.
  2. Zárt hozzáférés. Az adattagok private-ok (erről lásd public/private/protected és invariánsvédelem (23.1)), és nincs olyan publikus művelet, amely érvénytelen állapotba vihetné az objektumot.
  3. Őrzött átmenet. Minden módosító művelet vagy megtartja az invariánst, vagy visszautasítja a műveletet — jellemzően std::optional, hibakód vagy kivétel formájában.

A második pont az, amiért a „validálj a konstruktorban, aztán adj mindenkinek settert” minta nem működik: a setter visszaadja a nyilvános, ellenőrizetlen írás jogát.

23.2.1.1 Erős típus és validált konstrukció

A strong typedef (erős típusdefiníció) olyan wrapper, amely megkülönbözteti a reprezentációt a jelentéstől. A using Port = unsigned; nem az: ez csak alias, ezért egy unsigned-ból és egy Port-ból azonos overload-halmaz keletkezik, és az Overload, konverzió és explicit interfész (19.5) szerinti konverziós szabályok sem segítenek. Igazi erős típus csak külön osztály, amelynek nincs implicit konverziója a reprezentáció felé és felől.

A validált konstrukció kanonikus alakja: privát konstruktor + statikus gyártó függvény, amely std::optional-t ad vissza.

class Port {
public:
    static constexpr std::optional<Port> from_number(unsigned n) noexcept;
private:
    constexpr explicit Port(unsigned n) noexcept;
    unsigned number_;
};

Miért jó ez a három elem együtt? A privát konstruktor miatt kívülről nem lehet „nyers” értéket becsomagolni; a static gyártó az egyetlen belépési pont, tehát ott és csak ott kell az ellenőrzést helyesen megírni; az optional visszatérés pedig kikényszeríti, hogy a hívó foglalkozzon a hibával. A noexcept indokolt, mert a gyártó nem allokál és nem dob.

23.2.2 Explicit konverzió és mértékegység-biztos paraméter

Egy domain típusnak általában kell valamilyen kifelé mutató út: naplózáshoz, rendszerhíváshoz, szerializáláshoz. Ezt explicit konverziós operátorral adjuk meg, hogy a fordító ne konvertálhasson véletlenül ott, ahol a programozó nem akarta:

explicit constexpr operator unsigned() const noexcept { return number_; }

Az explicit kulcsszó nélkül a f(port) hívás csendben unsigned paraméterré alakulna, és egy portméretű argumentum helyére bármi bekerülhetne. Ugyanez a logika védi a mértékegység-biztos paramétert: a void sleep(int) típusú interfész nem mondja meg, hogy másodperc vagy milliszekundum; a std::chrono::milliseconds viszont igen, és a különböző duration típusok közötti átváltás csak duration_cast-tal, azaz szándékosan lehetséges. A saját Timeout osztály így nem „egy int”, hanem egy int, amelynek van mértékegysége, érvényességi tartománya és neve.

Ugyanez a bájtszámnál: a ByteCount és az elemszám két külön típus, akkor is, ha mindkettő std::uint64_t-en ül. Ez a fajta megkülönböztetés nem holmi esztétika: a memcpy(dst, src, n) harmadik paramétere bájtban értendő, míg a std::vector::reserve(n) elemszámban; a kétféle szám összekeverése tipikus, nehezen felderíthető hibát okoz.

23.2.3 Kompozíció a rejtett lifetime-függőségek ellen

A kompozíció azt jelenti, hogy az összetett típus tagként tartalmazza a részeit, és viseli azok érvényességi garanciáit. Az Endpoint nem „egy string meg egy int”, hanem egy nem üres host és egy érvényes Port párja; ha a kettőből bármelyik érvénytelen, az Endpoint létre sem jön. Ezzel a hiba a keletkezés helyére kerül, nem pedig a sokadik hívási szintre.

Az öröklés ezzel szemben is-a viszony: csak akkor helyes, ha a leszármazott valóban az ős speciális esete, és a bázis interfésze erre fel van készítve (virtuális destruktor, megfelelően dokumentált szerződés). Egy Port bázisból leszármaztatott PrivilegedPort például értelmetlen, mert a Port nem erre a bővítésre készült, és a bázisra mutató hivatkozáson át a leszármazott levágódna (slicing). Ugyanezért nem származtatunk std::string-ből vagy std::vector-ból: ezek nem bázisnak készültek, nem virtuális destruktorral. Az újrafelhasználást a kompozíció és a sablon adja; az öröklést nem tekintjük alapértelmezett eszköznek, és a tervezési minták katalógusát sem tárgyaljuk itt.

A kompozíció élettartamot is egyszerűsít: a tag objektum élettartama a tartalmazó objektuméba ágyazódik, így nincs külön new/delete, nincs „ki zárt be kit” kérdés. A Connection például tartalmazhat Endpoint-ot, Timeout-ot és egy tulajdonló handle-t; a megsemmisítési sorrend fordított, és a tagok sosem maradhatnak félig lebontott állapotban.

23.2.4 const interfész és logikai const

A const interfész azt jelenti, hogy a megfigyelő műveletek const tagfüggvények, tehát egy const Endpoint&-en is hívhatók. Ez nem formalitás: a const-korrektség az, ami a hívónak megmondja, hogy egy referencián át „csak olvasni” fog, és ami lehetővé teszi a const objektumok átadását anélkül, hogy lemásolnánk őket.

A logikai const ennél finomabb fogalom. A C++ alapértelmezésben bitenkénti (bitwise) const-ot ellenőriz: ha egy tag adattag megváltozik, a függvény nem lehet const. Néha viszont egy gyorsítótár (cache) frissítése megfigyelhetően nem változtatja meg az objektum értékét; ilyenkor a tag mutable lehet. Ez azonban nem ingyen van: a mutable tag írása a const tagfüggvényből adatversenyt okoz, ha ugyanazt az objektumot több szálon, szinkronizáció nélkül olvassák (erről lásd Data race, happens-before és publikalás (15.2)). A mutable tehát nem kényelmi eszköz, hanem állítás arról, hogy a gyorsítótár konzisztens marad; ha ezt nem tudjuk bizonyítani, ne használjuk.

23.2.5 Trivialitás és standard-layout: tulajdonság, nem cél

A trivially copyable típus bitenként másolható, memcpy-vel is, mert a másoló konstruktor, az értékadás és a destruktor triviális. A standard-layout típus tagjai azonos hozzáférésűek, nincs virtuális függvénye és virtuális bázisa, ezért a memóriabeli elrendezése kiszámíthatóbb, és C ABI-határon biztonságosabban használható. Ezek tulajdonságok, amelyeket std::is_trivially_copyable_v<T> és std::is_standard_layout_v<T> segítségével lehet ellenőrizni, nem pedig célok, amelyeket mindenáron el kell érni.

Fontos árnyalat: egy validált konstruktorú típus is lehet trivially copyable, mert a nem másoló konstruktor nem rontja el a triviális másolhatóságot. A Port tehát egyszerre hordoz invariánst és másolható bájtonként — de a Port-ot továbbra sem szabad nyersen, ellenőrizetlen unsigned-ből előállítani. A bitmásolás megengedett, a jelentés nélküli újraértelmezés nem.

23.2.6 Kis érték és megosztott mutable entitás

Kétféle modell létezik, és a kettőt nem szabad összekeverni:

A kettő keverése a leggyakoribb tervezési hiba: egy Connection értékként másolva két függetlennek látszó objektum ugyanarra a file descriptorra mutat, és a destruktorok versenyt futnak a close()-ért (erről bővebben Owner, view és erőforrásszerződés C++ API-ban (21.5)). Az értéktípusok ezért általában kis, önálló adatok; a nagy, megosztott állapot tulajdonosa egyértelmű legyen.

23.2.7 Kidolgozott példa

Az alábbi példa egy fordítási egységben mutatja a portot, a bájtszámot, az időtúllépést és az ezekből komponált endpointot. A kód ISO C++20-at használ (<=> és std::optional), a buildparancs explicit nyelvi módot ad meg. A kódot ebben a fejezetben nem futtattam; a parancs a saját laborodhoz tartozik, és a várt viselkedés leírás, nem mért eredmény.

// demo.cpp - ISO C++20, Linux, GCC vagy Clang
#include <chrono>
#include <compare>
#include <cstdint>
#include <iostream>
#include <limits>
#include <optional>
#include <string>
#include <type_traits>
#include <utility>

namespace devops {

class Port {
public:
    static constexpr unsigned min_value = 1;
    static constexpr unsigned max_value = 65535;

    static constexpr std::optional<Port> from_number(unsigned n) noexcept {
        if (n < min_value || n > max_value) {
            return std::nullopt;
        }
        return Port{n};
    }

    constexpr unsigned number() const noexcept { return number_; }

    // Szandekos konverzio: implicit nincs.
    explicit constexpr operator unsigned() const noexcept { return number_; }

    constexpr auto operator<=>(const Port&) const noexcept = default;

private:
    constexpr explicit Port(unsigned n) noexcept : number_{n} {}
    unsigned number_;
};

static_assert(!Port::from_number(0).has_value());
static_assert(Port::from_number(8080).has_value());
static_assert(Port::from_number(8080)->number() == 8080);

class ByteCount {
public:
    constexpr ByteCount() noexcept = default;   // 0 bajt ervenyes

    static constexpr ByteCount from_bytes(std::uint64_t v) noexcept {
        return ByteCount{v};
    }

    constexpr std::uint64_t bytes() const noexcept { return bytes_; }

    constexpr std::optional<ByteCount> checked_add(ByteCount other) const noexcept {
        constexpr auto max = std::numeric_limits<std::uint64_t>::max();
        if (other.bytes_ > max - bytes_) {
            return std::nullopt;
        }
        return ByteCount{bytes_ + other.bytes_};
    }

    constexpr auto operator<=>(const ByteCount&) const noexcept = default;

private:
    constexpr explicit ByteCount(std::uint64_t v) noexcept : bytes_{v} {}
    std::uint64_t bytes_{};
};

class Timeout {
public:
    using rep = std::chrono::milliseconds::rep;

    static constexpr std::optional<Timeout> from_milliseconds(rep ms) noexcept {
        if (ms <= 0) {
            return std::nullopt;
        }
        return Timeout{std::chrono::milliseconds{ms}};
    }

    constexpr std::chrono::milliseconds value() const noexcept { return ms_; }

private:
    constexpr explicit Timeout(std::chrono::milliseconds ms) noexcept : ms_{ms} {}
    std::chrono::milliseconds ms_{};
};

class Endpoint {
public:
    static std::optional<Endpoint> make(std::string host, Port port) {
        if (host.empty()) {
            return std::nullopt;
        }
        return Endpoint{std::move(host), port};
    }

    const std::string& host() const noexcept { return host_; }
    Port port() const noexcept { return port_; }

    std::string to_string() const {
        return host_ + ':' + std::to_string(port_.number());
    }

private:
    Endpoint(std::string host, Port port)
        : host_{std::move(host)}, port_{port} {}

    std::string host_;
    Port port_;
};

}  // namespace devops

int main() {
    using devops::ByteCount;
    using devops::Endpoint;
    using devops::Port;
    using devops::Timeout;

    const auto port = Port::from_number(9100);
    if (!port) {
        std::cerr << "ervenytelen port\n";
        return 1;
    }

    const auto endpoint = Endpoint::make("metrics.internal", *port);
    if (!endpoint) {
        std::cerr << "ervenytelen endpoint\n";
        return 1;
    }

    const auto timeout = Timeout::from_milliseconds(2500);
    if (!timeout) {
        std::cerr << "ervenytelen timeout\n";
        return 1;
    }

    const auto sum = ByteCount::from_bytes(1024).checked_add(ByteCount::from_bytes(2048));
    if (!sum) {
        std::cerr << "tulcsordulas\n";
        return 1;
    }

    std::cout << endpoint->to_string() << "  " << sum->bytes() << " B  "
              << timeout->value().count() << " ms\n";

    static_assert(std::is_trivially_copyable_v<Port>);
    static_assert(std::is_standard_layout_v<Port>);
    static_assert(!std::is_default_constructible_v<Port>);
    return 0;
}
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o demo demo.cpp && ./demo
# vagy: clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o demo demo.cpp

A példa várható kimenete (nem mért eredmény): metrics.internal:9100 3072 B 2500 ms. A Timeout miért nem int? Mert a value() típusa std::chrono::milliseconds, így a .count() hívás láthatóvá teszi, hogy itt egy számról van szó, és nem keveredik össze egy másodpercben értendő paraméterrel.

Szándékos compile-fail illusztráció — ez a töredék nem fordul le, és a diagnosztikai üzenetek fordítónként eltérnek:

Port p = 8080;      // nincs nyilvanos, implicit konverzio
unsigned u = p;     // az explicit operator nem hasznalhato implicit helyen
Port d;             // nincs alap-ertekezett allapot: nincs "0 port"

Az első két sor az explicit konverzió védelmét mutatja, a harmadik pedig azt, hogy az invariáns miatt szándékosan nincs alapértelmezett konstruktor: a „0 port” nem létező fogalom.

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

A leggyakoribb minta az alias: using Port = unsigned;. Ettől minden port és minden darabszám ugyanaz a típus lesz, és az overload-feloldás nem tud különbséget tenni. A javítás valódi osztály, nem alias.

A második: a validált gyártó mellett ott marad egy publikus set(int) vagy number(int) mutátor, amely megkerüli az ellenőrzést. Ha kell módosítás, adjunk std::optional<T> with_number(unsigned) const stílusú, új értéket előállító műveletet, vagy checked_add-hoz hasonló, hibát visszaadó változatot.

A harmadik: hiányzó explicit. Az implicit konverzió nem okoz azonnali hibát, csak később, amikor egy port paraméter helyére egy véletlen unsigned kerül. Rendszeres ellenőrzés: a gyártó és a konverziós operátor mindig explicit.

A negyedik: mutable gyorsítótár szinkronizáció nélkül. A const tagfüggvény írása adatverseny; ha a cache megosztott objektumon van, akkor atomikus művelet vagy zár szükséges, különben a „logikai const” állítás hamis.

Az ötödik: értéktípusból öröklés, hogy „újrahasznosítsunk”. A leszármazott levágódik, a bázis destruktora nem virtuális, és a bázis invariánsa nem védi a leszármazott új tagjait. A kompozíció itt mindig olcsóbb.

23.2.9 Összefoglaló

Az értéktípus a szerződés hordozója: az invariáns a konstrukcióban keletkezik, a privát adattagok és a gyártó függvények zárják be, a const interfész pedig megfigyelhetővé teszi, hogy ki mit módosíthat. A strong typedef megkülönbözteti a jelentést a reprezentációtól, az explicit konverzió megakadályozza a csendes típuskeveredést, a mértékegységes típusok (például std::chrono::milliseconds) pedig a paraméterek jelentését rögzítik. A kompozíció a tagok élettartamát a tartalmazó objektuméhez köti, ezért kevesebb rejtett függőséget hagy. A trivialitás és a standard-layout hasznos tulajdonság, de nem cél; a kis értékeket a megosztott, identitással rendelkező entitásoktól külön kell tartani, mert utóbbiak tulajdonlása és lezárása külön szerződés.

23.2.10 Feladatok

  1. Magyarázó feladat. Egy kollégád ezt javasolja: struct Port { unsigned number; };, mert „a validációt úgyis a hívó végzi”. Sorolj fel három konkrét DevOps-hibamódot, amelyet a privát adattag + validált gyártó kizár, és fogalmazd meg, miért nem elegendő a dokumentáció.

  2. Kódolási feladat. Készíts ResourceId erős típust std::uint64_t reprezentációval, ahol a 0 érvénytelen. Legyen from_raw, value, explicit operator std::uint64_t, defaultolt <=>, valamint checked_increment(), amely std::optional<ResourceId>-t ad és túlcsorduláskor üres értéket. Egészítsd ki egy static_assert-tel, amely igazolja, hogy a 0 nem hozható létre.

  3. Diagnosztikai feladat. Találd meg az alábbi típus összes tervezési hibáját, és javítsd:

class Timeout {
public:
    Timeout(int ms) : ms_{ms} {}
    int ms() { return ms_; }
    void extend(int extra) { ms_ += extra; }
private:
    int ms_;
};
  1. Tervezési feladat. Modellezd kompozícióval egy Connection típust, amely egy Endpoint-ot, egy Timeout-ot és egy nem tulajdonló int fd nézetet tartalmaz. Ne írj hálózati kódot; csak a tagokat, a konstrukciós szerződést és a const interfészt indokold.

23.2.11 Megoldások

1. megoldás. A struct Port { unsigned number; }; három tipikus hibamódja: (a) egy konfigurációból beolvasott 0 vagy 70000 érték tovább terjed, és csak a connect() hívásnál, érthetetlen EINVAL/ECONNREFUSED formájában bukkan fel; (b) két különböző „szám” (port és darabszám) összekeveredik egy függvényhívásban, mert a típusuk azonos; (c) egy naplózó vagy szerializáló kód véletlenül felülírja a mezőt, és az objektum érvénytelenné válik anélkül, hogy bármely művelet jelezné. A dokumentáció azért nem elég, mert nem kényszerítő: a fordító nem olvassa. Az ellenőrzési feltétel: a javasolt típusra a Port p; és a Port p = 8080; nem fordul le, a Port::from_number(0) pedig üres optional-t ad.

2. megoldás.

class ResourceId {
public:
    static constexpr std::optional<ResourceId> from_raw(std::uint64_t v) noexcept {
        if (v == 0) {
            return std::nullopt;
        }
        return ResourceId{v};
    }

    constexpr std::uint64_t value() const noexcept { return v_; }
    explicit constexpr operator std::uint64_t() const noexcept { return v_; }

    constexpr std::optional<ResourceId> checked_increment() const noexcept {
        constexpr auto max = std::numeric_limits<std::uint64_t>::max();
        if (v_ == max) {
            return std::nullopt;
        }
        return ResourceId{v_ + 1};
    }

    constexpr auto operator<=>(const ResourceId&) const noexcept = default;

private:
    constexpr explicit ResourceId(std::uint64_t v) noexcept : v_{v} {}
    std::uint64_t v_;
};

static_assert(!ResourceId::from_raw(0).has_value());
static_assert(ResourceId::from_raw(1).has_value());

Az indoklás: a from_raw az egyetlen belépési pont, ezért a „nem nulla” invariáns egy helyen ellenőrzött; a checked_increment megtartja az invariánst és a túlcsordulást hibaként jelzi, nem csendes wraparounddal; a <=> defaultolása csak akkor helyes, mert egyetlen tag és nincs kézi invariáns a rendezésben (a sorrend a reprezentáció sorrendje). Ellenőrzési feltétel: ResourceId::from_raw(0) üres, ResourceId::from_raw(5)->value() == 5, és a std::uint64_t u = *ResourceId::from_raw(5); nem fordul le az explicit operátor miatt.

3. megoldás. A hibák rendre: (a) a konstruktor nem explicit, ezért Timeout t = 5; és f(5) is lefordul, vagyis bármely int automatikusan időtúllépéssé válik; (b) nincs invariáns, a 0 és a negatív érték is „érvényes”, holott a nulla időtúllépés jelentése definiálatlan; (c) a ms() nem const, ezért const Timeout&-en nem hívható, és a csak olvasó kód kénytelen másolatot venni; (d) az extend publikus mutátor, amely negatívba viheti az értéket, tehát megsérti az invariánst; (e) az int nem hordoz mértékegységet, a hívó nem tudja, hogy ms-e vagy s. Javított változat: privát konstruktor, from_milliseconds gyártó std::optional-lal, const lekérdező, extend helyett std::optional<Timeout> extended(rep) const, és std::chrono::milliseconds belső reprezentáció. Ellenőrzési feltétel: Timeout::from_milliseconds(0) üres, a const Timeout&-en hívható a lekérdező, és nincs olyan publikus művelet, amely érvénytelen állapotot állítana elő.

4. megoldás.

class Connection {
public:
    Connection(Endpoint endpoint, Timeout timeout, int fd) noexcept
        : endpoint_{std::move(endpoint)}, timeout_{timeout}, fd_{fd} {}

    const Endpoint& endpoint() const noexcept { return endpoint_; }
    Timeout timeout() const noexcept { return timeout_; }
    int fd() const noexcept { return fd_; }   // nem tulajdonlo nezet

private:
    Endpoint endpoint_;   // ertek: sajat invariáns, sajat elettartam
    Timeout timeout_;     // ertek: sajat mertekegyseg
    int fd_;              // nezet: a tulajdonos masutt zarja le
};

Az indoklás: az Endpoint és a Timeout értékként beágyazott tag, ezért élettartamuk a Connection-éhez kötött, nincs külön felszabadítás és nincs „félkonstruált” állapot; a konstruktor feltétele, hogy a bemenetek már érvényesek legyenek, amit a gyártó függvények garantálnak. Az fd_ szándékosan nyers int és nem tulajdonló: a close() felelőssége a tulajdonosé, ezt kommentben rögzíteni kell. A const tagfüggvények miatt a const Connection&-en is olvasható minden adat. Ellenőrzési feltétel: a Connection-nek nincs destruktora, amely lezárná az fd-t; ha mégis kell RAII, akkor az egy külön tulajdonló tag (például egy UniqueFd) feladata, nem a Connection-é.