4

Imagine this:

uint64_t x = *(uint64_t *)((unsigned char[8]){'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});

I have read that type puns like that are undefined behavior. Why? I am literally, reinterpreting 8 bytes of bytes into an 8 byte integer. I don't see how that's different from a union except the type pun being undefined behavior and unions not being? I asked a fellow programmer in person and they said that if you're doing it, either you know what you're doing very well, or you're making a mistake. But the community says that this practice should ALWAYS be avoided? Why?

2 Answers2

5

Ultimately the why is "because the language specification says so". You don't get to argue with that. If that's the way the language is, it's the way it is.

If you want to know the motivation for making it that way, it's that the original C language lacked any way of expressing that two lvalues can't alias one another (and the modern language's restrict keyword is still barely understood by most users of the language). Being unable to assume two lvalues can't alias means the compiler can't reorder loads and stores, and must actually perform loads and stores from/to memory for every access to an object, rather than keeping values in registers, unless it knows the object's address has never been taken.

C's type-based aliasing rules somewhat mitigate this situation, by letting the compiler assume lvalues with different types don't alias.

Note also that in your example, there's not only type-punning but misalignment. The unsigned char array has no inherent alignment, so accessing a uint64_t at that address would be an alignment error (UB for another reason) independent of any aliasing rules.

R.. GitHub STOP HELPING ICE
  • 208,859
  • 35
  • 376
  • 711
  • What alignment? –  Aug 15 '20 at 02:53
  • So what does `restrict` do again? Does it mean telling the compiler that the pointer marked with `restrict` is the only way to access certain memory? –  Aug 15 '20 at 03:06
  • 1
    Re: alignment, the address of a declared object of type `T` has alignment `_Alignof(T)`. An address obtained by `malloc` has alignment suitable for storage of any standard type. If `p` is aligned for type `T`, `(unsigned char *)p+n` is aligned for type `T` if and only if the sum is defined and `n` is a multiple of `_Alignof(T)`. – R.. GitHub STOP HELPING ICE Aug 15 '20 at 03:08
  • 2
    Very roughly `restrict` on a pointer imposes a contract that you will not access the pointed-to object except through that pointer for the pointer's lifetime. The precise specification is a lot more complicated which is why nobody understands it. :-P – R.. GitHub STOP HELPING ICE Aug 15 '20 at 03:09
  • Well, if alignment is the value of `_Alignof(T)`, well, what is `_Alignof(T)` then? Is it equivalent to `&T%sizeof(T)`. –  Aug 15 '20 at 03:15
  • `T` is a [placeholder for a] type so there's no such thing as `&T`. `_Alignof(T)` is a number defined by the implementation for the type. For example `_Alignof(uint64_t)` is 8 on most implementations, 4 on some. – R.. GitHub STOP HELPING ICE Aug 15 '20 at 03:16
  • I though `T` was a variable. Then what's the difference between `sizeof` and `_Alignof`? –  Aug 15 '20 at 03:22
  • `_Alignof(T)` divides `sizeof(T)` – R.. GitHub STOP HELPING ICE Aug 15 '20 at 03:25
  • Also, what's ICE that GitHub should STOP HELPING? Sorry that's unrelated but still... –  Aug 15 '20 at 04:15
  • You correctly cite the motivation for allowing compilers to assume objects of different types don't alias in cases where there's no evidence that they would do so. Was there any motivation for inviting compilers to assume that a function like `uint32_t readFloatBits(float*p) { return *(uint32_t*)p;}` won't actually access an object of type `float`, or did the authors of the Standard not expect the rule to be interpreted as such an invitation? – supercat Aug 18 '20 at 15:26
  • "because the language specification says so" - there is what the spec says, and then there is the reality of current implementations. Countless applications and much system code uses type punning already, and if the dominant compilers (clang/gcc/msvc...) didn't support type punning properly, then countless programs would immediately fail. Even if the spec doesn't define it, in actual practice, type punning is well defined and well behaved across the major compilers in dominant computing systems. – Dwayne Robinson Jan 18 '21 at 12:52
  • @Dwayne: no, it is not. That was the case 20 years ago not now. – R.. GitHub STOP HELPING ICE Jan 18 '21 at 15:53
  • @R..GitHubSTOPHELPINGICE: Is there any evidence that the Standard's failure to define constructs that were non-portable but correct was intended to imply that such constructs should not continue to be regarded as non-portable but correct? – supercat Apr 05 '22 at 17:46
0

Type punning is considered UB because the authors of the Standard expected that quality implementations intended for various purposes would behave "in a documented manner characteristic of the environment" in cases where the Standard imposed no requirements, but where such behavior would serve the intended purposes. As such, it was more important to avoid imposing overly strong mandates on implementations than to require that they support everything programmers would need.

To adapt and slightly extend the example from the Rationale, consider the code (assume for simplicity a commonplace 32-bit implementation):

unsigned x;
unsigned evil(double *p)
{
  if (x) *p = 1.0;
  return x;
}
...
unsigned y;
int main(void)
{
  if (&y == &x + 1)
  {
    unsigned res;
    x=1;
    res = evil((double*)&x);
    printf("You get to find out the first word of 1.0; it's %08X.\n", res);
  }
  else
  {
    printf("You don't get to find out the first word of 1.0; too bad.\n");
  }
  return 0;
} 

In the absence of the "strict aliasing rule", a compiler processing evil would have to allow for the possibility that it might be invoked as shown in test on an implementation which might happen place two int values consecutively in such a way that a double could fit in the space occupied thereby. The authors of the Rationale recognized that if a compiler returned the value of x that had been seen by the if, the result would be "incorrect" in such a scenario, but even most advocates of type punning would admit that a compiler that did so (in cases like that) would often be more useful than one that reloaded x (and thus generated less efficient code).

Note that the rules as written aren't describe all cases where implementations should support type punning. Given something like:

union ublob {uint16_t hh[8]; uint32_t ww[4]; } u;

int test1(int i, int j)
{
  if (u.hh[i])
    u.ww[j] = 1;
  return u.hh[i];
}

int test2(int i, int j)
{
  if (*(u.hh+i))
    *(u.ww+j) = 1;
  return *(u.hh+i);
}

int test3(int i, int j)
{
  uint16_t temp;
  {
    uint16_t *p1 = u.hh+i;
    temp = *p1;
  }
  if (temp)
  {
    uint32_t *p2 = u.ww+j;
    *p2 = 1;
  }
  {
    uint16_t *p3 = u.hh+i;
    temp = *p3;
  }
  return temp;
}

static int test4a(uint16_t *p1, uint32_t *p2)
{
  if (*p1)
    *p2 = 1;
  return *p1;
}
int test4(int i, int j)
{
  return test4a(u.hh+i, u.ww+j);
}

Nothing in the Standard, as written, would imply that any of those would have defined behavior unless they all do, but the ability to have arrays within unions would be rather useless if test1 didn't have defined behavior on platforms that support the types in question. If compiler writers recognized that support for common type punning constructs was a Quality of Implementation issue, they would recognize that there would be little excuse for an implementation failing to handle the first three, since any compiler that isn't deliberately blind would see evidence that the pointers were all related to objects of common type union ublob, without feeling obligated to handle such possibilities in test4a where no such evidence would exist.

supercat
  • 77,689
  • 9
  • 166
  • 211
  • Not my DV, but C11 and later have a footnote in **6.5.2.3 Structure and union members**: *If the member used to read the contents of a union object is not the same as the member last used to store a value in the object, the appropriate part of the object representation of the value is reinterpreted as an object representation in the new type as described in 6.2.6 (a process sometimes called “type punning”). This might be a trap representation.* This should make the `test1` and `test2` defined but not `test3` nor `test4`. – chqrlie Aug 18 '20 at 01:00
  • @chqrlie: I don't see anything in the Standard that would indicate that directly using a pointer formed by array decay has different semantics from storing it and then using it. IMHO it would be reasonable to say that such pointers have to be used when "freshly and visibly derived", which would be the situation in #1, #2, and #3 but not #4, but nothing in the Standard would suggest such a thing. If I recall, gcc treats #2 as UB even though #1 is *defined* as equivalent. – supercat Aug 18 '20 at 13:17
  • 1
    @chqrlie: Note that under a pedantic reading of the Standard, the section you cite would only apply for character types, since otherwise the constraints in 6.5p7 trump everything else. – supercat Aug 18 '20 at 13:20
  • @chqrlie: BTW, I just tested the above in clang and gcc, and neither of them recognizes the `*(union.arraymember+index` form as equivalent to `union.arraymember[index]` form when optimizations are enabled, even though the Standard explicitly defines the meaning of the former as being the latter. – supercat Aug 18 '20 at 20:50
  • Indeed only the case #1 seems to generate the reinterpretation, very interesting test. https://godbolt.org/z/sq5e64 – chqrlie Aug 18 '20 at 21:30
  • @chqrlie: Do you see anything in the Standard that would suggest that #1 and #2 could behave differently if *either* was defined? The only way I can see to sensibly interpret the Standard here would be to say that *all* of these constructs, including #1, invoke UB, but that's because the authors of the Standard didn't expect implementations to interpret that as an invitation to behave nonsensically for no good reason. For an implementation to correctly handle #4 would be difficult, but #1-#3 should all be easy if an implementation makes any effort whatsoever not to be willfully blind. – supercat Aug 18 '20 at 21:37
  • I don't see anything either and agree with you that #1 and #2 should behave the same. – chqrlie Aug 18 '20 at 21:48