15
$\begingroup$

C++ disallows reinterpret_cast from int to float as far as I know, and using a union or pointer cast to reinterpret an int to a float or vice versa or even an int to a short[2] is undefined behavior. In C using a union is valid but using a pointer cast remains invalid. Most languages have no facilities to reinterpret the bits of one type to another.

Why are languages often averse to reinterpret/bitcasting a type to an 'unrelated' type. What are the advantages or disadvantages of allowing this?

$\endgroup$
7
  • $\begingroup$ Would-have-been dupe: langdev.stackexchange.com/questions/2326/… $\endgroup$ Jul 13, 2023 at 3:25
  • 3
    $\begingroup$ C++ disallows conversion from int to float, but you can convert from const int& to const float&, which is basically the same thing. $\endgroup$
    – Bbrk24
    Jul 13, 2023 at 3:47
  • 5
    $\begingroup$ I don't doubt you, but when I first got my degree 4 decades ago, "Most languages have facilities to reinterpret the bits of one type to another." would have been a truer statement. Its arguably a requirement for a system's programming language. $\endgroup$
    – T.E.D.
    Jul 13, 2023 at 13:36
  • $\begingroup$ FWIW even Haskell lets you reinterpret floats to ints and vice versa. Reinterpreting arbitrary objects is a whole different ballgame than just reinterpreting primitives. $\endgroup$
    – user1030
    Jul 13, 2023 at 15:41
  • $\begingroup$ Besides the reasons in the answers, there is also a general assumption that pointers of unrelated types point to different memory. $\endgroup$ Jul 14, 2023 at 9:37

5 Answers 5

24
$\begingroup$

It forces a well defined interpretation of all types involved

In order to have a well-defined reinterpretation of a cast between two types, their exact binary representations must always be well defined over all time on all platforms.

While, float, for example, is usually represented as IEEE 754, but the C standard does not require this and your program could have a radically different effect if some other format for floats is used.

By making reinterpreting undefined behavior language implementers have free choice how to represent each type without fear of breaking any well-defined programs.

Some languages guarantee that some specific types can be safely cast. For example, in Rust, Simd<T, N> is guaranteed to be safely transmuted to [T, N]. It may make sense to safely allow this for some specific combinations of types. However, allowing it for arbitrary types means the format of everything needs to be fixed.

$\endgroup$
14
$\begingroup$

Because it is problematic. Its a dangerous thing to do. Errors in code that does it can be devilishly hard to track down.

Perhaps an illustration will help.

I once had some code in K&R C that was calling a function with a float as an argument. Simplest thing in the world really. What could go wrong?

The problem was the function wasn't declared before it was called in one program unit (an error on my part). Under the K&R rules this compiler implemented, that meant the compiler assumed it existed as a function returning int, and any parameters sent to it were supposed to be int as well. So the compiler's code it generated to call this routine converted the float parameter to an int before pushing it on the stack.

The code implementing the function off in a different .c file of course knew nothing about this, and when it pulled that parameter off the stack, treated the int as a float.

This was a nightmare to debug, because as far as I knew there were no compilation or link errors, and everything looked coded correctly. The types in the source code matched up. The symptom (my data getting trashed) had no obvious relation to the ultimate coding error. It didn't look like I was doing anything unsafe or dangerous anywhere. Days were wasted.

The moral here (aside from "K&R C sucks"), is that type reinterpretation of bits is a hazardous operation. If the coder does something wrong it can cause really difficult to track down bugs.

Its perhaps worth thinking about this as the dataflow equivalent of a goto statement for control flows. A code maintainer looking at an object and seeing its type will have expectations about how the data contained by that object will be used, how it can be changed, and what its values can be. All of those assumptions are out the window once its type representation has been munged.

This is why modern languages are (usually) designed to make such operations difficult and ugly. Not happening silently, like my K&R compiler/linker was doing. The idea is that the language should both discourage casual use of this feature, and implement it in a way that makes its existence in source code very difficult to miss.


Of course the obvious next question is, why allow it at all? Well, its tough to do a lot of low-level programming without it. For example, the entire concept of a protocol stack relies on every layer of protocol code treating the contents of higher layers as untyped data. Higher layers in fact know some of that data is a specific header structure, and the highest layer most likely wants to transmit some floats.

$\endgroup$
1
  • 2
    $\begingroup$ That's why we need implicit declaration errors/warnings. $\endgroup$ Oct 4, 2023 at 20:03
10
$\begingroup$

Non-Portability

Particularly with pointers and floating-point numbers, there was a lot of hardware out there where even loading an invalid value into certain registers would cause a CPU fault and crash the program. There are even CPUs where a signed integer value with all bits set to 1 is a “trap” value that generates a hardware exception, and the C standard (up through C17) allowed compilers to use the native instructions, rather than check for this. Since C is a relatively low-level language often used for OS kernels and device drivers, the Standards committee wanted to let code that did this compile efficiently and transparently, but that meant the results would be unpredictable.

