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.
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.
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 |
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 erőforrást szerez sorban, és minden lépés után hibázhat. A naiv kézi kódban az -edik hibaponton erőforrást kell felszabadítani, így a forrásban összesen
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 , é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.
span és string_viewA 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ő.
variantA 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.
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:
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.errno, EAGAIN, EPIPE)
std::error_code-dé alakítja, és a core felé a core
hibapolicy-ját használja.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.
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ó.
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.
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 |
| idő, 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ő:
diag::parser C++ osztályba kerül; a
fázis enum class, a bemenet
std::span<const unsigned char>.dg_report és a dg_error megfelelője a
diag::report és a diag::error értéktípus.dg_* C ABI-t egy extern "C" wrapper
szolgáltatja a C++ core fölött. Az include/diag.h nem
változik.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 diagA 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_examplesA 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 $failA 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 corpusBuildjegyző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á.
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.
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.
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.
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.
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.