Type Punning in C en C++

Type punning is het interpreteren van geheugen als verschillende typen tijdens het lezen en schrijven. Het is essentieel voor serialisatie, netwerkprotocollen en toegang tot hardware op laag niveau. Het probleem is dat "werkt in de praktijk" en "heeft gedefinieerd gedrag" verschillende dingen zijn.

Het spectrum van veilig naar UB

In C zijn de veilige manieren om type punning toe te passen union en memcpy. Pointer casts zijn technisch gezien undefined behavior (UB) onder de strikte aliasing-regels, ook al werken ze op elke compiler die je tegenkomt.

Unions

Een union stelt je in staat om als het ene type te schrijven en als het andere te lezen. In C is dit gedefinieerd gedrag:

union {
    float f;
    uint32_t bits;
} pun;

pun.f = 3.14f;
uint32_t exp = (pun.bits >> 23) & 0xff; // extractie van IEEE-754 exponent

Dit werkt ook uitstekend voor het uit elkaar halen van structs:

union {
    struct color { float r, g, b, a; } c;
    float as_array[4];
} u;

u.c = (struct color){ .r = 1, .a = 1 };
float a = u.as_array[3];

memcpy

Als je geen union wilt gebruiken, is memcpy veilig en zal de compiler dit optimaliseren tot een register-move:

float f = 3.14f;
int i;
memcpy(&i, &f, sizeof(f)); // gedefinieerd gedrag, compileert naar één instructie

Pointer Casts — Handig maar UB

Dit compileert, draait en geeft het "juiste" antwoord op elk platform:

float f = 3.14f;
int *p = (int *)&f;
int i = *p;

Het is echter undefined behavior. De strikte aliasing-regel stelt dat een object alleen mag worden benaderd via een lvalue van zijn effectieve type, een gekwalificeerde versie daarvan, of een character-type. Een pointer cast naar een niet-gerelateerd type schendt deze regel.

Waarom C en C++ verschillen

In C zijn typen een manier om geheugen te interpreteren. In C++ zijn typen first-class citizens; de compiler mag er vanuit gaan dat verschillende typen elkaar nooit aliassen.

Dit heeft concrete gevolgen. Beschouw het volgende voorbeeld:

struct c {
    uint32_t a;
    uint32_t b;
};

uint32_t bar(uint64_t *u64, struct c *c) {
    if (c->a == 2) {
        *u64 = 4;
    }
    if (c->a == 2) {
        return c->a;
    }
    return c->b;
}

int main() {
    struct c c = { 2, 3 };
    return bar((uint64_t *) &c, &c);
}

Met GCC of Clang bij -O2 geeft dit 2 terug. Bij -O1 of lager geeft het 0 terug. De compiler ziet dat u64 een uint64_t is en c een struct c — verschillende typen — en neemt daarom aan dat ze elkaar niet aliassen. De tweede controle c->a == 2 wordt weggeoptimaliseerd op basis van de aanname dat het schrijven naar *u64 = 4 de waarde van c->a niet kan veranderen. Dit is technisch correct volgens de standaard, ook al overlappen de typen in het geheugen.

De diepere uitleg hiervoor is te vinden in Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes, waarin wordt besproken waarom C++ deze richting is opgegaan.

De praktische regel

Als je C schrijft en type punning nodig hebt, gebruik dan een union of memcpy. Pointer casts "werken" totdat ze dat niet meer doen. En "niet meer doen" betekent dat de compiler stilletjes de code wegoptimaliseert waarvan je dacht dat deze werd uitgevoerd. Als je C++ schrijft, geldt hetzelfde, met als aanvulling dat de compiler meer ruimte heeft om dingen te breken onder de as-if-regel.

De bug waar ik mee begon? Een pointer cast van float naar uint32_t in een hot loop. Bij -O2 werd de loop geoptimaliseerd onder strikte aliasing-aannames, waardoor de waarden die ik schreef nooit verschenen waar ik ze verwachtte. Een union loste het probleem in tien minuten op.