Cpp Type Punning
Type Punning C++
Notes on Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes - Robert Leahy - CppCon 2022
Bad Mental Model: types are lenses to view a buffer of underlying bytes.
Bad Example
struct foo {
std::uint32_t a;
std::uint32_t b;
};
static_assert(sizeof(foo) == sizeof(std::uint64_t));
std::uin32_t bar(std::uint64_t& i, const foo& f) {
if (f.a == 2) {
i = 4;
}
if (f.a == 2) {
return f.a;
}
return f.b;
}
int main() {
foo f{2,3};
bar((std::uint64_t&) f, f);
}
Expected Naive Behaviour:
- Initial memory layout of
f
:0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00
- First branch taken (
f.a==2
) and loadi = 4
and memory layout is0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
- Second branch not take
f.a == 2 => false
- Go the last return and return
f.b => 0
Actual Behviour:
Generated Assembly:
bar(std::uint64_t& i, const foo& f)
mov eax, dword ptr[rsi] # 1. load f.a into eax
cmp eax, 2 # 2. first branch (f.a == 2)
je .LBB0_1
cmp eax, 2
jne .LBB0_3
.LBB0_4: # first branch and second branch take
ret
.LBB0_1: # 3. if first branch take
mov qword ptr [rti], 4 # 4. store 4 at the memory address of `i` aka `foo`
cmp eax, 2 # 4. check eax against 2 (f.a == 2) (second branch).
# but no new value is loaded into `f.a`, compiler
# did not reload `f.a` into eax
je .LBB0_4 # 5. return f.a
.LBB0_3: # if first branch not taken but second branch taken
mov eax, dword ptr [rsi + 4]
ret
- Compiler assume that the store to
i = 4
cannot alias the store tof.a
so it optimise out reloadingf.a
into eax - Return
f.a => 2
instead off.b
Type Punning Rules
Accessing can be accessed by:
- Through a reference to its type (addition of cv qualification)
int i = 0; int& y = i; y = 3;
- Through a reference to its signed or unsigned equivalent
- Through a reference to
char
,unsigned char
,std::byte
- Anything else is UB
In the example above we can access f.a
through unsigned int&
struct foo {
std::uint32_t a;
std::uint32_t b;
};
static_assert(sizeof(foo) == sizeof(std::uint64_t));
std::uin32_t bar(std::uint64_t& i, const foo& f) {
if (f.a == 2) {
i = 4;
}
if (f.a == 2) {
return f.a;
}
return f.b;
}
int main() {
foo f{2,3};
bar((std::uint32_t&) f, f);
}
Generated Assembly:
bar(std::uint32_t& i, const foo& f)
mov eax, dword ptr[rsi]
cmp eax, 2
je .LBB0_1
cmp eax, 2
jne .LBB0_3
.LBB0_4:
ret
.LBB0_1:
mov dword ptr [rti], 4
mov eax, dword ptr [ris] <-- reload f.a!
cmp eax, 2
je .LBB0_4
.LBB0_3:
mov eax, dword ptr [rsi + 4]
ret
C++ Object Model
Model:
- Bytes: supply storage for objects
- Objects: have lifetime. Duration of storage not necessarily the same as object lifetime
- Accessing object outside of lifetime is UB
Storage vs Object:
- Object requires lifetime begin and end but storage does not have a concept of storage.
C++ Types Invariants:
- Invariants are estalbed by constructor
- If an invariant cannot be established, the constructor can throw an exception and there is no way for user code to be able to access an object with invalid invariants.
Aggregate Type
- An array is an aggregate
- class/structs/unions (must satisfy all):
- No user-declared constructor - compiler generated constructor are ok
- No
private
orprotected
non-static data member- Everything need to be public if non-static
- No base class
- No virtual functions
- Note:
- Array of non-aggregate type is an aggregate type because it is an array
- Aggregate can have user-declared/user-defined copy-assignment operator and/or destructor, just not constructor
- Benefit: can be Initialised using
{}
- Struct containing string is not an aggregate because std::string have a custom constructor
Initializing an array:
- if the number of element in the initializer list is the same as the array size, element wise construction
- if the number of element in the initilizer list is smaller than the array size, first
m
will be element wise constructed and the rest will be value initialized - if the number of element in the initilizer list is larger than the array size, error
- else: the array did not specify the size -> the array size will be assigned the number of elements in the initializer list
Value Initialization:
- Scalar Types (
bool
,int
,char
,double
): initialized to0
for that type,false
forbool
and0.0
fordouble
- User Type:
- If there is user declared default constructor, default constructor called
- If the default constructor is implicity defined, all non-static members are recursively value initialized
- Reference cannot be value initialized
- Value initialization for non-aggregate class can fail
Philosophy of Aggregate Type:
- An aggregate type is just a sum of its members (literally an aggregate)
- If a class needs a custom constructor, implies more work need to be done for construction and only initializing the members is wrong.
TODO: add POD type
Trivail Types
- types where the constructor and destructor does not need to do anything
- Trivial types are still types with lifetime and we need to begin and end the lifetime.
Implicit Lifetime Types
Criteria (one of):
- Array types
- Scalar Types
- Implicit-lifetime class types:
- aggregate
- or at least one trivial eligible constructor and trivial non-deleted destructor
- Less restrictive than trivial (require all constructor to be trivial)
Creating an implicit lifetime type operations:
std::malloc
,std::memcpy
,::memmove
, starting lifetime with buffer (array ofchar
,unsigned char
,std::byte
) and operatornew
andnew[]
How does these operation start lifetime of a specific type if malloc
returns void*
?
- Performing these operations will implicitly start the lifetime of all implicit lifetime type (super position) - Can be many types.
- Performing specific type operation on this super position set will reduce the set of types to it - well defined
- Leaving the types as all possible types is UB
// implicitly start lifetime of ALL implicit lifetime types
// that the storage have sufficient size and alignment for
const auto ptr = (int*) std::malloc(sizeof(int)*4);
for (int i = 0; i < 4; ++i) {
// implicitly create a lifetime of integer
ptr[i] = i;
}
Solving Type Punning
std::bit_cast
: takes a reference From&
to To
type
- Problem
static_cast
avoid*
toTo*
require that the prvoided argument (To*
) has already started a lifetime ofTo*
- Copies the
From&
into a buffer withmempcy
-> implicitly starts the lifetime of a superset of types that includesTo
- Reduce the set to
To
byreinterpret_cast
-ing the buffer toTo
As-if rule: the optimiser other than RVO cannot create the observable changes
start_lifetime_as
: takes a pointer to storage and implicitly start lifetime of T*
- starts the lifetime of an implicit type on a storage.
Ending an Object’s Lifetime
- Value object goes out of scope:
t.~T()
orptr->~T()
- Reuse of backing storage: if you reuse the storage of an implicit type, the original implicit type lifetime has ended.
std::launder
: deal with reusing storage
- Problem:
- Reusing storage invalidte pointers and references to the old object
- Pointer points to the storage but no longer to the object - lifetime ended
std::launder
gives a pointer to the object from a pointer to the storage - do not start or end lifetime? (tell compiler you know better)
open_query q(/* */);
//...
erased_update* ptr = q.last_update();
// getting a dervided class from the base class by `std::bit_casting`
// erased_update lifetime will ended as the storage is reused for update
update* auto u = update_as<update>(*ptr).value();
// tell the compiler to treat the storage that already has an object
ptr = std::launder(ptr);
std::cout << "Timestamp=" << ptr->time << std::endl;
std::cout << "Seq=" << u->seqno<< std::endl;