{"article":{"slug":"type-punning-in-c-and-c","title":"Type Punning in C and C++","subtitle":null,"summary":"A practitioner walkthrough of type punning pitfalls: why a pointer cast that works at -O0 can silently break at -O2, and how C versus C++ rules diverge in treacherous ways.","content_type":"blog_post","language":"en","canonical_url":"https://blog.pwkf.org/2026/09/21/correct-type-punning-in-c.html","author":{"name":"pwkf","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Personal Workflow Blog","url":"https://blog.pwkf.org/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"C","slug":"c","url":"https://listedarticles.com/topics/c"},{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"},{"name":"Engineering","slug":"engineering","url":"https://listedarticles.com/topics/engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":585,"reading_minutes":3,"published_at":"2026-09-21T12:00:00.000Z","added_at":"2026-09-22T12:24:37.776Z","updated_at":"2026-09-22T12:24:37.776Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/type-punning-in-c-and-c","markdown_url":"https://listedarticles.com/articles/type-punning-in-c-and-c.md","example":false,"citation":"pwkf, Personal Workflow Blog. \"Type Punning in C and C++.\" 21 Sept 2026. https://blog.pwkf.org/2026/09/21/correct-type-punning-in-c.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://blog.pwkf.org/2026/09/21/correct-type-punning-in-c.html"},"body_markdown":"# Type Punning in C and C++\n\nI had a bug that took me a while to track down. The problem\nwas type punning. A pointer cast worked fine at `-O0` and\nsilently broke at `-O2`. The C vs C++ distinction here is genuinely\ntreacherous, and most blog posts on the topic get it wrong.\n\nType punning is interpreting memory as different types between reads\nand writes. It’s essential for serialisation, network protocols, and\nlow-level hardware access.\n\nThe problem is that “works in practice” and\n“has defined behaviour” are different things.\n\n## The Spectrum from Safe to UB\n\nIn C, the safe ways to type pun are `union` and `memcpy`. Pointer casts are technically undefined behavior under strict aliasing rules, even though they work on every compiler you’ll encounter.\n\n### Unions\n\nA union lets you write as one type and read as another.\nThis is defined behavior in C:\n\n`union {\n    float f;\n    uint32_t bits;\n} pun;\n\npun.f = 3.14f;\nuint32_t exp = (pun.bits >> 23) & 0xff; // extract IEEE-754 exponent\n`\n\nThis also works beautifully for pulling apart structs:\n\n`union {\n    struct color { float r, g, b, a; } c;\n    float as_array[4];\n} u;\n\nu.c = (struct color){ .r = 1, .a = 1 };\nfloat a = u.as_array[3];\n`\n\n### memcpy\n\nIf you don’t want a union, `memcpy` is safe and the compiler will optimise it to a register move:\n\n`float f = 3.14f;\nint i;\nmemcpy(&i, &f, sizeof(f)); // defined behavior, compiles to a single instruction\n`\n\n### Pointer Casts — Convenient but UB\n\nThis compiles, runs, and gives you the “right” answer on every platform:\n\n`float f = 3.14f;\nint *p = (int *)&f;\nint i = *p;\n`\n\nIt’s also undefined behavior. The strict aliasing rule says an object shall only be accessed through an lvalue of its effective type, a qualified version of it, or a character type. A pointer cast to an unrelated type violates this.\n\n## Why C and C++ Differ\n\nIn C, types are a way to interpret memory. In C++, types are first-class citizens — the compiler is allowed to assume that different types never alias each other.\n\nThis has concrete consequences. Consider:\n\n`struct c {\n    uint32_t a;\n    uint32_t b;\n};\n\nuint32_t bar(uint64_t *u64, struct c *c) {\n    if (c->a == 2) {\n        *u64 = 4;\n    }\n\n    if (c->a == 2) {\n        return c->a;\n    }\n\n    return c->b;\n}\n\nint main() {\n    struct c c = { 2, 3 };\n    return bar((uint64_t *) &c, &c);\n}\n`\n\nWith GCC or Clang at `-O2`, this returns `2`. At `-O1` or below, it returns `0`. The compiler sees that `u64` is `uint64_t*` and `c` is `struct c*` — different types — so it assumes they don’t alias. The second `c->a == 2` check gets optimised away based on the assumption that writing `*u64 = 4` can’t change `c->a`. This is technically correct under the standard, even though the types do overlap in memory.\n\nThe deeper explanation is in Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes, which covers why C++ went this direction.\n\n## The Practical Rule\n\nIf you’re writing C and need to type pun, use a `union` or `memcpy`.\n\nPointer casts “work” until they don’t. And “don’t” means the compiler\nsilently optimises away the code you thought was executing. If you’re\nwriting C++, the same applies, plus the compiler has more latitude to\nbreak things under the as-if rule.\n\nThe bug I started with? A pointer cast from `float*` to `uint32_t*` in a hot loop. At `-O2`, the loop was optimised under strict aliasing assumptions, and the values I was writing never appeared where I expected them. A union fixed it in ten minutes.","body_html":"<h1 id=\"type-punning-in-c-and-c\">Type Punning in C and C++</h1>\n<p>I had a bug that took me a while to track down. The problem\nwas type punning. A pointer cast worked fine at <code>-O0</code> and\nsilently broke at <code>-O2</code>. The C vs C++ distinction here is genuinely\ntreacherous, and most blog posts on the topic get it wrong.</p>\n<p>Type punning is interpreting memory as different types between reads\nand writes. It’s essential for serialisation, network protocols, and\nlow-level hardware access.</p>\n<p>The problem is that “works in practice” and\n“has defined behaviour” are different things.</p>\n<h2 id=\"the-spectrum-from-safe-to-ub\">The Spectrum from Safe to UB</h2>\n<p>In C, the safe ways to type pun are <code>union</code> and <code>memcpy</code>. Pointer casts are technically undefined behavior under strict aliasing rules, even though they work on every compiler you’ll encounter.</p>\n<h3 id=\"unions\">Unions</h3>\n<p>A union lets you write as one type and read as another.\nThis is defined behavior in C:</p>\n<p>`union {\n    float f;\n    uint32_t bits;\n} pun;</p>\n<p>pun.f = 3.14f;\nuint32_t exp = (pun.bits &gt;&gt; 23) &amp; 0xff; // extract IEEE-754 exponent\n`</p>\n<p>This also works beautifully for pulling apart structs:</p>\n<p>`union {\n    struct color { float r, g, b, a; } c;\n    float as_array[4];\n} u;</p>\n<p>u.c = (struct color){ .r = 1, .a = 1 };\nfloat a = u.as_array[3];\n`</p>\n<h3 id=\"memcpy\">memcpy</h3>\n<p>If you don’t want a union, <code>memcpy</code> is safe and the compiler will optimise it to a register move:</p>\n<p><code>float f = 3.14f;\nint i;\nmemcpy(&amp;i, &amp;f, sizeof(f)); // defined behavior, compiles to a single instruction\n</code></p>\n<h3 id=\"pointer-casts-convenient-but-ub\">Pointer Casts — Convenient but UB</h3>\n<p>This compiles, runs, and gives you the “right” answer on every platform:</p>\n<p><code>float f = 3.14f;\nint *p = (int *)&amp;f;\nint i = *p;\n</code></p>\n<p>It’s also undefined behavior. The strict aliasing rule says an object shall only be accessed through an lvalue of its effective type, a qualified version of it, or a character type. A pointer cast to an unrelated type violates this.</p>\n<h2 id=\"why-c-and-c-differ\">Why C and C++ Differ</h2>\n<p>In C, types are a way to interpret memory. In C++, types are first-class citizens — the compiler is allowed to assume that different types never alias each other.</p>\n<p>This has concrete consequences. Consider:</p>\n<p>`struct c {\n    uint32_t a;\n    uint32_t b;\n};</p>\n<p>uint32_t bar(uint64_t *u64, struct c *c) {\n    if (c-&gt;a == 2) {\n        *u64 = 4;\n    }</p>\n<pre><code>if (c-&gt;a == 2) {\n    return c-&gt;a;\n}\n\nreturn c-&gt;b;</code></pre>\n<p>}</p>\n<p>int main() {\n    struct c c = { 2, 3 };\n    return bar((uint64_t *) &amp;c, &amp;c);\n}\n`</p>\n<p>With GCC or Clang at <code>-O2</code>, this returns <code>2</code>. At <code>-O1</code> or below, it returns <code>0</code>. The compiler sees that <code>u64</code> is <code>uint64_t*</code> and <code>c</code> is <code>struct c*</code> — different types — so it assumes they don’t alias. The second <code>c-&gt;a == 2</code> check gets optimised away based on the assumption that writing <code>*u64 = 4</code> can’t change <code>c-&gt;a</code>. This is technically correct under the standard, even though the types do overlap in memory.</p>\n<p>The deeper explanation is in Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes, which covers why C++ went this direction.</p>\n<h2 id=\"the-practical-rule\">The Practical Rule</h2>\n<p>If you’re writing C and need to type pun, use a <code>union</code> or <code>memcpy</code>.</p>\n<p>Pointer casts “work” until they don’t. And “don’t” means the compiler\nsilently optimises away the code you thought was executing. If you’re\nwriting C++, the same applies, plus the compiler has more latitude to\nbreak things under the as-if rule.</p>\n<p>The bug I started with? A pointer cast from <code>float*</code> to <code>uint32_t*</code> in a hot loop. At <code>-O2</code>, the loop was optimised under strict aliasing assumptions, and the values I was writing never appeared where I expected them. A union fixed it in ten minutes.</p>","headings":[{"level":1,"text":"Type Punning in C and C++","id":"type-punning-in-c-and-c"},{"level":2,"text":"The Spectrum from Safe to UB","id":"the-spectrum-from-safe-to-ub"},{"level":3,"text":"Unions","id":"unions"},{"level":3,"text":"memcpy","id":"memcpy"},{"level":3,"text":"Pointer Casts — Convenient but UB","id":"pointer-casts-convenient-but-ub"},{"level":2,"text":"Why C and C++ Differ","id":"why-c-and-c-differ"},{"level":2,"text":"The Practical Rule","id":"the-practical-rule"}]}}