C++ Object Streaming - Part I

C++ Object Streaming - Part I

Introduction

You often need to serialize (save) C++ objects to files for storage, and deserialize (load) them later on a subsequent run of a program. In video games, these data can be resource assets such as textures and meshes, gameplay information such as save games, etc. Here we'll construct a general library for streaming C++ objects to and from memory.

Because hard drives are slow, the most efficient way to deserialize data is to first read all of the file data at once into a byte buffer in memory (to minimize the number of disk accesses). When we deserialize, we sometimes want to byte-copy this data into existing C++ objects, but this can be slow and inefficient for large sets of data. However, we can skip this extra copy entirely if we can create the C++ objects directly in-place in the byte buffer. To get this functionality though we need to be extremely careful with how we serialize and deserialize our data.

As always, the code shown below has been edited for brevity and readability; the production code is slightly more complex than shown here.

Object Types

The C++ object types that we will stream fall into one of several categories:

  1. Arithmetic Types (including characters) and Enums

  2. Pointers

  3. Arrays

  4. Classes containing members of these types (including other classes)

Note that this does not include references, function pointers, or unions. These types are either not streamable, or should just be avoided in general (unions).

We may want to write custom methods for streaming user-defined classes, especially containers or those that aren't Standard Layout. Since the content of these streaming methods depend on knowledge of the structure of the class, they should be (public) member functions of the classes themselves.

Standard Layout

If we are going to deserialize objects in-place, then they must be Standard Layout types. All of our types listed above are standard layout, with the possible exception of user-defined classes. The requirements for Standard Layout Classes are:

  1. All non-static data members must have standard-layout

  2. All base classes must have standard-layout

  3. No virtual functions or virtual base classes

  4. All non-static data members have the same access control (i.e. all public, all private, or all protected)

  5. Only one class in the hierarchy has data members

  6. None of the base classes has the same type as the first non-static data member

While this is a long list, if you are following best practices (nothing virtual!) you typically just need to remember:

  1. For each class hierarchy, only one class has non-static data members

  2. Non-static members must be either all public, all private, or all protected

  3. Everything contained within must also be standard-layout

If a type is not standard-layout, then the layout of its data members in memory is implementation-defined! That means that if we serialize this data, there is no guarantee that the layout of the object won't change when we deserialize sometime in the future (e.g. different compiler version). We can still stream these types of objects, but we cannot deserialize them in-place: we must define custom streaming methods that choose the order we want to serialize the data for these classes.

Stream Operator

As far as the implementation is concerned, there are several different categories of object types:

  • Classes with custom streaming methods

  • Classes providing direct access to their member variables

  • Fixed-size arrays

  • Runtime arrays

  • Directly-serializable types (e.g. arithmetic types, enums, and classes thereof)

  • Pointers

So our streaming operator methods basically just detects what type we're streaming, and delegates the work to different methods based on the type category. For serialization:

//Implements serialization-specific routines
template <typename DerivedType>
class SinkBase : public StreamBase<DerivedType>
{
    //...
};

//Serialize Object
template <typename DerivedType>
template <typename ObjectType>
inline DerivedType& SinkBase<DerivedType>::operator<<(const ObjectType& aData)
{
    static_assert(!std::is_pointer_v<ObjectType>, "Pointers not supported!");

    auto& cSink = *static_cast<DerivedType*>(this);
    StreamBaseClass::template Stream<ObjectType>([&aData, &cSink]()
    {
        if constexpr (Has_Serialize<DerivedType, ObjectType>())
            aData.Serialize(cSink);
        else if constexpr (Has_ConstGetMembers<ObjectType>())
            Stream_Members<StreamMode::Sink>(cSink, aData);
        else if constexpr (std::is_bounded_array_v<ObjectType>)
            cSink.Serialize_FixedArray(aData);
        else
            cSink.Serialize_Directly(aData);
    });

    return cSink;
}

Note that SinkBase is templated on the type that derives it: This is the Curiously Recurring Template Pattern, or CRTP. Examples of deriving classes might be a ByteSink or a FileSink, which would do the actual implementation of writing the bytes to either a byte buffer or a data file, respectively.

Runtime-sized arrays are streamed using their own dedicated class methods, and are covered in Part II of this blog post. Types that need custom serialization methods (and the associated helper functions above) are also covered in the next post.

Streaming objects in the remaining type categories are covered in the sections below. This includes pointers, which should instead be implemented by serializing an offset to the pointed-to objects, as discussed in the Pointer Streaming section, and demonstrated in the examples shown in Part II.

Implicit-Lifetime Objects