C and C++ don’t prohibit all bit-casts whatsoever, only certain ones that are known to cause problems. C (but not C++) allows type-punning in unions. Both allow casts between pointers to layout-compatible struct objects, such as the sockaddr family of BSD sockets. They also define a safe conversion from any object pointer to void* to inptr_t or unintptr_t, and back. In many cases, memcpy() on the object representation is guaranteed to work.

C++20 does add std::bit_cast, whose behavior is merely “unspecified,” not undefined (if it does not produce a trap representation, so a round-trip conversion is safe).

A new language designed today might not care about supporting weird architectures from the 1960s. But C did, fifty years ago.

$\endgroup$
8
  • $\begingroup$ For example, they specify that all int types have no padding bits or trap representations - I thought it was only all the (u)intN_t. $\endgroup$ Jul 14, 2023 at 17:12
  • $\begingroup$ @user16217248-OnStrike Whoops. integer types are allowed to have padding bits. A value with all-bits-1 is allowed to be a trap representation, too. $\endgroup$
    – Davislor
    Jul 14, 2023 at 17:19
  • $\begingroup$ But that's just -1 $\endgroup$ Jul 14, 2023 at 17:20
  • 1
    $\begingroup$ @user16217248-OnStrike That’s just -1 on a two’s-complement machine. C also supports one’s-complement and sign-and-magnitude, and the Standard specifically states that it’s up to the implementation whether that representation is a trap representation. $\endgroup$
    – Davislor
    Jul 14, 2023 at 17:24
  • $\begingroup$ As of recently (C23), two's complement is required by the C standard. $\endgroup$ Jul 14, 2023 at 17:25
5
$\begingroup$

Managed Pointers

Casting a managed Object to an unmanaged byte* could lead to the object getting deallocated while you're manipulating the underlying data, and the other way around is just asking for the garbage collector to give you UB. Not all languages have a specified or stable GC header, so it's a bad idea to let you manipulate managed objects.

Even if you never dereference the pointer, this can cause UB. Consider the following code:

Object obj = // ...
++(long*)obj;

Suddenly, the data held by the object is now treated as the GC header, which is problematic for obvious reasons.

Object is not a true top type in C# for this exact reason. Instead, there are two top-like types, Object and void*, and there’s no way to convert between the two.

$\endgroup$
3
  • 2
    $\begingroup$ I think you missed the question by one level of indirection ;) $\endgroup$
    – feldentm
    Jul 13, 2023 at 6:52
  • $\begingroup$ By my understanding, the CompCert dialect of C, which is designed to allow optimizations to be proven sound, allows data to be accessed via lvalues of any non-pointer data types interchangeably, or by lvalues of single indirect pointer-to-data types interchangeably, or double indirect pointer-to-data-types, etc. but does not storage to be accessed via lvalues representing different levels of indirection, for reasons like those alluded to in this answer. $\endgroup$
    – supercat
    Oct 6, 2023 at 20:13
  • $\begingroup$ I think mixing pointer arithmetic and a never-conservative GC is what's the bad idea. Some other error cases: pointers to array members, pointers to struct/class fields, and array slices, do not have access to the GC headers either. Also "the data held by the object is treated as the GC header" requires the GC to follow scalars which most do not do. $\endgroup$
    – Longinus
    Oct 14, 2023 at 21:05
3
$\begingroup$

While casting the pointers to these types is often allowed, casting the values usually isn't. I guess the reason is that the cast has unknown arbitrary cost and requires code. My research project currently has such a cast operation and I eventually resorted to creating a hidden variable, moving the data to it, casting it to the target pointer type and loading the value back. Simply, because it was too cumbersome getting all the pairs right that one would need to consider. I mean, if you think about physical representation, it could be that casting long to float[2] results in moving one value from an integer register somewhere to two float registers in a coprocessor.

Also, there is very little things that you really want to do this way. The code you'd write with this cast is essentially loading all the data into registers just to decide that those were tho wrong registers and that you need to move them to another set of registers. Why not using the correct type in the first place?

$\endgroup$
8
  • $\begingroup$ "Why not using the correct type in the first place?" -- because you cannot do every operation on every type, usually. $\endgroup$
    – user1030
    Jul 13, 2023 at 9:29
  • 2
    $\begingroup$ Why would reinterpret_cast require any code? It doesn't make any change to the value, it just uses the existing bit pattern. $\endgroup$
    – Barmar
    Jul 13, 2023 at 13:50
  • 3
    $\begingroup$ @Barmar The bit pattern that is existing where? Part of the point of the answer is that the where matters. $\endgroup$
    – Pablo H
    Jul 13, 2023 at 14:03
  • $\begingroup$ The variable that you're reinterpreting. It shouldn't matter where it is. $\endgroup$
    – Barmar
    Jul 13, 2023 at 14:34
  • 1
    $\begingroup$ @Barmar what if I want to add it to a larger 64-bit int, and it's sitting in xmm0? $\endgroup$ Jul 13, 2023 at 22:13

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .