Fixing std::bit_cast of types
with padding bits

Document number:
D3969R0
Date:
2026-01-17
Audience:
LEWG
Project:
ISO/IEC 14882 Programming Languages — C++, ISO/IEC JTC1/SC22/WG21
Reply-to:
Jan Schultke <janschultke@gmail.com>
GitHub Issue:
wg21.link/P3969/github
Source:
github.com/eisenwave/cpp-proposals/blob/master/src/bit-cast-padding.cow

When bit-casting a type containing padding bits to a type with no padding bits, std::bit_cast degenerates into an alternative spelling for std::unreachable (some exceptions apply). I propose alternative behavior in the case of padded source types.

Contents

1

Introduction

2

Design

2.1

Why not make std::bit_cast_zero_padding the default behavior?

2.2

Can't you clear padding bits before bit-casting?

2.3

Can't you make std::bit_cast produce unspecified or erroneous values?

2.4

The problem of bit-casting union types

2.5

Constraints vs Mandates

3

Implementation experience

4

Wording

4.1

[version.syn]

4.2

[bit.syn]

4.3

[bit.cast]

5

References

1. Introduction

The following use of std::bit_cast has undefined behavior:

std::bit_cast<__int128>(0.0L);

That is because an 80-bit x87 long double has 6 bytes of padding, and it is undefined behavior to map those padding bits onto non-padding bits in the destination type via std::bit_cast.

Surprisingly, the undefined behavior in such cases does not depend on the argument. A specialization std::bit_cast<To, From> is an alternative spelling for std::unreachable if From has padding bits and To does not, a degenerate form. Despite not depending on the argument, the degenerate form of std::bit_cast does not violate the Constraints or Mandates element, leaving the bug undetected. Compilers also have no warning for the degenerate form at the time of writing.

If those padding paddings in From are all mapped onto std::byte or unsigned char objects within To, the behavior is well-defined.

This behavior is a footgun, and is not very useful. If users want a function that always has UB, they should be writing std::unreachable, not std::bit_cast.

Furthermore, it would be useful if bit-casting between long double and a 128-bit integer type was possible. After all, reinterpreting floating-point types and integer types is part and parcel of implementing mathematical functions like those in <cmath>. It would also be useful if this could be done portably in constant expressions. Another case where the degenerate form may arise frequently is bit-casting _BitInt (supported by Clang as an extension and proposed in [P3666R2]), considering that most _BitInt types (at least 7/8) have padding bits.

2. Design

The paper proposes the following changes:

2.1. Why not make std::bit_cast_zero_padding the default behavior?

It may also be possible to alter the behavior of std::bit_cast to clear padding, rather than creating a new function. However, this would be problematic because std::bit_cast can be used to convert padded types to a byte array without undefined behavior. Wiping padding bits would add more cost to existing code.

Furthermore, if users assumed std::bit_cast to wipe padding, they may inadvertently access uninitialized memory on older compiler versions, where that behavior is not implemented yet. Perfectly well-defined C++29 code with no erroneous behavior that uses std::bit_cast could be copied and pasted into older code bases, and suddenly obtain undefined behavior.

Last but not least, users may be surprised by std::bit_cast changing the value of any bits. Conceptually, it is a reinterpretation of existing bits as a new type, and it is desirable to express behavior like zeroing of padding explicitly.

2.2. Can't you clear padding bits before bit-casting?

In the discussion of this proposal prior to publication, it was suggested to clear the padding before bit-casting. That is, standardizing __builtin_clear_padding and using an idiom such as:

long double x = /* ... */; std::clear_padding(x); std::bit_cast<__int128>(x);

However, this approach does not make any sense in the C++ object model because the state of padding bits is not observable, and attempting to modify and later read their value is futile.

From a hardware perspective, long double may be stored on the x87 floating-point stack, so while it superficially has 6 padding bytes, those only exist on paper, not in hardware. Similarly, _BitInt(20) may superficially have 4 padding bits, but can be stored in a 20-bit register, where none of those bits actually exist.

The assumption that padding bits cannot be observed, may not even exist, and don't have to be preserved is crucial for compiler optimization.

Without that assumption, it would not be permitted to pass around long double via floating-point stack because its padding bits are lost in the process, like in:

#include <cstring> long double copy(long double y) { long double result; std::memcpy(&result, &y, sizeof(y)); return result; }

Clang on -O2 compiles this code to:

copy(long double): fld tbyte ptr [rsp + 8] ret

Even though the code uses std::memcpy, which copies all bytes in the object representation, all padding bytes are discarded when loading onto the floating-point stack.

Besides the hardware perspective, the approach of clearing padding bits in the object does not make any sense for constant evaluation. For instance, Clang does not store an object representation for values during constant evaluation. When bit-casting, one is generated on the fly.

2.3. Can't you make std::bit_cast produce unspecified or erroneous values?

A possible approach would be to make std::bit_cast produce unspecified bit values instead of indeterminate bit values. That is, std::bit_cast<__int128>(0.0L) would create a __int128 with 10 predictable bytes and 6 bytes with unspecified value. There are two problems with this idea:

Overall, this design sweeps the problem under the rug with little to no benefit to the user.

It is also possible to make the result have erroneous value. However, once again, this approach could not be used to portably bit-cast long double to __int128, especially not during constant evaluation; the degenerate form of std::bit_cast would then always produce erroneous values, so it makes no sense to let it compile in the first place. This solution would only benefit the case of bit-casting to a byte array; perhaps that is worth pursuing, but the only way not to add cost to std::bit_cast (with no opt-out) would be to give the bytes an unspecified value that is considered an erroneous value. This provides minimal (if any) benefit, and could be explored in a separate paper; it is a separate issue from the one presented in this paper.

2.4. The problem of bit-casting union types

Consider the following code:

union U { char c; int x; }; auto z = std::bit_cast<int>(U{ .c = 0 });

There are two possible interpretations of why this code has undefined behavior:

The latter interpretation is more reasonable because padding bytes are intuitively a property of the type, given that object and value representations are defined as properties of types. It would be a surprising wording strategy if we considered the set of padding bits to change at run-time.

2.5. Constraints vs Mandates

The degenerate form of std::bit_cast should be diagnosed using a Mandates element (that is, static_assert). That is because the condition for the degenerate form is relatively complicated and may change in the future. Also, Constraints tempts the user to test whether bit_cast is safe using requires, but this test can have false positives. The detection of the degenerate form would only tell the user whether all possible arguments result in undefined behavior.

Conceptually, Constraints for std::bit_cast should tell the user whether bit-casting is technically feasible due to sizes matching and types being trivially copyable, whereas Mandates should catch misuses such as passing consteval-only types or types that result in the degenerate form.

3. Implementation experience

None yet.

There exists no way to query which bits or bytes of a type are padding bits, or whether a type has padding bits in the first place. Therefore, an implementation requires compiler intrinsics, both for detecting the degenerate form of std::bit_cast and for std::bit_cast_zero_padding.

4. Wording

The changes are relative to [N5014].

[version.syn]

Add a feature-test macro to [version.syn] as follows:

#define __cpp_lib_bit_cast 201806L 20XXXXL // freestanding, also in <bit>

[bit.syn]

Change [bit.syn] as follows:

// all freestanding namespace std { // [bit.cast], bit_cast bit-casting template<class To, class From> constexpr To bit_cast(const From& from) noexcept; template<class To, class From> constexpr To bit_cast_zero_padding(const From& from) noexcept; […] }

[bit.cast]

Change [bit.cast] as follows:

Function template bit_cast Bit-casting [bit.cast]

template<class To, class From> constexpr To bit_cast(const From& from) noexcept;

Constraints:

  • sizeof(To) == sizeof(From) is true;
  • is_trivially_copyable_v<To> is true;
  • is_trivially_copyable_v<From> is true.

Mandates:

  • Neither To nor From are consteval-only types ([basic.types.general]). ; and
  • for some argument of type From, the result of the function call expression is well-defined.

Constant When: To, From, and the types of all subobjects of To and From are types T such that:

  • is_union_v<T> is false;
  • is_pointer_v<T> is false;
  • is_member_pointer_v<T> is false;
  • is_volatile_v<T> is false;
  • T has no non-static data members of reference type.

Returns: An object of type To. Implicitly creates objects nested within the result ([intro.object]). Each bit of the value representation of the result is equal to the corresponding bit in the object representation of from. Padding bits of the result are unspecified. For the result and each object created within it, if there is no value of the object's type corresponding to the value representation produced, the behavior is undefined. If there are multiple such values, which value is produced is unspecified. A bit in the value representation of the result is indeterminate if it does not correspond to a bit in the value representation of from or corresponds to a bit for which the smallest enclosing object is not within its lifetime or has an indeterminate value ([basic.indet]). A bit in the value representation of the result is erroneous if it corresponds to a bit for which the smallest enclosing object has an erroneous value. For each bit b in the value representation of the result that is indeterminate or erroneous, let u be the smallest object containing that bit enclosing b:

  • If u is of unsigned ordinary character type or std::byte type, u has an indeterminate value if any of the bits in its value representation are indeterminate, or otherwise has an erroneous value.
  • Otherwise, if b is indeterminate, the behavior is undefined.
  • Otherwise, the behavior is erroneous, and the result is as specified above.

The result does not otherwise contain any indeterminate or erroneous values.

Append the following declaration to [bit.cast]:

template<class To, class From> constexpr To bit_cast_zero_padding(const From& from) noexcept;

Effects: Equivalent to bit_cast<To>(from), except that if a bit b in the value representation of the result does not correspond to a bit in the value representation of from, b is zero, not indeterminate.

[Example: The following example assumes that sizeof(S) == 1 is true.

struct S { }; void f() { bit_cast<char8_t>(S{}); // error: bit_cast<char8_t, S> is always undefined bit_cast<unsigned char>(S{}); // OK, returns indeterminate value bit_cast_zero_padding<char8_t>(S{}); // OK, returns char8_t{0} }

end example]

5. References

[N5014] Thomas Köppe. Working Draft, Programming Languages — C++ 2025-08-05 https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/n5014.pdf