39.1 Portterv és azonos viselkedési szerződés

A C záróprojekt akkor fejeződik be igazán, amikor a működő implementációt nem eldobjuk, hanem szerződésként használjuk a következő változathoz. Ez az alfejezet arról szól, hogyan lehet a C capstone viselkedését C++20-ra vinni úgy, hogy a port ne „újraírás” legyen, hanem ellenőrizhető transzformáció. A cél nem az, hogy minden C szerkezet helyére látványos C++ feature kerüljön, hanem az, hogy minden megváltoztatott döntés mögött legyen kimondott tulajdonlási, hibakezelési és elfogadási indok. A fejezet a portterv dokumentumára és annak első, konkrét szeletére összpontosít; a teljes szolgáltatás termékfejlesztését, üzemeltetését és a C++ nyelv teljes eszköztárának kötelező beépítését szándékosan nem tárgyalja.

A fejezet szabványos állításainak forrása a forrásjegyzék [C++20] és [C17] tétele. A konkrét helyek az N4861 tervezet szakaszcímkéivel: a nyelvi kötés [dcl.link]; a nem dobó kivételspecifikáció megsértése és a std::terminate [except.spec] és [except.terminate]; a std::span [views.span]; a placement new [new.delete.placement]; a static_assert és a noexcept operátor [dcl.pre], illetve [expr.unary.noexcept]. A fejezet kódját a lab-gcc profilban lefordítottuk és lefuttattuk; az eredményeket a buildjegyzőkönyvek rögzítik.

39.1.1 A viselkedési szerződés mint a port tárgya

A portolás kulcsfogalma a viselkedési szerződés (behavioral contract): azon megfigyelhető tulajdonságok halmaza, amelyekre a rendszer külső fogyasztói támaszkodhatnak, és amelyeknek a port után változatlannak kell maradniuk. Külső fogyasztó a protokoll kliens, a CLI-t használó operátor, a naplót elemző monitoring, a könyvtárat linkelő más program, és—ha van ilyen—a C ABI-n keresztül hívó idegen nyelvű kód. A szerződés elemei jellemzően a következők:

Ezzel szemben a szabadon változó belső tulajdonságok közé tartozik a belső struktúrák elrendezése, az allokációk száma és mintája, a szálak száma, a belső segédfüggvények neve és a naplózás belső implementációja—feltéve, hogy a szerződés ezekre nem hivatkozik. A portterv legfontosabb fegyelme éppen ennek a két halmaznak az írásos szétválasztása. Ha ezt nem tesszük meg a kód megírása előtt, akkor a végén nem tudjuk megmondani, hogy egy viselkedésbeli eltérés hiba-e vagy megengedett változás.

Két további definíciót érdemes rögzíteni. A golden teszt olyan rögzített bemenet–kimenet pár, amelyet a port előtti implementációval vettünk fel, és amelyet a port után bájtszinten összehasonlítunk. A tulajdonlási térkép (ownership map) minden erőforrásra megmondja, ki allokálja, ki szabadítja fel, meddig él, és mely művelet invalidálja. A két eszköz bizonyító ereje korlátos, ezért a port ellenőrzését három külön kérdésre bontjuk. A specifikációhoz viszonyított helyesség kérdése, hogy a program azt teszi-e, amit a szerződés előír; ehhez független tesztorákulum kell, például a protokoll nyelvtanából kézzel levezetett elvárt értékek. A C-hez viszonyított regressziós egyezés kérdése, hogy a port ugyanazt teszi-e, mint a C referencia; erre való a golden teszt és a differenciális összevetés, de ezek a referencia esetleges hibáját is konzerválják. Az ownership-invariánsok kérdése, hogy a végrehajtás betartja-e a tulajdonlási térképet; a térkép maga csak dokumentáció, betartását sanitizeres futás, hibainjektálás és kódáttekintés igazolja, mindig csak a ténylegesen bejárt utakra. Végül az ekvivalenciaosztály a bemenet olyan részhalmaza, amelyre a szerződés azonos viselkedést ír elő; a tesztkészletnek minden osztályból legalább egy reprezentánst tartalmaznia kell.

39.1.2 Modulonkénti leképezés: a döntési tábla

A portterv gerince egy táblázat, amely minden C mintához hozzárendeli a választott C++ eszközt, a megőrzendő szerződést és a tipikus hibát. A táblázat nem recept, hanem döntési napló: minden sor mögött ott kell lennie annak az indoknak, hogy miért ez az eszköz, és miért nem egy másik.

C minta C++20 eszköz Megőrzendő szerződés Tipikus hiba
goto cleanup sorozat RAII owner (unique_ptr egyedi deleterrel, saját wrapper) minden úton pontosan egyszer szabadít fel dobó destruktor, dupla felszabadítás
(const char*, size_t) pár std::string_view, std::span<const std::byte> nem tulajdonol; a hívó élettartamához kötött a view túléli az ownert
kézi tag + switch std::variant + std::visit kimerítő kezelés, a hibás tag továbbra is hiba std::get dob, ha rossz alternatíva
int visszatérési kód std::error_code, std::variant, C++23-ban std::expected a kód–viselkedés leképezés stabil kivétel átszivárgása a C határon
void *ctx callback típusos struct + referencia vagy span a tulajdonos és az élettartam dokumentált típusvesztés, élettartam-túllépés

