39.2 C++ core: parser, erőforrások és értéktípusok

A C záróprojekt funkcionális szerződését a C++ port nem értelmezi újra: ugyanazt a rekordnyelvtant, ugyanazokat a hibakódokat és hibapozíciókat, ugyanazt a CLI-kimenetet kell produkálnia, mint a 17.2 C referencia. A Portterv és azonos viselkedési szerződés (39.1) rögzítette a viselkedési határt, a migrációs naplót és a C ABI wrappert; ez a rész a C++ magot építi fel: az értéktípusokat, a parser állapotgépét, a platformoldali erőforrás-tulajdonosokat és a teszteket. A cél nem az, hogy minden új C++ eszközt beépítsünk, hanem hogy a szerződést a típusokba kódoljuk, és a hibalehetőségeket fordítási időbe emeljük.

39.2.1 A port szerződése: value, owner, view

A C++ port három szerepet különböztet meg. Az értéktípus (value type) önálló, invariánssal rendelkező érték: másolható vagy mozgatható, és minden megfigyelhető állapota kielégíti az invariánst. A tulajdonos (owner) erőforrást birtokol, és a destruktorában felszabadítja; a tulajdonjog mozgatható, de nem másolható, ha az erőforrás egyedi. A nézet (view) nem birtokol, hanem más objektum által birtokolt adatra mutat; élettartama nem haladhatja meg a mögöttes adatét.

A portban:

A C változatban a tulajdonlás és az élettartam a dokumentációban élt; a C++ változatban a típusok mondják ki. A megfigyelhető viselkedés eközben bájtra azonos marad.

39.2.2 Értéktípusok: report és error

A C++ core felülete (include/diag_core.hpp) a portterv kidolgozott példájában szerepel. Az értéktípusok a C struktúrák közvetlen megfelelői:

C ABI C++ core Megjegyzés
dg_report diag::report Azonos mezők: bytes, records, sum
dg_error diag::error kind és offset; a kind hatókörös enum class
dg_code diag::code Azonos numerikus értékek, static_assert ellenőrzi
DG_MAX_INPUT diag::max_input inline constexpr, egy definíció a programban

Az enum class nem konvertálódik implicit módon egésszé, ezért a C értékre fordítás a wrapperben explicit static_cast, és csak ott történik. Mivel minden alapértelmezett tagkezdőérték nulla, egy alapállapotú diag::parser pontosan a C dg_create utáni állapotnak felel meg: üres jelentés, code::ok, névfázis.

A diag::parser invariánsa ugyanaz, mint a C struktúráé: a report_.bytes legfeljebb max_input; a névhossz 0–16; a számjegyszám 0–5; a részérték legfeljebb 65535; és az első hiba után az error_ nem változik. A privát adattagok miatt ezt csak a tagfüggvények módosíthatják.

39.2.3 RAII: file descriptor és memóriatérkép

A core nem lát erőforrást; a C++ oldali platformadapterben (például egy C++ CLI-ben vagy a TCP-adapter portjában) viszont a file descriptor és a memóriatérkép a két tipikus egyedi erőforrás. Mindkettő move-only owner: a másolás tiltott, a mozgatás noexcept, a destruktor felszabadít. A UniqueFd a close-ot, a MappedFile a munmap-ot hívja.

// include/diag_io.hpp (részlet)
#pragma once
#include <cerrno>
#include <cstddef>
#include <span>
#include <system_error>
#include <utility>
#include <sys/mman.h>
#include <unistd.h>

class UniqueFd {
public:
    explicit UniqueFd(int fd = -1) noexcept : fd_(fd) {}
    ~UniqueFd() { if (fd_ >= 0) ::close(fd_); }
    UniqueFd(UniqueFd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    UniqueFd& operator=(UniqueFd&& o) noexcept {
        if (this != &o) { reset(); fd_ = std::exchange(o.fd_, -1); }
        return *this;
    }
    UniqueFd(const UniqueFd&) = delete;
    UniqueFd& operator=(const UniqueFd&) = delete;

    int get() const noexcept { return fd_; }
    int release() noexcept { return std::exchange(fd_, -1); }
private:
    void reset() noexcept { if (fd_ >= 0) { ::close(fd_); fd_ = -1; } }
    int fd_;
};

class MappedFile {
public:
    MappedFile(int fd, std::size_t length, off_t offset = 0)
        : length_(length) {
        addr_ = ::mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, offset);
        if (addr_ == MAP_FAILED) {
            addr_ = nullptr;
            throw std::system_error(errno, std::generic_category(), "mmap");
        }
    }
    ~MappedFile() { reset(); }
    MappedFile(MappedFile&& o) noexcept
        : addr_(std::exchange(o.addr_, nullptr)),
          length_(std::exchange(o.length_, 0)) {}
    MappedFile& operator=(MappedFile&& o) noexcept {
        if (this != &o) { reset(); addr_ = std::exchange(o.addr_, nullptr);
                          length_ = std::exchange(o.length_, 0); }
        return *this;
    }
    MappedFile(const MappedFile&) = delete;
    MappedFile& operator=(const MappedFile&) = delete;

