Examples of Undefined Behavior Related to Type Accessibility in C++

From C++ standard:

Attempting to read or modify the stored value through any other type results in undefined behavior.

Any other type means type accessablity:

The first basic example illustrating this principle:

#include <cstdint>

std::uint32_t toInt(float value)
{
    auto* p = reinterpret_cast<std::uint32_t*>(&value); // The cast itself is permitted.
    return *p; // Undefined behavior: accesses a float object through a std::uint32_t glvalue.
}

UB in our develop branch

An example demonstrating what we do in develop branch in our project:

#include <cstddef>
#include <cstdint>

struct Packet
{
    std::int8_t x;
    std::int16_t y;
    std::int32_t z;
};

struct A
{
    float value;
};

static_assert(sizeof(A) < sizeof(Packet));
static_assert(alignof(A) <= alignof(Packet));

A getA(const Packet* packet)
{
    const auto* bytes = reinterpret_cast<const std::byte*>(packet);
    // Allowed: a Packet's object representation may be inspected through std::byte.

    const auto* a = reinterpret_cast<const A*>(bytes);
    // The cast itself is allowed, but it does not create an A object.

    return *a;
    // Undefined behavior: no A object exists at this address.
    // Casting through std::byte does not start the lifetime of an A object.
}

After a long spam discussion we realized that -fno-strict-aliasing GCC option does not affect whether the code has undefined behavior.

An attempt to fix with placement new

An example demonstrating how we tried to fix this:

#include <cstddef>
#include <cstdint>
#include <new>

struct Packet
{
    std::int8_t x;
    std::int16_t y;
    std::int32_t z;
};

struct A
{
    float value;
};

static_assert(sizeof(A) < sizeof(Packet));
static_assert(alignof(A) <= alignof(Packet));

A toA(const Packet* packet)
{
    auto* mutablePacket = const_cast<Packet*>(packet);
    // The const_cast itself is allowed because the original object in main
    // is not actually const.

    auto* bytes = reinterpret_cast<std::byte*>(mutablePacket);
    // This conversion only produces a pointer to the Packet's storage.

    auto* a = ::new (bytes) A;
    // Placement new creates an A object and starts its lifetime.
    // Reusing the storage ends the lifetime of the entire Packet object.
    // A is default-initialized, but its trivial default constructor does
    // not initialize A::value, so value remains indeterminate.

    return *a;
    // Undefined behavior: copying *a reads the indeterminate float value.
}

int main()
{
    Packet packet{1, 2, 3}; // The original object is not const.

    const A a = toA(&packet);
    // Undefined behavior occurs inside toA when the indeterminate
    // A::value member is read while constructing the returned A object.

    // Accessing packet here would also be undefined behavior because
    // its lifetime ended when the A object was created in its storage.

    return 0;
}

So basically we did this:

int main()
{
    int* value = new int; // Default-initialized; the value is indeterminate.
    return *value;        // Undefined behavior: reads an indeterminate int.
}

From C++ 23 standard:

If an indeterminate value is produced by an evaluation, the behavior is undefined …

We did default initialization:

but A does not have a constructor, so our placement new in a an initialized storage is not an initialization of A.

Using valgrind

Used with our trivial example (with new int):

g++ -std=c++23 -O0 -g d.cpp -o d
valgrind --tool=memcheck \
  --track-origins=yes \
  --leak-check=full \
  ./d
==3796550== Memcheck, a memory error detector
==3796550== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==3796550== Using Valgrind-3.22.0 and LibVEX; rerun with -h for copyright info
==3796550== Command: ./d
==3796550==
==3796550== Syscall param exit_group(status) contains uninitialised byte(s)
==3796550==    at 0x4BCD31D: _Exit (_exit.c:30)
==3796550==    by 0x4B26A35: __run_exit_handlers (exit.c:131)
==3796550==    by 0x4B26BBD: exit (exit.c:138)
==3796550==    by 0x4B091D0: (below main) (libc_start_call_main.h:74)
==3796550==  Uninitialised value was created by a heap allocation
==3796550==    at 0x4846FA3: operator new(unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==3796550==    by 0x10915E: main (d.cpp:3)
==3796550==
==3796550==
==3796550== HEAP SUMMARY:
==3796550==     in use at exit: 4 bytes in 1 blocks
==3796550==   total heap usage: 2 allocs, 1 frees, 73,732 bytes allocated
==3796550==
==3796550== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==3796550==    at 0x4846FA3: operator new(unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==3796550==    by 0x10915E: main (d.cpp:3)
==3796550==
==3796550== LEAK SUMMARY:
==3796550==    definitely lost: 4 bytes in 1 blocks
==3796550==    indirectly lost: 0 bytes in 0 blocks
==3796550==      possibly lost: 0 bytes in 0 blocks
==3796550==    still reachable: 0 bytes in 0 blocks
==3796550==         suppressed: 0 bytes in 0 blocks
==3796550==
==3796550== For lists of detected and suppressed errors, rerun with: -s
==3796550== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

But our example with placement new does not read physically uninitialized memory at runtime because the underlying bytes still contain data from the previous Packet object. However, in terms of the C++ abstract machine, the newly created A::value has an indeterminate value, and reading it results in undefined behavior.

2 Responses to Examples of Undefined Behavior Related to Type Accessibility in C++

  1. dmitriano says:

    An example of placement new usage in a similar scenario: https://stackoverflow.com/questions/79998366/using-stdstart-lifetime-as-multiple-times-with-the-same-source-object
    It does std::memcpy after placement new.
    Also see an interesting comment: Then after the first new storage is reused ending value lifetime and I think that you cannot rely anymore on its bits.

  2. dmitriano says:

    https://stackoverflow.com/questions/79998366/using-stdstart-lifetime-as-multiple-times-with-the-same-source-object#comment141160551_79998388
    – Probably yes, but anyway I realized that we can’t use reinterpret_cast + placement new, because it is not clear how to initialize our objects
    – it’s why I “construct” them in initialized state and then copy the bits with memcpy

Leave a Reply

Your email address will not be published. Required fields are marked *