39.1.2.1 Cleanup ág → RAII owner

A C-ben a goto cleanup helyes és gyakori minta: egy függvény több erőforrást szerez meg egymás után, és minden hibaponton a már megszerzett erőforrásokat kell felszabadítani. A C++ megfelelője nem az, hogy „minden goto-t eltüntetünk”, hanem az, hogy az erőforrás élettartamát egy owner objektum élettartamához kötjük. Az owner lehet std::unique_ptr egyedi deleterrel (például FILE* vagy file descriptor esetén), lehet saját kis wrapper osztály, amelynek destruktora a felszabadítást végzi, és lehet a scope-ra kötött scope guard is.

A lényeg a lokális helyesség: minden owner csak a saját erőforrásáért felel, és a felszabadítás akkor is megtörténik, ha a függvény kivétellel vagy korai visszatéréssel lép ki. Ez nem esztétikai kérdés. Tegyük fel, hogy egy függvény nn erőforrást szerez sorban, és minden lépés után hibázhat. A naiv kézi kódban az ii-edik hibaponton ii erőforrást kell felszabadítani, így a forrásban összesen

∑i=1ni=n(n+1)2\sum_{i=1}^{n} i = \frac{n(n+1)}{2}

felszabadító utasítás szerepel. RAII esetén minden owner destruktora pontosan egyszer fut, tehát a tényleges felszabadítások száma nn, és a kódban minden ownerhez egyetlen destruktor tartozik. A különbség nem a futásidejű költség, hanem a karbantarthatóság: a kézi változatban minden új erőforrás az összes korábbi hibapont módosítását igényli, a RAII-változatban csak egy új owner deklarációja kerül be.

Fontos feltétel: a destruktor ne dobjon, és a felszabadítás ne függjön attól, hogy a program milyen állapotban van. Ha a felszabadítás hibázhat (például egy flush vagy egy close), akkor azt nem a destruktorba kell tenni, hanem egy explicit commit/close műveletbe, amelynek hibája a hívóhoz tartozik. A destruktor csak a „már nem lehet mást tenni” esetet kezeli.

39.1.2.2 Byte view → span és string_view

A C API gyakori mintája a (pointer, hossz) pár. Ennek C++20-as megfelelője szöveges adatra a std::string_view, bináris adatra a std::span<const std::byte>. Mindkettő nem tulajdonló nézet (borrowed view): nem allokál, nem másol, és élettartama ahhoz a tárolóhoz kötött, amelyre mutat. A porttervnek ezt a szerződést explicit módon rögzítenie kell, mert a view visszaadása a legkönnyebben keletkező dangling hiba forrása. Ha egy függvény std::string_view-t ad vissza egy lokális std::string-ből, a hívó érvénytelen memóriára mutató nézetet kap; ezt a fordító nem feltétlenül jelzi.

A std::span használatakor érdemes a std::byte-ot választani a char helyett, ha az adat bináris: a std::byte kifejezi, hogy nem karakterekről van szó, és megakadályozza a véletlen szövegműveleteket. A std::string_view beágyazott nulla bájtot is képes tárolni, ellentétben a nullterminált C sztringgel; ez viselkedésváltozás lehet, ha az eredeti C kód strlen-re támaszkodott. A port során ezért a „sztring” szót minden előfordulásnál pontosítani kell: nullterminált, hosszal megadott, vagy mindkettő.

39.1.2.3 Tagged union → variant

A C-ben a tagged union kézi enum tag + union + switch hármasa. C++20-ban a std::variant ugyanezt a szerepet tölti be típusbiztosabban: a std::visit kimerítő kezelést kényszerít, és a nem várt tag elfelejtése fordítási hibát ad. Két dolgot azonban a portnak meg kell őriznie. Egyrészt a std::get kivételt dob, ha az alternatíva nem az, amit kértünk; a C változatban egy ilyen helyzet jellemzően nem definiált viselkedés vagy csendes hibás érték volt. A port nem „javíthatja” csendben a hibát kivétellé, ha a hívó ezt nem várja; a viselkedést a szerződésben kell rögzíteni. Másrészt a variant alternatíváinak sorrendje és a hozzá tartozó indexek a belső szerződés részei lehetnek, ha a variantt perzisztáljuk vagy ABI-határon adjuk át—ezt a porttervnek külön kell tiltania vagy engedélyeznie.

39.1.2.4 Hibakód → hibapolicy

A hibakezelés portja nem „cseréljük le a hibakódokat kivételekre”, hanem hibapolicy-t definiálunk. A tipikus, jól működő felosztás három réteg:

  1. Core library: platformfüggetlen, memóriában tesztelhető logika. Itt lehet kivétel vagy std::expected (C++23), illetve saját result típus; a választás a csapat konvenciója, de a core-on belül konzisztens.
  2. Platform adapter: vékony réteg, amely az OS hibáit (errno, EAGAIN, EPIPE) std::error_code-dé alakítja, és a core felé a core hibapolicy-ját használja.
  3. C ABI határ: extern "C" függvények, amelyek soha nem dobnak, és az eredeti C hibakódokat adják vissza.