    std::span<const std::byte> bytes() const noexcept {
        return {static_cast<const std::byte*>(addr_), length_};
    }
private:
    void reset() noexcept {
        if (addr_ != nullptr) { ::munmap(addr_, length_); addr_ = nullptr; length_ = 0; }
    }
    void* addr_ = nullptr;
    std::size_t length_ = 0;
};

A bytes() nézet a mapping élettartamáig érvényes. A MappedFile mozgatása nem invalidálja a nézetet, mert a címzett cím nem változik, és a mozgatott-from objektum már nem szabadít fel; a nézet akkor válik érvénytelenné, amikor az új tulajdonos megszűnik. Ezt a szerződést a header dokumentációjában rögzíteni kell. A MappedFile konstruktora std::system_error-t dobhat; ez a ritka, nem várt erőforrás-hiba, nem a parser várható hibája.

39.2.4 Az állapotgép portja és a hibapozíció

A megvalósítás a C dg_feed és dg_finish állapotgépének soronkénti megfelelője. A C++ eszközök csak ott jelennek meg, ahol a szerződést pontosabban fejezik ki: tartományalapú for a span fölött, enum class a fázisra, aggregátum-inicializálás a hibához.

// src/diag_core.cpp
#include "diag_core.hpp"

namespace diag {

bool parser::fail(code kind) noexcept
{
    if (error_.kind == code::ok)
        error_ = error{kind, report_.bytes};
    return false;
}

bool parser::feed(std::span<const unsigned char> input) noexcept
{
    if (error_.kind != code::ok)
        return false;
    if (finished_)
        return fail(code::state);

    for (const unsigned char byte : input) {
        if (report_.bytes == max_input)
            return fail(code::too_large);

        if (phase_ == phase::key) {
            if (byte >= 'a' && byte <= 'z') {
                if (key_length_ == 16)
                    return fail(code::syntax);
                ++key_length_;
            } else if (byte == '=' && key_length_ != 0) {
                phase_ = phase::number;
                digits_ = 0;
                value_ = 0;
            } else {
                return fail(code::syntax);
            }
        } else {
            if (byte >= '0' && byte <= '9') {
                const unsigned long digit = byte - '0';
                if (digits_ == 5 || value_ > (65535UL - digit) / 10UL)
                    return fail(code::syntax);
                value_ = value_ * 10UL + digit;
                ++digits_;
            } else if (byte == '\n' && digits_ != 0) {
                ++report_.records;
                report_.sum += value_;
                phase_ = phase::key;
                key_length_ = 0;
            } else {
                return fail(code::syntax);
            }
        }
        ++report_.bytes;
    }
    return true;
}

bool parser::finish() noexcept
{
    if (error_.kind != code::ok)
        return false;
    if (phase_ != phase::key || key_length_ != 0)
        return fail(code::truncated);
    finished_ = true;
    return true;
}

} // namespace diag

A bájtosztályokat explicit összehasonlítás dönti el, nem std::islower vagy std::isdigit: azok locale-függők lehetnek, a nyelvtan viszont rögzített ASCII-oktetteket ír elő. A túlcsordulásellenőrzés a szorzás előtt fut, ugyanazzal a v≤⌊(65535−d)/10⌋v \le \lfloor(65535-d)/10\rfloor feltétellel, mint a C változatban.

A hibapozíció a teljes munkamenet elejéhez viszonyított bájtoffset: a report_.bytes értéke a hiba pillanatában. Mivel a számláló csak sikeresen feldolgozott bájt után nő, ez éppen az első elutasított bájt helye; csonkolásnál a bemenet vége; limitnél pontosan max_input. A darabolás nem számít: több feed hívás offsetjei összeadódnak, nem szeletenként indulnak nulláról.

39.2.4.1 Helyességi vázlat

A port minden ága a C referencia egy ágának felel meg, azonos feltétellel és azonos állapotváltozással; a vezérlés bájtonként halad, és a hívások határán nincs implicit lezárás. Indukcióval a feldolgozott bájtok számára: ha a két implementáció állapota kk bájt után megegyezik (fázis, névhossz, számjegyszám, részérték, jelentés, hiba), akkor a (k+1)(k+1)-edik bájt után is, mert ugyanaz a döntés születik. A finish ugyanazt a feltételt vizsgálja. Ezért a port és a C referencia minden bemenetre és minden darabolásra azonos jelentést és hibát ad. A vázlat feltételezi, hogy a span bájtjai a hívás idején érvényesek.

A vázlat a C referenciához viszonyított egyezést igazolja. A specifikációhoz viszonyított helyességet a projektszerződés nyelvtanából levezetett tests/spec_examples.c és a 17.2 bizonyításvázlatai támasztják alá; a kettő együtt adja a port elfogadását.

39.2.5 Tároló- és allokátorválasztás

A C referencia egyetlen, rögzített méretű parserobjektummal dolgozik, és nem tárol rekordokat. A port ezt megtartja: a core-ban nincs std::vector, std::string vagy más dinamikus tároló, és nincs new. Ha egy rekordokat gyűjtő tárolót vezetnénk be, az megfigyelhető változás lenne a memóriakorlátban, és csak külön migrációként jöhetne szóba.

Az allokátorszerződést a wrapper őrzi meg. A dg_create a hívó dg_allocator callbackjével foglal tárolót, abba placement new-val hozza létre a dg_parser objektumot, a dg_destroy pedig meghívja a destruktort, majd ugyanazzal az allokátorral szabadít fel. A C tesztkészlet allokációs hibát injektáló esete ezért a C++ könyvtárral is ugyanúgy fut.

Egy std::pmr::memory_resource adapter, amely a dg_allocator callbackjeit hívja, kézenfekvő kiterjesztés lenne, ha a core-ba később valódi dinamikus tároló kerülne. A jelenlegi, O(1)O(1) memóriájú parsernél nincs mit az arena alá helyezni.

39.2.6 Hibamodell: explicit eredmény, kivétel nélkül

A port alapprofilja: a várható hibák — szintaktikai hiba, csonkolás, limit, API-használati hiba — értékként utaznak: a feed és a finish bool eredményt ad, a részleteket a last_error() adja. Ez a C API egy-az-egyben leképezhető alakja, ezért nincs szükség std::variant-ra vagy C++23 std::expected-re.

A core minden művelete noexcept, és nem allokál; így kivétel nem keletkezhet benne. Az egyetlen erőforrás-hiba a létrehozás: ezt a C API NULL visszatérése jelzi, ahogyan a C változatban is. A platformadapter UniqueFd és MappedFile típusai std::system_error-t dobhatnak; ezek a C ABI határán kívül élnek, és a határon belépő függvénynek el kell kapnia őket, mielőtt hibakóddá alakítja.

39.2.7 A C interfész és a tesztek

A C interfész a változatlan include/diag.h; a C++ oldali definíciókat a portterv src/diag_c_api.cpp wrappere adja. Új C header nem kell, és nem is szabad: a C kliensek, köztük a src/main.c és a C tesztek, forrásszinten változatlanok maradnak.

A C++ core saját tesztje közvetlenül a diag::parser osztályt hívja, C ABI nélkül. Az ellenőrzések NDEBUG mellett sem tűnnek el, mert nem assert-re épülnek:

// tests/test_core.cpp -- a C++ core közvetlen tesztje
#include "diag_core.hpp"

#include <cstdio>
#include <cstdlib>
#include <span>
#include <string_view>

#define CHECK(condition) do {                                      \
    if (!(condition)) {                                            \
        std::fprintf(stderr, "CHECK failed at line %d\n", __LINE__); \
        std::exit(EXIT_FAILURE);                                   \
    }                                                              \
} while (0)

static std::span<const unsigned char> bytes(std::string_view s)
{
    return {reinterpret_cast<const unsigned char *>(s.data()), s.size()};
}

