9.3 Union, tagged union, enum és bit-field

Egy mérési esemény tartalmazhat számlálót, terhelési arányt vagy hibainformációt. Ha egyszerre pontosan egy változat értelmes, a három külön mező helyett közös tárolót használhatunk. A megtakarítás azonban csak akkor biztonságos, ha azt is egyértelműen nyilvántartjuk, melyik változatot tároltuk.

A C-ben ezt rendszerint egy union és egy külön változatazonosító együttese, a címkézett unió (tagged union, más néven discriminated union) valósítja meg. A kapcsolatot a nyelv nem ellenőrzi automatikusan: az invariáns a program szerződésének része.

Az alábbiak ISO C17 szabályokra épülnek. A Padding, alignment és offsetof fejezetből a méret–igazítás kapcsolatát, az Effective type, aliasing és objektumreprezentáció részből az érték és reprezentáció különbségét használjuk.

9.3.1 Union: különböző tagok közös tárolón

A struct tagjai külön, deklarációs sorrendjüknek megfelelő tárolási területet kapnak. A union tagjainak tárolója ezzel szemben átfed: minden tag ugyanott kezdődik.

C17 deklarációs részlet:

union measurement {
    unsigned long count;
    double load;
};

A union measurement mérete elegendő bármelyik tag tárolásához, igazítása pedig lehetővé teszi bármelyik tag megfelelő elhelyezését. Mérete legalább a legnagyobb tag mérete, de kitöltés miatt annál nagyobb is lehet:

sizeof(U)≥maxisizeof(Mi). \operatorname{sizeof}(U) \geq \max_i \operatorname{sizeof}(M_i).

Itt UU az uniótípus, MiM_i annak valamely tagtípusa; a méretek C bájtban értendők. A union mérete tehát nem a tagméretek összege.

A tagválasztást az inicializálás vagy a tagba történő értékadás határozza meg:

/* C17, önálló programba illeszthető részlet. */
union measurement m = { .count = 120UL };
m.load = 0.75;

A második utasítás után a load tag értékét tároljuk. Nem két független változónk van: a korábbi count nem marad meg külön.

Az aktív tag kifejezést itt a legutóbb eltárolt változat rövid neveként használjuk. Ez nem jelenti a C++ objektumélettartam-szabályainak átvételét. C17-ben másik tag olvasásának saját reprezentációs szabályai vannak, amelyeket később külön tárgyalunk.

Fontos következmény: a union önmagában nem tartalmaz futásidejű információt arról, melyik tagba írtunk. A debugger sem feltétlenül tudja ezt kikövetkeztetni.

9.3.2 Enum mint változatazonosító

A felsorolási típus, az enum, nevesített egész konstansokat kapcsol össze:

enum sample_kind {
    SAMPLE_COUNT,
    SAMPLE_LOAD,
    SAMPLE_FAILURE
};

ISO C17: explicit értékadás nélkül az első felsorolási konstans értéke nulla, a következőké eggyel nagyobb. A felsorolási konstansok típusa int, értéküknek pedig int-tel ábrázolhatónak kell lenniük.

Ettől külön kérdés magának az enum sample_kind típusnak a reprezentációja. Egy char, előjeles vagy előjel nélküli egész típussal kompatibilis; a megfelelő típus kiválasztása implementáció által meghatározott. Nem következik tehát, hogy minden enum mérete négy C bájt.

Az enum ráadásul nem zárt, automatikusan ellenőrzött értékhalmaz. Egy enumobjektum tárolhat a nevesített konstansoktól eltérő, a kompatibilis típussal ábrázolható értéket is. Emiatt a switch default ága hasznos védelmi határ.

Ez azonban nem teszi biztonságossá inicializálatlan vagy érvénytelen reprezentációjú objektum olvasását. A default csak olyan értéket kezelhet, amelynek kiértékelése már önmagában is érvényes.

A C23 rögzített alaptípusú enumlehetőségeit itt nem használjuk; azok nem részei a C17 szerződésnek.

9.3.3 Címkézett értéktípus és invariánsai

A címke és a tároló közötti kapcsolatot minden változatra külön rögzítsük:

Címke Tárolt uniontag Invariáns
SAMPLE_COUNT count Bármely unsigned long érték elfogadható
SAMPLE_LOAD load 0≤load≤10 \leq load \leq 1; NaN és végtelen nem megengedett
SAMPLE_FAILURE failure code > 0; retryable logikai érték

Az invariáns része az is, hogy a címke valóban az eltárolt uniontagot azonosítja. A címke átírása önmagában nem konvertálja az adatot.

9.3.3.1 Teljes C17 példa