A döntési táblát a porttervben rögzíteni kell: melyik réteg melyik hibát hogyan reprezentálja, mi a leképezés a rétegek között, és mely hibakódok számítanak „nem fordulhat elő” esetnek. A C ABI enum-értékeit soha nem szabad átrendezni, még akkor sem, ha a C++ oldalon már nem használjuk őket: a már lefordított kliensek a numerikus értékekre támaszkodnak.

39.1.2.5 Core library és platform adapter

A port egyik legnagyobb nyeresége a szétválasztás. A core library nem tartalmazhat #include <sys/socket.h>-t, nem hívhat read-et, és nem ismerheti a file descriptorokat. A core bemenete egy std::string_view vagy std::span, kimenete egy értéktípus; a tesztje memóriában fut, sanitizerekkel és property tesztekkel. A platform adapter ezzel szemben vékony: átveszi az OS eseményeit, std::error_code-dé alakítja a hibákat, és továbbadja a core-nak. Ennek a szétválasztásnak a haszna a port szempontjából az, hogy a viselkedési szerződés nagy része a core-ban tesztelhető, és a port után a golden tesztek többsége OS nélkül, determinisztikusan futtatható.

39.1.3 A C-kompatibilis határ

Ha a C++ implementációt meg kell tartani egy stabil C API mögött, akkor a határnak saját szerződése van. Három szabályt érdemes betartani, és közben három fogalmat külön kell tartani.

A nyelvi kötés (extern "C") a név- és hívási konvenciót határozza meg, nem kivételspecifikáció. Egy C kötésű, de C++-ban definiált függvény C++ hívó felé ugyanúgy dobhat, mint bármely más függvény. A noexcept kivételspecifikáció: ha egy noexcept függvényből kivétel próbál kilépni, std::terminate hívódik. Az ABI és a futtatókörnyezet dönti el, mi történik, ha a kivétel valódi idegen nyelvi kereteken, például C-ben fordított hívón át terjedne; ez a C és C++ szabvány szempontjából nem hordozható viselkedés, ezért erre semmilyen módon nem építünk. A különbséget egy kis próba mutatja:

// tests/ffi_probe.cpp -- a nyelvi kötés nem kivételspecifikáció
#include <cstdio>
#include <cstdlib>
#include <exception>

extern "C" int plain_entry() { throw 42; }            // C kötés, dobhat
extern "C" int noexcept_entry() noexcept { throw 42; } // C kötés, noexcept

int main()
{
    try {
        (void)plain_entry();
        std::puts("plain: nem dobott");
    } catch (int) {
        std::puts("plain: a kivétel a C++ hívóhoz eljutott");
    }

    std::set_terminate([] {
        std::puts("noexcept: std::terminate");
        std::fflush(stdout);           // az _Exit nem üríti a puffert
        std::_Exit(EXIT_SUCCESS);
    });
    (void)noexcept_entry();
    std::puts("noexcept: nem várt folytatás");
    return EXIT_FAILURE;
}

GCC 13.3.0-val fordítva a kimenet: plain: a kivétel a C++ hívóhoz eljutott, majd noexcept: std::terminate. A GCC a második függvénynél fordításkor -Wterminate figyelmeztetést is ad.

Ebből következik a projekt FFI-szabálya: egyetlen C ABI belépési pontból sem léphet ki kivétel. Ezt vagy úgy biztosítjuk, hogy a belépési pont csak bizonyítottan nem dobó műveleteket hív, és ezt fordításkor static_assert(noexcept(...)) ellenőrzi; vagy úgy, hogy a törzs try/catch (...) blokkban fut, és minden kivételt dokumentált hibakódra képez. A projekt wrappere az első utat követi.

Másodszor: az opaque handle (áttetsző fogantyú) mögött a C++ objektum rejtve marad; a kliens csak mutatót lát, és a létrehozás/felszabadítás ugyanabban a modulban történik, hogy az allokátor párosítva legyen.

Harmadszor: a wrapper ne változtasson csendben a szerződésen, egyik irányban sem. Három változásfajtát kell megkülönböztetni:

Mindhárom megfigyelhető változás, ezért a porttervben külön migrációs tételként szerepel, dokumentációval és teszttel. A projekt portja egyiket sem vezeti be: a nem null parser továbbra is előfeltétel.

Az allokátorszerződés is a határ része. Ha a C API-ban a hívó allokál és a könyvtár szabadít fel (vagy fordítva), akkor a C++ oldalon ugyanazt az allokátort kell használni; new/delete és malloc/free keverése nem megengedett. A porttervnek rögzítenie kell, hogy a határon átadott buffereket ki allokálja, ki felszabadítja, és milyen élettartamig érvényesek.

39.1.4 Kidolgozott példa: a 17.2 rekordparser portja

A port kiindulópontja egyetlen, rögzített C referencia-implementáció: a C könyvtári mag és CLI megvalósítási labor include/diag.h, src/diag.c és src/main.c fájlja, a projektszerződés nyelvtanával, kimeneti formátumával és kilépési kódjaival. A port előtt ennek a buildjével vesszük fel a golden kimeneteket, és verziókövetésbe tesszük a tesztkészletet.