int main()
{
    const auto input = bytes("a=7\ncpu=12\n");

    for (std::size_t split = 0; split <= input.size(); ++split) {
        diag::parser p;
        CHECK(p.feed(input.first(split)));
        CHECK(p.feed(input.subspan(split)));
        CHECK(p.finish());
        const diag::report r = p.current();
        CHECK(r.records == 2 && r.sum == 19 && r.bytes == 11);
    }

    diag::parser truncated;
    CHECK(truncated.feed(bytes("a=7\ncpu=12")));
    CHECK(!truncated.finish());
    CHECK(truncated.last_error().kind == diag::code::truncated);
    CHECK(truncated.last_error().offset == 10);

    diag::parser bad;
    CHECK(!bad.feed(bytes("a=65536\n")));
    CHECK(bad.last_error().kind == diag::code::syntax);
    CHECK(bad.last_error().offset == 6);

    unsigned char reused[] = {'a', '=', '7', '\n'};
    diag::parser r;
    CHECK(r.feed(std::span<const unsigned char>(reused, 2)));
    reused[0] = reused[1] = 'X';      // a hívó újrahasználja a puffert
    CHECK(r.feed(std::span<const unsigned char>(reused + 2, 2)));
    CHECK(r.finish() && r.current().sum == 7 && r.current().bytes == 4);
    return 0;
}
mkdir -p build/cxx
g++ -std=c++20 -O2 -g -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
    -Iinclude src/diag_core.cpp tests/test_core.cpp \
    -o build/cxx/test_core
build/cxx/test_core

A C ABI-n keresztüli ellenőrzést a C referencia saját tesztjei végzik, a C++ könyvtárhoz linkelve; a buildparancsokat és a differenciális tesztet a portterv adja.

Buildjegyzőkönyv (lab-gcc profil). A diag_core.cpp, a diag_c_api.cpp és a test_core.cpp G++ 13.3.0-val, -std=c++20 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Werror beállítással figyelmeztetés nélkül fordult Linux x86-64 környezetben; a test_core sikeresen lefutott. Az RAII-szakasz UniqueFd és MappedFile részlete nem része ennek a buildnek.

39.2.8 Tipikus hibák

A leggyakoribb hiba a span megőrzése: a parser a feed után nem tárolhat a bemenetre mutató nézetet, mert a hívó a puffert újrahasználja; a félbemaradt rekordhoz elég a fázis, a névhossz, a számjegyszám és a részérték. A második: a bájtosztályok cseréje std::isalpha/std::isdigit hívásra, ami locale-függővé teszi a nyelvtant. A harmadik: a túlcsordulásellenőrzés áthelyezése a szorzás utánra, különösen szűkebb tárolótípussal; ilyenkor az érték csendben körbefordul. A negyedik: a diag::code értékeinek átrendezése; a static_assert ezt fordításkor megfogja, ezért nem szabad eltávolítani. Az ötödik: a MappedFile bytes() nézetének használata a MappedFile megszűnése után; a nézet nem tartja életben a mappinget.

39.2.9 Összefoglaló

A C++ core a 17.2 C referencia állapotgépének hű portja. A bemenet nem tulajdonló span, a jelentés, a hiba és maga a parser értéktípus, a C handle és a platformerőforrások tulajdonosok. A core nem allokál és nem dob, ezért a C ABI wrapper kivétel- és allokátorszerződése egyszerű. A C header és a C kliensek változatlanok; a port helyességét a C referencia saját tesztjei, a specifikációs példák és a differenciális korpusz együtt igazolják.

39.2.10 Feladatok

  1. Magyarázó feladat. Nevezd meg a portban a nézetet, az értéktípusokat és a tulajdonosokat! Miért lehet a diag::parser másolható, miközben a UniqueFd nem? Mit jelent egy rekord közepén készített másolat?

  2. Kódolási feladat. Írj C++ tesztet, amely a "a=7\ncpu=12\n" bemenetet bájtonként táplálja be, a hatodik bájt előtt másolatot készít a parserről, majd a másolatot a maradék bemenettel külön lezárja. Ellenőrizd, hogy a két parser jelentése azonos.

  3. Diagnosztikai feladat. Egy kolléga a részértéket std::uint16_t típusban tárolja, és az ellenőrzést a szorzás utánra helyezi:

value_ = static_cast<std::uint16_t>(value_ * 10u + digit);
if (value_ > 65535u)
    return fail(code::syntax);

Mit ír ki a C++ CLI a "a=65536\n" bemenetre, és mit a C referencia? Melyik ellenőrzés fogja meg a hibát?

  1. Kódolási és diagnosztikai feladat. A MappedFile bytes() nézetét eltárolod egy std::span-ban, majd a MappedFile-ot mozgatod egy másik objektumba. Érvényes marad-e a nézet? Fogalmazd meg a szerződést, és egészítsd ki a MappedFile dokumentációját egy bekezdéssel.

