5.3 Inicializálás, indeterminate érték és reprezentáció

Az „inicializáljuk nullára” kifejezés C-ben legalább három különböző műveletet takarhat: egy számnak nulla értéket adunk, egy pointert null pointer értékre állítunk, vagy egy tárolási terület minden bitjét nullázzuk. Ezek gyakori platformokon hasonló gépi műveletekre vezethetnek, de a nyelvi szerződésük nem azonos.

A tárolási időről és élettartamról tanultak alapján az objektum létezése és értékének használhatósága külön kérdés. Egy objektum élettartama már elkezdődhetett úgy, hogy az értékét még nem szabad kiolvasni. Az egész típusok és adatmodellek pedig arra emlékeztetnek, hogy a típus neve, értékkészlete és reprezentációja nem ugyanaz.

Ebben az egységben az ISO C17 szabályait használjuk. A C bájt a sizeof egysége; bitszáma CHAR_BIT, nem általánosan nyolc.

5.3.1 Inicializálás a tárolási idő függvényében

Az inicializálás (initialization) az objektum kezdeti értékének megadása. Nem azonos a későbbi értékadással, még akkor sem, ha mindkettőben szerepelhet az = jel.

Az alábbi C17-kódrészletben:

void example(void)
{
    int a;       /* Nincs explicit inicializáló. */
    int b = 0;   /* Inicializálás. */
    a = 7;       /* Értékadás a már létező objektumnak. */
}

Az a deklarációja önmagában nem hiba. Akkor keletkezik probléma, ha a program megfelelő értékadás előtt használja az értékét.

5.3.1.1 Automatikus, statikus és szálhoz kötött objektumok

ISO C17: az explicit inicializáló nélküli automatikus tárolási idejű objektum értéke indeterminate, azaz meghatározatlan. Ezzel szemben a statikus és szálhoz kötött tárolási idejű objektumok inicializáló nélkül is a típusuknak megfelelő kezdeti értéket kapják.

Objektum Inicializáló nélküli kezdeti állapot
Automatikus tárolási idejű objektum Meghatározatlan érték
Statikus tárolási idejű aritmetikai objektum Pozitív vagy előjel nélküli nulla
Statikus tárolási idejű pointer Null pointer érték
Statikus tárolási idejű aggregátum Tagjai, elemei rekurzívan inicializálódnak
Szálhoz kötött objektum Ugyanezek a típus szerinti szabályok, szálanként külön objektumra

A statikus tárolási idő nem jelent szükségképpen fájlszintű nevet. A függvényblokkban deklarált static int count; is nulla értékkel indul, és nem inicializálódik újra minden függvényhíváskor. A _Thread_local objektumból minden szálnak saját példánya van.

C17-részlet:

static int process_count;                  /* Kezdetben 0. */
static _Thread_local int thread_count;    /* Szálanként kezdetben 0. */

void work(void)
{
    static int calls;   /* Kezdetben 0, nem hívásonként új objektum. */
    int temporary;      /* Meghatározatlan érték. */

    temporary = 12;     /* Ettől kezdve az értéke használható. */
}

A hatókörből tehát nem következik az inicializálási szabály: a calls és a temporary neve egyaránt blokkhatókörű, tárolási idejük mégis eltér.

5.3.1.2 Részleges aggregátuminicializálás

Tömbnél és struktúránál a meg nem adott elemek, illetve tagok úgy inicializálódnak, mint a statikus tárolási idejű objektumok.

C17-részlet:

struct Options {
    unsigned retries;
    double delay;
    void *context;
};

void configure(void)
{
    struct Options options = {0};
    unsigned limits[4] = {3};
}

Az options.retries értéke nulla, az options.delay értéke lebegőpontos pozitív nulla, az options.context értéke null pointer. A limits elemei rendre 3, 0, 0, 0.

A {0} nem azt mondja, hogy „írj nulla bájtokat az egész objektumba”. Az első tag explicit inicializálót kap, a többi tagra pedig a hiányzó inicializálók nyelvi szabálya érvényesül. Az üres {} inicializáló nem szabványos C17-megoldás.

5.3.2 Meghatározatlan érték és trap reprezentáció

ISO C17: az indeterminate value két lehetőség valamelyike:

A trap reprezentáció olyan objektumreprezentáció, amely nem képvisel érvényes értéket az adott típusban. A „trap” elnevezés nem garantál processzorkivételt vagy azonnali programleállást. Ha egy ilyen reprezentációjú objektum tárolt értékét nem karaktertípusú lvalue-val olvassuk ki, a viselkedés nem definiált.

A C17 fogalomhasználatában az indeterminate elsősorban az érték minősítése. Nem helyes minden inicializálatlan objektumot úgy leírni, hogy „véletlen biteket tartalmaz, amelyek egy véletlen, de használható értéket jelentenek”. Más szabványverziók vagy a C++ eltérő terminológiáját sem szabad automatikusan rávetíteni.

5.3.2.1 Egy egyértelmű UB-példa

Az alábbi hibás C17-függvényrészlet kizárólag elemzésre szolgál:

int retry_limit(void)
{
    int limit;
    return limit;   /* Nem definiált viselkedés. */
}

A limit automatikus tárolási idejű, nincs inicializálva, a címét nem vettük, és az értékét olvassuk. Erre az esetre a C17 szabálya nem definiált viselkedést mond ki; nem kell hozzá trap reprezentáció létezését feltételezni.

Nem ígérhető, hogy a függvény nullát, „stacken maradt adatot” vagy minden futáskor más számot ad vissza. A fordító optimalizáláskor abból indulhat ki, hogy a helyes program nem hajt végre ilyen műveletet.

A javítás:

/* Helyes C17-függvényrészlet. */
int retry_limit(void)
{
    int limit = 0;
    return limit;
}

Fontos különbség, hogy a nem VLA típusú limit objektumra alkalmazott sizeof limit nem olvassa annak értékét. Az inicializálatlan objektum puszta létezése tehát nem UB.

A karaktertípusú reprezentáció-hozzáférésnek külön szabályai vannak. Ebből azonban nem következik, hogy az inicializálatlan adat általánosan biztonságosan felhasználható. A helyes program szerződése egyszerűbb: minden értékolvasást előzzön meg megfelelő inicializálás vagy értékadás.

5.3.3 Nulla érték és csupa nulla bit

ISO C17: a null pointer olyan pointerérték, amely nem mutat objektumra vagy függvényre. Egy nulla értékű egész konstanskifejezés pointertípusra konvertálva null pointert eredményez. C-ben az ilyen kifejezés void *-ra konvertált alakja is null pointer konstans.

C17-részlet:

int *a = 0;
int *b = (void *)0;

Mindkét pointer null pointer értékű. A fordító feladata az adott implementáció megfelelő reprezentációjának előállítása. Ebből nem következik, hogy ez a reprezentáció csupa nulla bit.

Művelet Hordozható jelentés C17-ben
int n = 0; Egész nulla érték
double x = 0; Lebegőpontos pozitív nulla
int *p = 0; Null pointer érték
struct Options o = {0}; Típus szerinti taginicializálás
memset(&o, 0, sizeof o) Minden C bájt nulla értékűre írása

Az egész típusoknál a csupa nulla bit reprezentáció a nulla értéket képviseli. Ugyanez nem általános garancia pointerekre és lebegőpontos típusokra.

5.3.3.1 Mit végez a memset?

A memset a megadott értéket unsigned char típusra konvertálja, majd ennek megfelelő bájtokkal tölti fel a kért tartományt.

A következő C17-részlet helyes bájtpuffer-nullázás; a <string.h> fejléc szükséges hozzá:

unsigned char buffer[64];
memset(buffer, 0, sizeof buffer);

A tároló a buffer tömb része, élettartamát a befoglaló blokk szabályozza. A függvény csak a hívás idejére kap kölcsönzött hozzáférést; a megadott tartomány teljes egészében írható. Hibakimenet nincs a szerződés teljesülésekor. Érvénytelen cím vagy túl nagy hossz esetén nem definiált viselkedés következhet.

Ezzel szemben a pointertagot tartalmazó struktúra memset-es nullázása önmagában nem hordozható pointerinicializálás. Az így létrehozott pointerreprezentáció akár érvénytelen is lehet; a későbbi tagolvasás ezért nem feltétlenül megengedett.

5.3.3.2 Mit garantál a calloc?

A calloc siker esetén olyan allokált tárolót ad, amelynek minden bitje nulla. Nem garantálja, hogy az abban később értelmezett pointerek null pointerek, vagy a lebegőpontos objektumok nulla értékűek.