A típus csak beágyazott értékeket tartalmaz: nincs dinamikus memória, erőforrásátadás vagy kölcsönzött sztring. Ezért a struktúra értékmásolása teljes, önálló másolatot eredményez.

A következő kód sample.c néven fordítható. Minimumkövetelménye hosted ISO C17. Javasolt, de itt nem fordítással ellenőrzött laborprofil: GCC 14.2.0 vagy Clang 19.1.7, Linux x86-64 System V ABI, LP64, glibc 2.40, alapértelmezett adattípus-elrendezéssel.

#include <stdbool.h>
#include <stdio.h>

enum sample_kind {
    SAMPLE_COUNT,
    SAMPLE_LOAD,
    SAMPLE_FAILURE
};

struct failure_info {
    int code;
    bool retryable;
};

union sample_payload {
    unsigned long count;
    double load;
    struct failure_info failure;
};

struct sample {
    enum sample_kind kind;
    union sample_payload data;
};

static struct sample sample_count(unsigned long count)
{
    return (struct sample) {
        .kind = SAMPLE_COUNT,
        .data = { .count = count }
    };
}

static bool sample_load(double load, struct sample *out)
{
    if (out == NULL || !(load >= 0.0 && load <= 1.0)) {
        return false;
    }

    *out = (struct sample) {
        .kind = SAMPLE_LOAD,
        .data = { .load = load }
    };
    return true;
}

static bool sample_failure(int code, bool retryable,
                           struct sample *out)
{
    if (out == NULL || code <= 0) {
        return false;
    }

    *out = (struct sample) {
        .kind = SAMPLE_FAILURE,
        .data = {
            .failure = {
                .code = code,
                .retryable = retryable
            }
        }
    };
    return true;
}

static bool sample_print(struct sample s)
{
    switch (s.kind) {
    case SAMPLE_COUNT:
        return printf("count=%lu\n", s.data.count) >= 0;

    case SAMPLE_LOAD:
        return printf("load=%.2f\n", s.data.load) >= 0;

    case SAMPLE_FAILURE:
        return printf("failure=%d retryable=%d\n",
                      s.data.failure.code,
                      s.data.failure.retryable) >= 0;

    default:
        return false;
    }
}

int main(void)
{
    struct sample a = sample_count(42UL);
    struct sample b = sample_count(0UL);
    struct sample c = sample_count(0UL);

    if (!sample_load(0.75, &b) ||
        !sample_failure(5, true, &c)) {
        return 1;
    }

    if (!sample_print(a) ||
        !sample_print(b) ||
        !sample_print(c)) {
        return 1;
    }

    return 0;
}

Fordítási parancsok a megfelelő kiadáshoz rendelt fordítóparancsokkal:

gcc-14 -std=c17 -Wall -Wextra -Wpedantic -O2 -fno-fast-math sample.c -o sample-gcc
clang-19 -std=c17 -Wall -Wextra -Wpedantic -O2 -fno-fast-math sample.c -o sample-clang

A parancsnevek önmagukban nem garantálják a pontos javítóverziót; azt a laborban külön rögzíteni kell. A példához nem tartozik mért futási eredmény.

9.3.3.2 Élettartam és hibaszerződés

Szereplő Tulajdonlás és élettartam Módosítás vagy hibakimenet
a, b, c A main helyi objektumai; saját beágyazott tárolóval Élettartamuk a blokk elhagyásáig tart
out Nem tulajdonló hozzáférés a hívó írható struct sample objektumához Nem marad meg a hívás után
Sikeres konstrukció A célobjektum teljes értékét lecseréli A korábbi változat szerinti értelmezés többé nem használható
Sikertelen konstrukció Nem ír a célobjektumba false; a cél korábbi értéke változatlan
sample_print Értékmásolatot kap Ismeretlen címkénél vagy azonnal jelzett kimeneti hibánál false

Nem null out esetén előfeltétel, hogy ténylegesen élő, megfelelően igazított, írható struct sample objektumra mutasson. A nullvizsgálat dangling pointert nem ismer fel.

Invariánsmegőrzési bizonyításvázlat. A konstruktor először ellenőrzi a bemenetet. Hibánál nem történik értékadás. Sikernél az összetett literál egyszerre írja le a helyes címkét és az ahhoz tartozó tagot, majd a struktúraértékadás ezt az értéket másolja a célba. A load feltétel a NaN-t is elutasítja, mert az összehasonlítások nem lesznek igazak. Ez a bizonyítás szabványos lebegőpontos szemantikát feltételez; ezért nem használunk fast-math módot.

Az „egyszerre” itt forráskódbeli értékkonstrukciót jelent, nem szálak közötti atomikusságot.

9.3.3.3 A közös tároló számszerű következménye