39.2.11 Megoldások

  1. A std::span<const unsigned char> a nézet: nem birtokol, és csak a feed hívás idejéig érvényes. A diag::report, a diag::error és a diag::parser értéktípus: csak skalárokat tartalmaz, külső erőforrást nem birtokol, ezért a másolás teljes, független állapotot hoz létre. A dg_parser * handle és a UniqueFd tulajdonos; a UniqueFd másolása két tulajdonost teremtene ugyanarra a file descriptorra, ami dupla close-hoz vezetne, ezért tiltott. A rekord közepén készített másolat a félbemaradt rekord teljes állapotát (fázis, névhossz, számjegyszám, részérték) hordozza, így bármelyik példány ugyanonnan folytatható. Ellenőrzési feltétel: a magyarázat a három szerepet és a másolhatóság okát is megnevezi.

// tests/test_bytewise.cpp -- bájtonkénti betáplálás és értékszemantika
#include "diag_core.hpp"

#include <cstdio>
#include <cstdlib>
#include <span>
#include <string_view>

#define CHECK(condition) do {                                      \
    if (!(condition)) {                                            \
        std::fprintf(stderr, "CHECK failed at line %d\n", __LINE__); \
        std::exit(EXIT_FAILURE);                                   \
    }                                                              \
} while (0)

int main()
{
    constexpr std::string_view text = "a=7\ncpu=12\n";
    const std::span<const unsigned char> input{
        reinterpret_cast<const unsigned char *>(text.data()), text.size()};

    diag::parser p;
    diag::parser snapshot;
    for (std::size_t i = 0; i < input.size(); ++i) {
        if (i == 6)
            snapshot = p;               // másolat a rekord közepén
        CHECK(p.feed(input.subspan(i, 1)));
    }
    CHECK(p.finish());
    CHECK(p.current().records == 2 && p.current().sum == 19);

    // A másolat független: ugyanonnan folytatva ugyanoda jut.
    CHECK(snapshot.current().bytes == 6);
    CHECK(snapshot.feed(input.subspan(6)));
    CHECK(snapshot.finish());
    CHECK(snapshot.current().records == p.current().records);
    CHECK(snapshot.current().sum == p.current().sum);
    CHECK(snapshot.current().bytes == p.current().bytes);
    return 0;
}

A hatodik bájt előtt a parser 6 bájtot fogyasztott el (a=7\ncp), és a második rekord névfázisában áll. A teszt G++ 13.3.0-val, -Werror mellett fordult és sikeresen lefutott. Ellenőrzési feltétel: a két jelentés mindhárom mezője megegyezik.

  1. A std::uint16_t legfeljebb 65535-öt tárol, ezért a 6553 után a 6 számjegynél a 6553665536 érték 0-ra fordul körbe, és a value_ > 65535u feltétel soha nem teljesül. A C++ CLI kimenete OK records=1 sum=0 bytes=8, kilépési kódja 0; a C referenciáé ERR code=syntax offset=6, kilépési kódja 4. A hibát a differenciális teszt a korpusz a=65536 elemén megfogja (stdout, stderr és kilépési kód is eltér), a specifikációs példák "a=65536\n" sora szintén, és a GCC -Wextra alatt -Wtype-limits figyelmeztetést ad a mindig hamis összehasonlításra. A javítás a C referencia sorrendje: a tartományellenőrzés a szorzás előtt, elég széles típusban. Ellenőrzési feltétel: a hibás változat a fenti kimenetet adja, a javított a C referenciáéval azonosat. A hibás változatot GCC 13.3.0-val ténylegesen lefordítottuk és lefuttattuk; a fenti kimenetek ebből származnak.

  2. A nézet érvényes marad, feltéve, hogy a mozgatott-from MappedFile nem szabadítja fel a mappinget (a move után a addr_ nullptr), és az új tulajdonos életben marad. A std::span csak a címet és a hosszt tárolja, nem az owner identitását, ezért a mozgatás nem változtatja meg a nézet által látott memóriát. A szerződés: a bytes() nézet a mapping élettartamáig érvényes; a mapping akkor szűnik meg, amikor az őt birtokló MappedFile (a mozgatás után az új példány) megszűnik, vagy explicit módon felszabadítják. A dokumentáció kiegészítése: „A bytes() által visszaadott span a mapping élettartamához kötött; a MappedFile mozgatása nem invalidálja, a tulajdonos megszűnése igen.” Ellenőrzési feltétel: a válasz különbséget tesz a owner objektum identitása és a mapping élettartama között.