Egy kívánt típus szerinti kezdeti állapotot explicit értékadással adhatunk meg:

/* C17-részlet: p már ellenőrzött, megfelelő méretű
   és igazítású allokációra mutat. */
*p = (struct Options){0};

Itt a compound literal tagjai nyelvi szabályok szerint inicializálódnak, majd a struktúraértéket a célobjektumba másoljuk. Ha úgyis minden tagot beállítunk, a megelőző bájtnullázás gyakran szükségtelen; az allokáció részletes szerződését a dinamikus memóriáról szóló egység tárgyalja.

5.3.4 Padding és a reprezentáció összehasonlítása

Az objektumreprezentáció az objektum tárolását alkotó bájtok összessége. Struktúrában a tagok között vagy az utolsó tag után kitöltés, azaz padding lehet. Ennek mérete és elhelyezkedése implementációfüggő; itt nem feltételezünk konkrét ABI-layoutot.

Két objektum értékazonossága nem jelenti feltétlenül bájtazonosságukat. A tagok értékei egyezhetnek, miközben a padding bájtok eltérnek. Egyes típusoknál az érték és a reprezentáció kapcsolata önmagában sem egy-egyértelmű.

A memcmp a megadott bájtsorozatokat hasonlítja össze, unsigned char értékekként. Ezért általában nem alkalmas struktúrák szemantikai egyenlőségének eldöntésére.

Bizonyításvázlat: legyen két struktúrában minden tag értéke azonos. Ha van olyan padding bájt, amelynek értéke eltérhet a két objektumban, akkor a memcmp eltérést találhat, noha a program számára jelentéssel bíró tagok megegyeznek. Az értékegyenlőségből tehát nem következik reprezentációegyenlőség.

ISO C17: az explicit inicializáló nélküli statikus vagy szálhoz kötött aggregátum kezdeti paddingje nulla bitekre inicializálódik. Ez nem általános garancia minden inicializálási módra. Ráadásul struktúrába vagy annak tagjába történő későbbi tároláskor a padding bájtok nem specifikált értékeket vehetnek fel. A „kezdetben mindent nulláztam” ezért nem tartós reprezentációs invariáns.

Üzemeltetési következményként nyers struktúrabájtokat naplózni, hálózatra küldeni vagy hash-elni nemcsak hordozhatósági gond: paddingen keresztül nem kívánt tárolótartalom is megjelenhet. A protokollmezőket külön kell kódolni.

5.3.5 Kidolgozott példa: értékazonosság és bájtazonosság

Az alábbi init.c teljes, hosted C17-program. A választott, helyben ellenőrizendő laborprofil: Linux, x86-64 System V ABI, LP64, glibc 2.40; GCC 14.2.0 vagy Clang 19.1.7. A program nem támaszkodik konkrét struktúraméretre. A példa fordítása és futtatása itt nem történt meg.

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

struct State {
    unsigned char ready;
    unsigned count;
    void *context;
};

static struct State process_state;
static _Thread_local struct State thread_state;

static int state_equal(struct State a, struct State b)
{
    return a.ready == b.ready
        && a.count == b.count
        && a.context == b.context;
}

int main(void)
{
    struct State local = {0};
    struct State *heap = calloc(1, sizeof *heap);

    if (heap == NULL) {
        fputs("allocation failed\n", stderr);
        return EXIT_FAILURE;
    }

    /* A calloc bájtnullázása helyett erre alapozzuk
       a tagok típus szerinti kezdeti értékét. */
    *heap = (struct State){0};

    int values_equal =
        state_equal(local, process_state)
        && state_equal(local, thread_state)
        && state_equal(local, *heap);

    int bytes_equal =
        memcmp(&local, &process_state, sizeof local) == 0;

    printf("values_equal=%d\n", values_equal);
    printf("bytes_equal=%d\n", bytes_equal);

    free(heap);
    return EXIT_SUCCESS;
}

Fordítás a választott kiadásoknak megfelelő parancsokkal:

gcc -std=c17 -O2 -Wall -Wextra -Wpedantic init.c -o init-gcc
clang -std=c17 -O2 -Wall -Wextra -Wpedantic init.c -o init-clang
Objektum vagy hozzáférés Élettartam és tulajdonlás
local Automatikus objektum; a main blokkjából kilépéskor élettartama véget ér
process_state Statikus tárolási idejű objektum
thread_state A végrehajtó szál saját, szálhoz kötött objektuma
heap pointerobjektum Automatikus objektum; az allokáció felszabadításának felelősségét hordozza
Allokált tároló A sikeres calloc és a free között él
memcmp argumentumai Csak a hívás idejére kölcsönzött hozzáférések; nincs tulajdonátadás

Sikeres allokáció esetén a values_equal számított értéke 1: minden összehasonlított tag azonos értéket kapott. A bytes_equal értékére nincs ugyanez a hordozható ígéret. Ez nem UB-demonstráció, hanem a kétféle egyenlőség szétválasztása.

A free érvényteleníti az allokált objektumra mutató hozzáféréseket. A program utána nem olvassa a heap értékét és nem dereferálja azt. Az allokációs hibautat ellenőrzi; a szabványos kimenet írási hibájának részletes kezelését a példa nem tárgyalja.

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

5.3.7 Feladatok

5.3.7.1 Magyarázó feladat: kezdeti állapotok

Egy függvényben szerepel az int a;, a static int b; és az int *p = 0; deklaráció. Mely értékek olvashatók azonnal? Mit tudunk p bitmintájáról?

5.3.7.2 Kódolási feladat: hordozható alapállapot

Írj C17-függvényt, amely egy struct Options értéket ad vissza nulla próbálkozásszámmal, nulla késleltetéssel és null pointer környezettel. Ne használj bájtnullázást.

5.3.7.3 Diagnosztikai feladat: hibás teszt

Egy teszt két struct State objektumot {0} inicializálóval hoz létre, majd megköveteli, hogy a memcmp eredménye nulla legyen. Érvényes ez a követelmény? Mi legyen a javított ellenőrzés?

5.3.7.4 Haladó feladat: hibás javítás

Valaki az inicializálatlan int limit; olvasását úgy „javítja”, hogy a tesztet csak optimalizálás nélkül fordítja. Miért elégtelen ez? Adj valódi javítást és ellenőrzési feltételt.

5.3.8 Megoldások

5.3.8.1 Kezdeti állapotok

A b értéke nulla, a p értéke null pointer; mindkettő olvasható. Az a megfelelő értékadás nélküli kiolvasása ebben a helyzetben nem megengedett. A p reprezentációjáról nem következik, hogy csupa nulla bit.

Ellenőrzési feltétel: a magyarázat külön kezelje a tárolási időt, az értéket és a reprezentációt.

5.3.8.2 Hordozható alapállapot

C17-részlet a korábban megadott struct Options definíciójával:

struct Options default_options(void)
{
    struct Options result = {0};
    return result;
}

A függvény értéket ad vissza, nem a lokális objektum címét.

Ellenőrzési feltétel: a visszaadott tagokra retries == 0, delay == 0.0 és context == 0 teljesül. A padding tartalma nem része a feltételnek.

5.3.8.3 A hibás teszt javítása

A bájtazonosság követelménye nem hordozható. A padding és a reprezentáció lehetséges eltérései miatt két értékazonos objektum bájtjai különbözhetnek.

A javított ellenőrzés a példabeli state_equal(a, b) eredményét követeli igaznak.

Ellenőrzési feltétel: a teszt kizárólag a szerződésben jelentéssel bíró tagokra épüljön.

5.3.8.4 Valódi javítás optimalizálási kapcsoló helyett

Az optimalizálás kikapcsolása nem változtatja meg a C17 szabályait. A javítás megfelelő inicializálás vagy minden olvasáshoz vezető vezérlési úton végrehajtott értékadás.

Ellenőrzési feltétel: minden értékolvasást megelőz egy megfelelő értékadás. Ezt vezérlésifolyam-elemzéssel kell indokolni; néhány sikeres futás önmagában nem bizonyíték.

5.3.9 Összefoglaló

Az objektum élettartama, kezdeti értéke és reprezentációja külön fogalom. Az automatikus objektum nem kap általános implicit nullainicializálást; a statikus és szálhoz kötött objektum igen, mégpedig típus szerinti értékekkel. A memset és a calloc bájtokat nulláz, nem általánosan szemantikai értékeket. A padding miatt az értékazonosság nem bájtazonosság, az inicializálatlan érték hibás olvasásához pedig nem rendelhető stabil, megjósolható kimenet.