The correct way to do type punning in C++ - The second act

Last time, I wrote about type-punning in C++ and how C++20's std::bit_cast can help you. Today, I want to discuss a different reason for type-punning where std::bit_cast might not apply.

std::bit_cast and its limits

You probably remember the example from last month's post where I showed std::bit_cast. For your convenience, here is the code:

1
2
const float    pi  = 3.14f;
const uint32_t pii = std::bit_cast<uint32_t>(pi);

The example above is perfect for a case where you want and can afford a copy of the data (the bits). But what if you're dealing with a larger structure in a constraint environment, where

  • copying the data is too costly, and or
  • you don't have enough RAM left to make the copy?

Suppose you encounter code like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct ConfigValues {
  uint32_t                  chksum;
  std::array<uint32_t, 128> values;
};

bool ProcessData(std::span<unsigned char> bytes)
{
  if(bytes.size() < sizeof(ConfigValues)) { return false; }

  A UB due to type punning
  ConfigValues* cfgValues = reinterpret_cast<ConfigValues*>(bytes.data());

  return HandleConfigValues(cfgValues);
}

I assume you're dealing with some kind of message-processing system. The data most likely comes in from the wire due to constraints in RAM and processing time copying a large structure (128 uint32_ts are considered larger there). You want a zero-copy. This is why, despite knowing about std::bit_cast, you go back to the pleasures of UB-land and the casting dance. Of course, with the same consequences as I described last time.

Well, remember that I talked about different shaped and colored safety buoys? C++23 added another safety buoy for exactly such a situation.

C++23's std::start_lifetime_as

Meet std::start_lifetime_as below, which ships in <memory>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct ConfigValues {
  uint32_t                  chksum;
  std::array<uint32_t, 128> values;
};

bool ProcessData(std::span<unsigned char> bytes)
{
  if(bytes.size() < sizeof(ConfigValues)) { return false; }

  A Using std::start_lifetime_as
  ConfigValues* cfgValues = std::start_lifetime_as<ConfigValues>(bytes.data());

  return HandleConfigValues(cfgValues);
}

As you can see, the change is in A where in the previous example, the code was UB, I replaced the reinterpret_cast with a cast-looking std::start_lifetime_as. One noticeable difference is that while reinterepret_cast took a pointer as an argument for the destination type, std::start_lifetime_as takes just the type, but the function still returns a pointer of the destination type.

Book an in-house C++ training class

Do you like this content?

I'm available for in-house C++ training classes worldwide, on-site or remote. Here is a sample list of my classes:
  • From C to C++
  • Programming with C++11 to C++17
  • Programming with C++20
All classes can be customized to your team's needs. Training services

As memcpy in c++17 and later, std::start_lifetime_as is blessed by the standard to start the lifetime of an object. The difference to std::bit_cast is that std::start_lifetime_as doesn't copy the data. You can see this function like a placement new. You get a pointer of the destination type back. One major difference is that no constructor gets called. With that, std::start_lifetime_as is what all the folks in embedded and potential other domains were looking for for over 30 years, a way to tell the compiler that a bit representation a is now used as an object b.

Andreas