A megőrzendő szerződés leltára.

Szerződéselem Forrás Ellenőrzés a port után
Rekordnyelvtan, records/sum/bytes jelentése 17.1 tests/spec_examples.c, tests/test_diag.c
Hibakódok és offsetek, ragadós hiba, finish utáni feed 17.1, 17.2 tests/test_diag.c, differenciális korpusz
CLI stdout, stderr és kilépési kód 17.1 golden fájlok, tests/cli_args.sh
C ABI: dg_* nevek, dg_code értékek, struktúrák, allokátorszerződés include/diag.h változatlan header, C-ben fordított tesztek
O(n)O(n) idő, O(1)O(1) parsermemória, egyetlen allokáció 17.2 kódáttekintés, allokációs teszt

Migrációs napló. Ebben a portban megfigyelhető viselkedés nem változik; minden változás belső:

  1. Az állapotgép a diag::parser C++ osztályba kerül; a fázis enum class, a bemenet std::span<const unsigned char>.
  2. A dg_report és a dg_error megfelelője a diag::report és a diag::error értéktípus.
  3. A dg_* C ABI-t egy extern "C" wrapper szolgáltatja a C++ core fölött. Az include/diag.h nem változik.
  4. A src/main.c CLI változatlanul C17-ben fordul, és a C++ könyvtárhoz linkeljük.

Ha egy későbbi változat megfigyelhető viselkedést módosítana — például új hibakódot vezetne be —, az külön, számozott migrációs tétel lenne, saját tesztekkel és a golden fájlok dokumentált frissítésével. Az „azonos viselkedés” állítás csak a fenti ellenőrzések sikere után használható.

A C++ core felülete:

// include/diag_core.hpp -- C++20 core, nem tud az OS-ről
#pragma once
#include <cstddef>
#include <span>

namespace diag {

inline constexpr std::size_t max_input = 1048576;

// A numerikus értékek a C dg_code értékeivel egyeznek.
enum class code { ok = 0, syntax, truncated, too_large, state };

struct error {
    code kind = code::ok;
    std::size_t offset = 0;
};

struct report {
    std::size_t bytes = 0;
    std::size_t records = 0;
    unsigned long long sum = 0;
};

// A 17.2 C állapotgépének portja. Nem allokál, nem dob kivételt,
// és a feed hívás után nem őrzi meg a bemeneti span-t.
class parser {
public:
    bool feed(std::span<const unsigned char> input) noexcept;
    bool finish() noexcept;

    [[nodiscard]] error last_error() const noexcept { return error_; }
    [[nodiscard]] report current() const noexcept { return report_; }

private:
    enum class phase { key, number };

    bool fail(code kind) noexcept;

    report report_{};
    error error_{};
    std::size_t key_length_ = 0;
    unsigned digits_ = 0;
    unsigned long value_ = 0;
    phase phase_ = phase::key;
    bool finished_ = false;
};

} // namespace diag

A megvalósítást a C++ core alfejezet adja. A C ABI wrapper ugyanazokat a szimbólumokat definiálja, mint a src/diag.c; ezért a két megvalósítás soha nem kerül ugyanabba a linkelésbe, hanem két külön könyvtár készül: libdiag.a a C-ből és libdiagxx.a a C++-ból.

// src/diag_c_api.cpp -- a 17.2 diag.h C ABI-ja a C++ core fölött
#include "diag_core.hpp"

extern "C" {
#include "diag.h"
}

#include <cstdlib>
#include <new>
#include <type_traits>
#include <utility>

static_assert(static_cast<int>(diag::code::ok) == DG_OK);
static_assert(static_cast<int>(diag::code::syntax) == DG_SYNTAX);
static_assert(static_cast<int>(diag::code::truncated) == DG_TRUNCATED);
static_assert(static_cast<int>(diag::code::too_large) == DG_TOO_LARGE);
static_assert(static_cast<int>(diag::code::state) == DG_STATE);
static_assert(diag::max_input == DG_MAX_INPUT);

// FFI-szabály: egy belépési pontból sem léphet ki kivétel. Itt ezt az
// biztosítja, hogy csak nem dobó core-műveleteket hívunk; ha ez
// megváltozna, a fordítás itt megáll, és try/catch blokk kell.
static_assert(std::is_nothrow_default_constructible_v<diag::parser>);
static_assert(noexcept(std::declval<diag::parser &>().feed({})));
static_assert(noexcept(std::declval<diag::parser &>().finish()));
static_assert(noexcept(std::declval<const diag::parser &>().last_error()));
static_assert(noexcept(std::declval<const diag::parser &>().current()));

struct dg_parser {
    dg_allocator allocator;
    diag::parser core;
};

