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.
É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:
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.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.
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.
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.
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.
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.
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.
Kétféle modell létezik, és a kettőt nem szabad összekeverni:
const&-ként adjuk át; a másolat önálló, élettartama a
hívóé.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.
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.cppA 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.
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.
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.
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ó.
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.
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_;
};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.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-é.