Dit artikel bespreekt type punning: het interpreteren van geheugen als verschillende typen. De auteur waarschuwt dat er een groot verschil is tussen code die 'in de praktijk werkt' en code met gedefinieerd gedrag.
De belangrijkste punten zijn:
- Veilige methoden: In C zijn
union en memcpy de veilige manieren om type punning toe te passen. Deze methoden zorgen voor gedefinieerd gedrag en worden door moderne compilers efficiënt geoptimaliseerd.
- Het gevaar van pointer casts: Pointer casts leiden tot undefined behavior (UB) vanwege de strikte aliasing-regels. Hoewel ze vaak lijken te werken, kunnen ze bij hogere optimalisatieniveaus (zoals
-O2) ervoor zorgen dat de compiler onterecht code wegoptimaliseert.
- Verschil tussen C en C++: In C++ zijn typen 'first-class citizens', waardoor de compiler nog striktere aannames mag doen over aliasing dan in C.
- Praktisch advies: Gebruik altijd een
union of memcpy om onvoorspelbare bugs bij compiler-optimalisaties te voorkomen.
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.
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.