Deserializing to pre-existing objects in memory is similar to serializing, defining operator>> instead of operator<<. However, for in-place deserialization we don't yet have an object to pass the stream operator! Instead, we pass in a TypeTag to indicate the type we want to deserialize at the current location:

template <typename DataType>
struct TypeTag
{
    using Type = DataType;
};

template <typename DerivedType>
template <typename ObjectType>
auto& InPlaceSourceBase<DerivedType>::operator>>(TypeTag<ObjectType>)
{
    //Start object lifetime unless mid-streaming (recursive, already done)
    if(!Is_MidStreaming())
    {
        //TODO: Assert Is-implicit lifetime when avaiable
        //TODO: Start object lifetime when available
        if constexpr (std::is_bounded_array_v<ObjectType>)
            //std::start_lifetime_as_array();
        else
            //std::start_lifetime_as();
    }

    //...
}

When deserializing in-place, you might be tempted to just reinterpret_cast<> the current memory address to the desired pointer type to use the new object. However, this can be undefined behavior if we don't first formally create an object at that location. We must inform the compiler (except in special circumstances) that an object of that type needs to be created at that location.

But how do we that? Not only do we not want to do a placement-new at that location, we don't even want to call any constructors on the object! That's because constructors may default-initialize the class members of the object, overriding the values of our data in the byte buffer!

Fortunately the new std::start_lifetime_as and std::start_lifetime_as_array functions in C++23 (P2590R1) tell the compiler to create objects at that location, and to not call any of their constructors. This informs the compiler that there are now objects at these locations, thus avoiding undefined behavior. These functions are called by InPlaceSourceBase above.

The std::start_lifetime_as and std::start_lifetime_as_array functions can only be called to create implicit-lifetime objects. Arithmetic types, enums, pointers, and arrays are all implicit lifetime types. Class types must either:

The exact details as to what constitutes trivial con/destructors are detailed at those links, but they basically boil down to operations that perform no actions and have nothing virtual.

More details on implicit-lifetime objects and starting their lifetimes can be found in this excellent talk at Cppcon:

Byte Copy Streaming

The simplest types to work with are those that can be streamed with a simple byte copy. Arithmetic types, and enums fall into this category, as well as classes containing only those types of members (and other such classes).

Until recently one needed to worry about data being represented with different byte-orders, or endianness, on different machines. For example, suppose you serialized an asset on PC (x86, little-endian) but deserialized it on an XBox 360 or Playstation 3 (PowerPC big-endian). During deserialization you would have to reorder the bytes in every single integer, float, etc. However, now that all of the current major platforms are little-endian by default, this is something we don't need to worry about any more:

If ignoring endianness is good enough for John Carmack, it's certainly good enough for me :) Note however that you may need to consider endianness when writing network packet headers and data, depending on what you're trying to do.

There is one thing we need to be careful about though, and that is memory alignment. If we are deserializing objects in-place, we must serialize them to memory addresses that are properly aligned so that we can deserialize them without paying for (slow) unaligned reads. This alignment can be done by skipping ahead in the stream by the appropriate number of bytes:

template <typename DerivedType>
class StreamBase
{
//...
private:
    int mCurrentIndex = 0;
};

template <int PowerOf2>
constexpr int Align(int aInteger)
{
    //https://stackoverflow.com/questions/3407012/c-rounding-up-to-the-nearest-multiple-of-a-number
    return (aInteger + PowerOf2 - 1) & (-aInteger);
}

template <Types::Align Alignment>
void StreamBase::Align()
{
    //Round up mCurrentIndex to the nearest multiple of the alignment
    mCurrentIndex = ::Align(mCurrentIndex);
}

Once the destination has been properly aligned, serializing bytes directly is implemented as (where Span is similar to std::span):

class ByteSink : public SinkBase<ByteSink>
{
    //...
private:
    Span<std::byte> mByteBuffer;
};

//Directly serialize an object to a byte buffer
template <typename DerivedType>
template <typename ObjectType>
void SinkBase<DerivedType>::Serialize_Directly(const ObjectType& aObject)
{
    auto cBytes = reinterpret_cast<const std::byte*>(&aObject);
    auto cSpan = Wrappers::Make_ConstSpan(cBytes);
    Serialize_Bytes(cSpan);
}

template <typename DerivedType>
void SinkBase<DerivedType>::Serialize_Bytes(const Span<const std::byte>& aBytes)
{
    auto& cDerived = *static_cast<DerivedType*>(this);
    cDerived.Serialize_Bytes_Impl(aBytes);
    mCurrentIndex += aBytes.size();
}

void ByteSink::Serialize_Bytes_Impl(const Span<const std::byte>& aBytes)
{
    auto cWriteToBegin = std::begin(mByteBuffer) + cCurrentIndex;
    std::copy_n(std::begin(aBytes), aBytes.size(), cWriteToBegin);
}