extern "C" {

static void *default_allocate(size_t size, void *)
{
    return std::malloc(size);
}

static void default_release(void *pointer, void *)
{
    std::free(pointer);
}

dg_parser *dg_create(const dg_allocator *allocator)
{
    dg_allocator chosen = { default_allocate, default_release, nullptr };
    if (allocator != nullptr)
        chosen = *allocator;

    void *storage = chosen.allocate(sizeof(dg_parser), chosen.context);
    if (storage == nullptr)
        return nullptr;
    // Nem dobó konstrukció: a C határon nem léphet át kivétel.
    return ::new (storage) dg_parser{chosen, diag::parser{}};
}

void dg_destroy(dg_parser *parser)
{
    if (parser != nullptr) {
        const dg_allocator allocator = parser->allocator;
        parser->~dg_parser();
        allocator.release(parser, allocator.context);
    }
}

bool dg_feed(dg_parser *parser, dg_view input)
{
    return parser->core.feed({input.data, input.size});
}

bool dg_finish(dg_parser *parser)
{
    return parser->core.finish();
}

dg_error dg_get_error(const dg_parser *parser)
{
    const diag::error e = parser->core.last_error();
    return dg_error{static_cast<dg_code>(e.kind), e.offset};
}

dg_report dg_get_report(const dg_parser *parser)
{
    const diag::report r = parser->core.current();
    return dg_report{r.bytes, r.records, r.sum};
}

const char *dg_code_name(dg_code code)
{
    switch (code) {
    case DG_OK:        return "ok";
    case DG_SYNTAX:    return "syntax";
    case DG_TRUNCATED: return "truncated";
    case DG_TOO_LARGE: return "limit";
    case DG_STATE:     return "state";
    }
    return "state";
}

} // extern "C"

A példa több portdöntést is láthatóvá tesz. A C header változatlan, ezért a C++ fordítási egység extern "C" blokkba ágyazva include-olja; így a deklarációk C nyelvi kötést kapnak, a header pedig nem tartalmaz C++-ra vonatkozó módosítást. A static_assert sorok fordításkor ellenőrzik, hogy a C++ hibakódok numerikus értékei egyeznek a C ABI értékeivel. A wrapper nem ad hozzá új ellenőrzést: ahol a C API előfeltétele nem null parser volt, ott a C++ változat is előfeltételként kezeli. Az FFI-szabályt a második static_assert-csoport fordításkor ellenőrzi: a wrapper csak noexcept core-műveleteket és nem dobó placement new-t hív, így a C határon nem léphet át kivétel. Ha egy későbbi változtatás a feed elől elvenné a noexcept-et, a wrapper fordítása ezen a ponton megállna; ilyenkor a belépési pontot try/catch blokkal kell hibakóddá alakítani. Ezt a negatív próbát GCC 13.3.0-val elvégeztük: a noexcept eltávolítása után a fordítás a feed-re vonatkozó static_assert-nél leállt.

A buildparancsok a laborhoz. A C forrás C fordítóval, C17 módban fordul; a C++ forrás C++ fordítóval; a végső linkelést a C++ driver végzi, mert a C++ futtatókönyvtárat is hozzá kell adni:

set -eu
mkdir -p build/gcc build/cxx

# Referencia: a 17.2 buildje, változatlanul
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
    -Iinclude -c src/diag.c -o build/gcc/diag.o
ar rcs build/gcc/libdiag.a build/gcc/diag.o

# Port: C++20 core és C ABI wrapper
for f in diag_core diag_c_api; do
    g++ -std=c++20 -O2 -g -Wall -Wextra -Wpedantic -Wconversion \
        -Wshadow -Iinclude -c "src/$f.cpp" -o "build/cxx/$f.o"
done
ar rcs build/cxx/libdiagxx.a build/cxx/diag_core.o build/cxx/diag_c_api.o

# Ugyanaz a C CLI és ugyanazok a C tesztek, a C++ könyvtárral
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -D_POSIX_C_SOURCE=200809L \
    -Iinclude -c src/main.c -o build/cxx/main.o
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic \
    -Iinclude -c tests/test_diag.c -o build/cxx/test_diag.o
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic \
    -Iinclude -c tests/spec_examples.c -o build/cxx/spec_examples.o
g++ build/cxx/main.o build/cxx/libdiagxx.a -o build/cxx/diag
g++ build/cxx/test_diag.o build/cxx/libdiagxx.a -o build/cxx/test_diag
g++ build/cxx/spec_examples.o build/cxx/libdiagxx.a \
    -o build/cxx/spec_examples

A C++ változat tehát a C referencia saját tesztjeit futtatja, nem újakat. Ezt egy differenciális teszt egészíti ki, amely tetszőleges bemenetkorpuszon összeveti a két CLI teljes megfigyelhető kimenetét:

#!/bin/sh
# tests/diff_c_cxx.sh C_DIAG CXX_DIAG KORPUSZ_KÖNYVTÁR
# Minden *.in bemenetre összeveti a két CLI stdoutját, stderrjét
# és kilépési kódját. Eltérésnél a fájl nevét írja ki.
set -u
c=$1 cxx=$2 dir=$3
tmp=$(mktemp -d) || exit 2
fail=0 count=0

for f in "$dir"/*.in; do
    "$c"   "$f" >"$tmp/c.out"   2>"$tmp/c.err";   echo $? >"$tmp/c.rc"
    "$cxx" "$f" >"$tmp/cxx.out" 2>"$tmp/cxx.err"; echo $? >"$tmp/cxx.rc"
    for k in out err rc; do
        if ! cmp -s "$tmp/c.$k" "$tmp/cxx.$k"; then
            echo "DIFF $k: $f" >&2
            fail=1
        fi
    done
    count=$((count + 1))
done

rm -rf "$tmp"
echo "checked=$count"
exit $fail

A korpusz rögzített véletlenmaggal generálható, így bárki pontosan ugyanazt a 3016 bemenetet kapja:

#!/usr/bin/env python3
"""tests/gen_corpus.py KÖNYVTÁR -- a differenciális korpusz előállítása"""
import os
import random
import sys

out = sys.argv[1]
os.makedirs(out, exist_ok=True)
cases = [b"", b"a=7\ncpu=12\n", b"a=7\ncpu=12", b"a=65535\n", b"a=65536\n",
         b"a=000001\n", b"abcdefghijklmnop=1\n", b"abcdefghijklmnopq=1\n",
         b"a=1\r\n", b"a=\n", b"=1\n", b"a=1\x00\n", b"cpu", b"cpu=",
         b"a=0\n" * 262144, b"a=0\n" * 262144 + b"b"]
rnd = random.Random(20261025)
alphabet = b"abz=0159\n\rA \x00"
for _ in range(3000):
    n = rnd.randint(0, 40)
    cases.append(bytes(rnd.choice(alphabet) for _ in range(n)))
for i, case in enumerate(cases):
    with open(os.path.join(out, f"{i:04d}.in"), "wb") as f:
        f.write(case)
python3 tests/gen_corpus.py corpus
build/cxx/test_diag
build/cxx/spec_examples
build/cxx/diag tests/golden.in | cmp tests/golden.out -
sh tests/cli_args.sh build/cxx/diag
sh tests/diff_c_cxx.sh build/gcc/diag build/cxx/diag corpus

Buildjegyzőkönyv (lab-gcc profil). A fenti buildet és teszteket Linux x86-64 környezetben, GCC/G++ 13.3.0 és glibc 2.39 mellett lefuttattuk, -Werror beállítással is figyelmeztetés nélkül. A C++ könyvtárral linkelt test_diag, spec_examples, golden és cli_args.sh teszt sikeres volt; a test_diag ASan/UBSan builddel is. A differenciális korpusz 3016 bemenetből állt: a specifikációs példák, a pontos méretlimit és a limit utáni bájt, valamint 3000 rögzített véletlenmaggal generált, legfeljebb 40 bájtos bemenet a abz=0159, LF, CR, A, szóköz és 0x00 oktettekből. A két CLI stdoutja, stderrje és kilépési kódja egyik bemeneten sem tért el. Ez regressziós egyezés a C referenciával, nem a specifikációhoz viszonyított teljes helyességi bizonyítás; utóbbit a specifikációból levezetett spec_examples.c és a 17.2 bizonyításvázlatai támasztják alá.

39.1.5 Elfogadási feltételek

A port akkor kész, ha a szerződés minden dimenziójában van ellenőrizhető feltétel. Öt kategóriát érdemes külön kezelni; az első a specifikációhoz, a második és a harmadik a C referenciához mér, a negyedik az ownership-invariánsokat vizsgálja.

Specifikációs elfogadás. A projektszerződés nyelvtanából kézzel levezetett elvárt értékek (tests/spec_examples.c) a C és a C++ változaton is teljesülnek. Ez a független orákulum: nem a referencia kimenetéből készült, ezért a referencia hibáját is felismeri. Ezt egy mutációs próba igazolja, amely szándékosan elrontja a C referenciát:

#!/bin/sh
# tests/mutation_probe.sh -- szándékosan hibás C referencia felismerése.
# A projekt gyökeréből futtatandó, a 17.2 fixture-jei után.
set -eu
tmp=$(mktemp -d) || exit 2
trap 'rm -rf "$tmp"' EXIT

# Mutáció: hat számjegyet is elfogad (a nyelvtan legfeljebb ötöt enged).
sed 's/parser->digits == 5/parser->digits == 6/' src/diag.c \
    > "$tmp/diag_bad.c"
if cmp -s src/diag.c "$tmp/diag_bad.c"; then
    echo "a mutáció nem alkalmazható" >&2
    exit 2
fi
gcc -std=c17 -O2 -Iinclude -D_POSIX_C_SOURCE=200809L \
    "$tmp/diag_bad.c" src/main.c -o "$tmp/diag_bad"
gcc -std=c17 -O2 -Iinclude \
    "$tmp/diag_bad.c" tests/spec_examples.c -o "$tmp/spec_bad"

# A hibás referenciából felvett golden a hibát rögzítené:
printf 'a=000001\n' | "$tmp/diag_bad"
# a meglévő golden pedig nem érinti a hibás ágat:
"$tmp/diag_bad" tests/golden.in | cmp -s tests/golden.out -
echo "golden: nem ismeri fel"

# A specifikációból kézzel levezetett példák felismerik:
if "$tmp/spec_bad" 2>/dev/null; then
    echo "HIBA: a specifikációs teszt nem bukott" >&2
    exit 1
fi
echo "spec_examples: felismerte a hibás referenciát"

A futás eredménye: a hibás referencia az a=000001 bemenetre OK records=1 sum=1 bytes=9 sort ad, amelyet egy belőle felvett golden fájl hibátlannak rögzítene; a meglévő golden teszt nem bukik; a spec_examples viszont bukik. A próbát GCC 13.3.0-val lefuttattuk, a leírt eredménnyel.

Funkcionális elfogadás. Minden nyelvtani ekvivalenciaosztályra és minden hibakódra létezik legalább egy teszt, amely a port előtti és a port utáni implementáción ugyanazt az eredményt adja. A teszt a hibakód mellett az offsetet és a három számlálót is ellenőrzi, nem csak a visszatérési értéket.

Golden elfogadás. A rögzített bemenetekre a kimenet bájtszinten egyezik. Ide tartozik a protokollválasz, a naplósor formátuma és a kilépési kód. A golden fájlokat a port előtt, a C implementációval kell felvenni, és verziókövetésbe tenni; a port után ezekhez nem nyúlunk.

Ownership elfogadás. Az AddressSanitizer és a LeakSanitizer hibamentes futása a teljes tesztkészleten, az allokációs hibát injektáló esettel együtt. Ez a bejárt utakon kizárja a dupla felszabadítást, a use-after-free-t és a szivárgást, de nem bizonyítja, hogy minden lehetséges úton így van; a többi utat a tulajdonlási térkép szerinti kódáttekintés fedi. Mivel a port core-ja nem tárol nézetet a hívás után, a std::span élettartamát a hívó puffer újrahasználata teszteli: a feed után a puffer felülírása nem változtathatja meg az eredményt. Ezt a C++ core tests/test_core.cpp tesztje ellenőrzi.

Benchmark elfogadás. A mérőszámokat a port előtt, ugyanazon a gépen, ugyanazzal a buildprofillal és ugyanazzal a bemeneti halmazzal kell rögzíteni. Jellemzően: átbájt/másodperc, p50 és p99 feldolgozási idő, csúcs RSS, allokációk száma. A port utáni mérés akkor fogadható el, ha a rögzített toleranciasávon belül marad; a sávot szintén a port előtt kell megadni, különben utólag bármi „elfogadható” lesz. A mérés nélküli állítás—„ez a változat gyorsabb”—nem része az elfogadásnak.

39.1.6 Gyakori hibák és javításuk

Az első tipikus hiba a nézet élettartamának elszalasztása: egy „optimalizált” port a feed hívásban kapott std::span-t tagváltozóban megőrzi, hogy a következő hívásnál onnan folytassa a félbemaradt rekordot. A C referencia szerződése szerint a bemenet csak a hívás idejére kölcsönzött; a hívó a puffert a visszatérés után újrahasználja, így a megőrzött nézet más adatra mutat. A javítás az, hogy a core csak a szükséges állapotot (fázis, névhossz, számjegyszám, részérték) tárolja, a bemenetre mutató nézetet soha.

A második a kivétel a C határon. Ha a wrapper belsejében egy std::string allokáció std::bad_alloc-ot dob, és a wrapper nem kapja el, a folyamat leáll. A javítás a try/catch a wrapperben és a hibakód leképezése, vagy az, hogy a wrapper csak nem dobó műveleteket hív.

A harmadik a hibakódok átrendezése. Ha a C++ oldalon „szebb” sorrendbe tesszük az enum értékeket, a már lefordított kliensek rossz ágat futtatnak. A javítás: az enum numerikus értékei változatlanok, az új értékek a végére kerülnek.

A negyedik a nézet túléli a mögötte lévő puffert: a std::span egy std::vector-ra mutat, majd a vektor újraallokál, és a span dangling lesz. A javítás az invalidáló művelet dokumentálása és a span élettartamának szűkítése.

Az ötödik a csendes szemantikaváltás: a C változat explicit bájtösszehasonlítással ('a'–'z', '0'–'9') ellenőriz, a C++ változatba viszont std::islower vagy std::isdigit kerül. Ezek a locale-tól függhetnek, és char argumentummal negatív értékre nem definiáltak; a nyelvtan viszont rögzített ASCII-oktetteket ír elő. A javítás az explicit összehasonlítás megtartása, és olyan korpuszelemek, amelyek nem ASCII-, nagybetűs és 0x00 oktettet is tartalmaznak.

A hatodik a mérés elhalasztása. Ha a benchmarkot csak a port után vesszük fel, nincs összehasonlítási alap, és a „nem lett lassabb” állítás igazolhatatlan. A javítás a portterv mellékleteként rögzített mérési protokoll.

39.1.7 Feladatok

1. feladat (magyarázó). Készítsd el a dg_* API megfigyelhető viselkedésének leltárát! Sorold fel a kimeneti kategóriákat, a bemenet ekvivalenciaosztályait, és minden osztályra adj egy reprezentáns bemenetet. Jelöld, mely viselkedések tartoznak a szerződéshez és melyek a szabadon változó belső tulajdonságokhoz.

2. feladat (kódolási). Írj C17 tesztet, amely a terminális állapotokat ellenőrzi: ismételt finish, finish utáni feed, valamint a ragadós hiba. Ellenőrizd a dg_code numerikus értékeit is. Fordítsd le egyszer, és linkeld a C és a C++ könyvtárhoz is.

3. feladat (diagnosztikai). Készíts golden teszt tervet és port előtti benchmark protokollt: mely bemeneteket rögzítesz, milyen metrikákat mérsz, milyen toleranciasávot fogadsz el, és hogyan bizonyítod, hogy a mérés ugyanazon a profilon történt.

39.1.8 Megoldások

1. megoldás. A kimeneti kategóriák: siker (DG_OK), DG_SYNTAX, DG_TRUNCATED, DG_TOO_LARGE és DG_STATE, a CLI-ben ezekhez a 0, 4 és 5 kilépési kód tartozik. Bemeneti ekvivalenciaosztályok és reprezentánsok: (a) üres bemenet: ""; (b) érvényes rekordok: "a=7\ncpu=12\n"; (c) érvénytelen névkarakter: "A=1\n"; (d) üres név vagy érték: "=1\n", "a=\n"; (e) 17 betűs név; (f) hat számjegy, illetve 65535 fölötti érték: "a=000001\n", "a=65536\n"; (g) CRLF és beágyazott 0x00; (h) csonka rekord: "cpu="; (i) pontosan limitméretű bemenet, illetve egy további bájt; (j) finish utáni feed. Minden osztály határértékei külön tesztesetet kapnak (például 16 és 17 betű, 65535 és 65536). A szerződés része a kód, az offset, a három számláló és a CLI-kimenet; szabadon változó a belső állapot elrendezése, a segédfüggvények neve és a fázisok ábrázolása. Ellenőrzési feltétel: minden osztályhoz és határhoz tartozik legalább egy teszteset.

2. megoldás.

/* tests/test_terminal.c -- terminális állapotok mindkét könyvtáron */
#include "diag.h"

#include <stdio.h>
#include <stdlib.h>

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

int main(void)
{
    static const unsigned char ok[] = "a=1\n";
    static const unsigned char bad[] = "a=x\n";

    CHECK(DG_OK == 0 && DG_SYNTAX == 1 && DG_TRUNCATED == 2 &&
          DG_TOO_LARGE == 3 && DG_STATE == 4);

    dg_parser *p = dg_create(NULL);
    CHECK(p != NULL);
    CHECK(dg_feed(p, (dg_view){ ok, 4 }));
    CHECK(dg_finish(p) && dg_finish(p));        /* ismételt finish */
    CHECK(!dg_feed(p, (dg_view){ ok, 4 }));     /* finish után */
    CHECK(dg_get_error(p).code == DG_STATE);
    CHECK(dg_get_error(p).offset == 4);
    dg_destroy(p);

    p = dg_create(NULL);
    CHECK(p != NULL);
    CHECK(!dg_feed(p, (dg_view){ bad, 4 }));
    CHECK(!dg_feed(p, (dg_view){ ok, 4 }));     /* ragadós hiba */
    CHECK(!dg_finish(p));
    CHECK(dg_get_error(p).code == DG_SYNTAX);
    CHECK(dg_get_error(p).offset == 2);
    dg_destroy(p);
    return 0;
}