A fenti x86-64 System V LP64 laborprofilhoz, rövidített enum és packing nélkül, a következő elrendezéssel számolunk. Feltételezett méret/igazítás párok: enum és int 4/4, unsigned long és double 8/8, _Bool 1/1.

Elem Offset a közvetlen befoglalóban Méret Igazítás
failure_info.code 0 4 4
failure_info.retryable 4 1 1
struct failure_info — 8 4
sample_payload bármely tagja 0 8 tagfüggő
union sample_payload — 8 8
sample.kind 0 4 4
sample.data 8 8 8
struct sample — 16 8

Minden adat C bájtban értendő. A táblázat profilhoz kötött levezetés, nem mérési jegyzőkönyv és nem általános C-garancia. A helyi ellenőrzés eszköze a sizeof, _Alignof és offsetof, az előző részben tanult módon.

9.3.4 Union type punning: reprezentáció, nem konverzió

ISO C17: ha nem a legutóbb eltárolt tagot olvassuk, a megfelelő objektumreprezentációt az olvasott tag típusának reprezentációjaként értelmezzük. Ezt nevezik unionon keresztüli type punningnak.

Szándékosan nem hordozható, elemzésre szolgáló C17 részlet:

union bits {
    unsigned int integer;
    float real;
};

union bits b = { .integer = 1u };
float x = b.real;  /* Nem azonos ezzel: (float)b.integer */

Ez nem numerikus egész–lebegőpontos konverzió. Az eredmény az ábrázolásoktól, méretektől és bájtsorrendtől függ. Ha az új tag nagyobb, a korábbi tárolás által nem meghatározott bájtok is érintettek lehetnek. Az előálló reprezentáció csapdareprezentáció (trap representation) is lehet; ilyen érték olvasása nem definiált viselkedést okozhat.

Két gyakori szélsőséges állítás ezért egyaránt hibás:

Az azonos sizeof sem bizonyítja a reprezentációk kompatibilitását. A memcpy segíthet elkerülni bizonyos aliasing- és igazítási hibákat, de önmagában nem garantál érvényes célreprezentációt. Ezeket a C-szabályokat C++-ra sem szabad változtatás nélkül átvinni.

Külön C17-garancia vonatkozik a közös kezdősorozatra: ha egy union struktúratagjai megfelelő kezdeti tagjaikban kompatibilis típusúak — bitmezőknél azonos szélességgel —, a közös kezdet vizsgálható a másik struktúratagon keresztül is, ahol a teljes uniontípus deklarációja látható. Ez szűk kivétel, nem általános engedély a struktúrák tetszőleges részének átértelmezésére.

Címkézett értéktípusnál egyszerűbb szabályt alkalmazunk: mindig a címkéhez tartozó tagot olvassuk.

9.3.5 Bitmezők: logikai szélesség, nem wire layout

A bitmező (bit-field) egész típusú struktúra- vagy uniontag, amelyhez bitszélességet adunk meg. Folyamaton belüli jelzőkhöz kényelmes lehet:

/* C17 deklarációs részlet; a fizikai elrendezés ABI-függő. */
struct local_flags {
    unsigned int ready : 1;
    unsigned int mode : 3;
    unsigned int : 2;
    unsigned int : 0;
    unsigned int retries : 4;
    signed int delta : 4;
    _Bool enabled : 1;
};

ISO C17: a szélesség nemnegatív egész konstanskifejezés; nem haladhatja meg az alapul szolgáló típusban rendelkezésre álló bitszámot. A biztosan használható típusok _Bool, signed int és unsigned int; más típusok megengedése implementáció által meghatározott. A _Bool bitmező legfeljebb egy bit széles lehet.

Az egyszerű int bitmező előjelessége implementáció által meghatározott, ezért azt érdemes explicit megadni. Egy ww bites unsigned int bitmező értéktartománya:

0≤x≤2w−1. 0 \leq x \leq 2^w-1.

Így a mode tartománya 0–7. A signed int delta : 4 pontos negatív tartományánál C17-ben a signed reprezentációt is figyelembe kell venni; a −8–7 tartomány kettes komplemens profilt feltételez.

A névtelen, nem nulla szélességű mező elérhetetlen kitöltési helyet foglal. A nulla szélességű mező csak névtelen lehet: megtiltja, hogy további bitmező kerüljön az előző bitmezőt tartalmazó allokációs egységbe. Nem jelent hordozható „következő négybájtos címre igazítást”.

A fizikai elrendezés több külön döntést tartalmaz:

Tulajdonság C17 szerinti korlát
Magas vagy alacsony helyiérték felől történő kiosztás Implementáció által meghatározott
Nem elegendő hely esetén egységhatár átlépése Implementáció által meghatározott
Az allokációs egység igazítása Nem specifikált
Konkrét elrendezés fordítók közötti egyezése Az ABI és fordítási opciók szerződésétől függ