Yes that is a lot of functions and classes, but breaking the operations up this finely allows us to reuse these components for streaming other types of objects, and to do so in different ways. Deserialization to pre-existing objects is quite similar and is thus not shown.

But what about deserializing in place? This is done by:


template <typename DerivedType>
DerivedType& StreamBase<DerivedType>::operator+=(int aNumBytes)
{
    //Advance current index
    mCurrentIndex += aNumBytes;

    //No-op for byte streams, move file pointer for file streams
    auto& cDerived = *static_cast<DerivedType*>(this);
    cDerived.Advance(aNumBytes);
    return cDerived;
}

template <typename DerivedType>
template <typename ObjectType>
void InPlaceSourceBase<DerivedType>::Deserialize_Directly_InPlace()
{
    *this += sizeof(ObjectType);
}

template <typename ObjectType>
void Destreamer::Destream_Directly()
{
    *this += sizeof(ObjectType);
}

All we do is advance the stream! That's because we already created the object at this location in the stream operator, so there's nothing more to do.

The Destreamer is responsible for calling the (trivial) destructor of these objects, informing the compiler that the object is no longer needed and the memory can be reused. This is done in its operator<<, which while not shown is similar to that of the streamers (taking a TypeTag instead of an object like InPlaceSourceBase does)

Streaming Fixed-Size Arrays

For fixed-size arrays, the main question is whether the array elements can be streamed directly or not. If they can (e.g. arithmetic types, enums, or classes thereof), then we can simply stream the bytes of all of the objects at once, similar to the Byte Copy Streaming section. If not, then we have to stream each of the objects individually. This functionality is only shown here for serialization, but it is similar for other types of streaming (deserializing, destreaming):

template <typename DerivedType>
template <typename ArrayType>
void SinkBase<DerivedType>::Serialize_FixedArray(const ArrayType& aArray)
{
    //NOTE: Must be generalized for multi-dimensional arrays!
    static constexpr int sNumElements = std::extent_v<ArrayType, 0>;
    using ObjectType = std::remove_all_extents_t<ArrayType>;

    //E.g. arithmetic types, enums, and classes thereof
    if constexpr (Is_DirectlySerializable<DerivedType, ObjectType>())
    {
        static constexpr auto sNumBytes = sNumElements * sizeof(ObjectType);
        auto cByteArray = reinterpret_cast<const std::byte*>(aArray);
        auto cSpan = Span<const std::byte, sNumBytes>(cByteArray);
        Serialize_Bytes(cSpan);
    }
    else //Stream the objects individually
    {
        for(int ci = 0; ci < sNumElements; ++ci)
            *this << aArray[ci];
    }
}

Streaming Pointers

Streaming pointers is very tricky. The main question is: "Does this object own the memory that it is pointing to?" If it doesn't own it (e.g. we are just pointing somewhere for convenience), then someone else is (and better be!) responsible for streaming the object we're pointing to. If we do own it (e.g. a pointer to a dynamic array that we allocated), then we must also stream the pointed-to data as well.

Now, the specific value of the memory address where we are pointing to has no meaning once we serialize the data: on the next run of the program, objects may exist at completely different memory locations. What we can do instead is serialize a distance offset (std::intptr_t) indicating where the pointed-to-data is in the data stream, relative to the position of the distance offset itself.

How do you know where this will be? If you are (very carefully!) serializing a large chunk of contiguous data, and it is pointing to an object somewhere in this data, then this offset will simply be the difference between the memory location of the pointer itself and the object it is pointing to. For objects that are not owned, it is extremely difficult to guarantee that this pointer will always point to memory that is in the continuous buffer.

Unless we are deserializing in-place though, deserializing a pointer to memory that we don't own is also tricky. While we may know where the pointed-to object is in the original byte stream, in general we do not know the address of the memory where that object will be deserialized-to. If we do own the memory for the pointed-to object, we can control where that goes directly at the call site and set up our pointer appropriately.

Therefore, generically streaming a pointer to unowned memory is not advisable. Instead, you should manually handle setting the pointer location properly as a post-deserialization step in your pipeline.

So how do we stream a pointer to an object that we own? Well, it's identical to how we stream a pointer to an array that we own, if that array has a size of one. So we'll just leave it for the discussion of streaming runtime arrays in the next part of this series.

Conclusion

Deserializing objects in-place can be significantly faster than doing so to pre-existing objects. To do so though we are restricted to streaming implicit-lifetime types with standard-layout. Runtime-sized arrays and types that need custom streaming methods are covered in Part II of this blog post.

Did you find this article valuable?

Support Paul Mattione by becoming a sponsor. Any amount is appreciated!