A tesztet egyszer, C fordítóval kell objectté fordítani, majd egyszer a build/gcc/libdiag.a, egyszer a build/cxx/libdiagxx.a könyvtárral linkelni (utóbbit g++ driverrel). A finish utáni feed offsetje 4, mert addig 4 bájt fogyott el; a ragadós hiba offsetje 2, az x helye. Ellenőrzési feltétel: mindkét linkelt program 0 kóddal lép ki. A tesztet GCC 13.3.0-val mindkét könyvtárra lefuttattuk, sikerrel.

3. megoldás. A golden készlet az 1. megoldás ekvivalenciaosztályainak és határainak reprezentánsait tartalmazza, mindegyikhez a C referenciával felvett, rögzített stdout-, stderr- és kilépésikód-értékkel. A benchmark protokoll rögzíti a gépet, a fordítót, a -O2 profilt, a bemeneti fájlt, az ismétlésszámot és a mért metrikákat (átbájt/másodperc, p50, p99, csúcs RSS, allokációk száma). A toleranciasávot a port előtt kell megadni, például „a p99 legfeljebb 10%-kal nőhet, az átbájt/másodperc legfeljebb 5%-kal csökkenhet”. Az ellenőrzési feltétel: a port utáni mérés ugyanazon a profilon, ugyanazzal a bemenettel történik, és az eredmény a sávon belül van; ha nem, a porttervnek indokolnia kell az eltérést, nem pedig utólag módosítania a sávot.

39.1.9 Összefoglaló

A portterv a viselkedési szerződés írásos rögzítése: mi az, ami a port után változatlan, és mi az, ami szabadon változhat. A C cleanup ágak RAII ownerré alakulnak, a (pointer, hossz) párok std::string_view és std::span nézetekké, a kézi tagged union std::variant-tá, a hibakódok pedig rétegenkénti hibapolicy-vá. A core library és a platform adapter szétválasztása tesztelhetővé teszi a viselkedés nagy részét, a C ABI wrapper pedig megőrzi a stabil határt. Az elfogadás négy lába a funkcionális, a golden, az ownership és a benchmark feltétel; a mérést a port előtt kell rögzíteni, különben nincs összehasonlítási alap. A következő alfejezet erre a tervre építve magát a core implementációt és az értéktípusokat dolgozza ki.