A bitmezőnek nem képezhető címe, és közvetlenül sizeof sem alkalmazható rá. Bitmezőre hordozható offsetof-alapú offsetet sem kaphatunk.

Ezért a bitmezős struktúra nem hordozható hálózati formátum. A bitkiosztás nem vezethető le pusztán a processzor bájtsorrendjéből. Wire protokollhoz explicit oktettsorrend, maszkok és eltolások szükségesek; ezt a Byte order és biztonságos szerializálás rész folytatja.

9.3.6 Gyakori hibák

A címke átírása uniontag-értékadás nélkül csak inkonzisztens objektumot hoz létre. Javítása a teljes új változat konstrukciója.

A default ág nem általános unionvalidátor. Felismerhet ismeretlen címkét, de nem bizonyítja, hogy egy ismert címke mellett valóban a megfelelő tagot tárolták.

A „nálam két bájt” megfigyelés nem bitmező-protokollszerződés. Más ABI, packing vagy fordítóopció megváltoztathatja az elrendezést.

Végül az értékmásolás csak a jelenlegi, beágyazott adatokat tartalmazó típusnál jelent teljesen független értéket. Pointertag hozzáadása után külön tulajdonlási szerződésre lenne szükség.

9.3.7 Feladatok

9.3.7.1 Magyarázó feladat

Egy SAMPLE_COUNT érték címkéjét SAMPLE_LOAD-ra írjuk, a unionhoz nem nyúlunk. Megőrzi-e ez az invariánst? Megjavítaná-e egy default ág?

9.3.7.2 Kódolási feladat

Írj sample_is_retryable(struct sample s) függvényt. Csak újrapróbálható hibaváltozat esetén adjon igaz értéket, és más változatnál ne olvassa a failure tagot.

9.3.7.3 Diagnosztikai feladat

Egy protokoll két oktettjéből az első felső három bitje típusazonosító, a fennmaradó tizenhárom bit pedig hossz, magas helyiértékű oktettel előre. A bemenet A0 01.

Miért nem megfelelő dekóder egy három- és tizenhárom bites unsigned int bitmezőt tartalmazó struktúra? Add meg a helyes mezőértékeket explicit bitműveletekkel.

9.3.8 Megoldások

9.3.8.1 Magyarázó feladat megoldása

Az invariáns sérül: a címke és a legutóbb eltárolt tag eltér. A default nem segít, mert a címke ismert. A sample_load konstruktor használata helyreállítja a kapcsolatot, és az értéktartományt is ellenőrzi.

Ellenőrzési feltétel: minden változatváltás után együtt legyen helyes a címke, a választott tag és annak tartalmi invariánsa.

9.3.8.2 Kódolási feladat megoldása

C17 részlet, a teljes példa típusdeklarációi után:

static bool sample_is_retryable(struct sample s)
{
    return s.kind == SAMPLE_FAILURE && s.data.failure.retryable;
}

A && rövidzáras: a jobb oldalt csak igaz bal oldal esetén értékeli ki. Előfeltétel a típus invariánsát teljesítő bemenet.

Ellenőrzési feltétel: számlálóra és terhelésre hamis; hibára a retryable értékével egyező eredmény.

9.3.8.3 Diagnosztikai feladat megoldása

A bitmezők sorrendje, allokációs egysége és kitöltése nem a protokoll szabályait követi. Még a struktúra kétoktettes mérete sem garantált.

Nyolcbites oktettprofilban, helyi tömbbel:

/* C17, CHAR_BIT == 8 előfeltétellel; elemzési részlet. */
unsigned char bytes[2] = { 0xA0u, 0x01u };

unsigned int type = (bytes[0] >> 5) & 0x07u;
unsigned int length =
    ((unsigned int)(bytes[0] & 0x1Fu) << 8) |
    (unsigned int)bytes[1];

A tároló a helyi bytes tömb; nincs megtartott kölcsönzött hozzáférés. A felső három bit 101, ezért type == 5. Az alsó öt bit nulla, így length == 1. Ezek levezetett ellenőrzési értékek, nem futtatási eredmények.

9.3.9 Összefoglaló

A union közös tárolót ad, nem automatikus változatkezelést. Biztonságos értéktípushoz explicit címke, változatonkénti invariáns és címkevezérelt hozzáférés szükséges. A C17 union type punning reprezentációt értelmez át, ezért trap- és hordozhatósági korlátai vannak. A bitmezők logikai szélességet fejeznek ki, de fizikai elrendezésükből nem következik hordozható wire formátum.