Binary Serialization using Cereal

A detailed and practical tutorial for binary serialization in modern C++ using the cereal library.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

Often, we need to convert our C++ objects into a form that can be stored or transported outside of our program.

For example, we may want to save the state of our objects onto a hard drive, so our program can recover it when it is reopened later.

Or we may want to send the state of our objects to another instance of our program, running on another machine, over the internet. This allows our users to collaborate on projects, or to share the same world in a multiplayer game.

Serialization is the process of converting our objects into a format that can be stored or transmitted. Our previous lessons had examples of this using JSON. Here, we’ll focus on binary serialization.

Binary vs JSON

Our previous lesson included examples of how we can create and modify objects based on data stored as JSON. At a high level, serializing our data using binary achieves similar goals.

Whether we use binary or JSON broadly depends on our needs. Text formats like JSON prioritize versatility, whilst binary prioritizes performance.  Specifically:

  • JSON is easily readable and understandable by humans; binary is not
  • JSON has a much more generic format. Programs can easily be created or adapted to consume JSON data, but binary outputs tend to be much more tightly linked to the details of the specific software that created it
  • Binary serialization and deserialization are much faster
  • The result of binary serialization is much more compact, meaning it requires less space and is faster to transfer, particularly over a network

Installing Cereal

For this lesson, we’ll be using the cereal library, which is a popular choice for binary serialization. The cereal homepage is available here.

We can install cereal using our preferred method. In a previous lesson, we covered vcpkg, a C++ package manager that makes library installation easier.

Users of vcpkg can install cereal using this command:

.\vcpkg install cereal

Cereal reads and writes the serialized data to streams, so this lesson is building on previous lessons, where we covered those concepts.

In the following examples, we’ll be using file streams, which we’ll assume readers are already familiar with. We have dedicated lessons on the file system and file streams earlier in the course:

Troubleshooting: warning STL4034: std::aligned_storage and std::aligned_storage_t are deprecated in C++23

When working with cereal in C++23, we may see compilation errors caused by std::aligned_storage and std::aligned_storage_t being deprecated. This may eventually be fixed in later versions of cereal.

For now, if we encounter these errors, we can provide a preprocessor definition to disable them.

This definition needs to happen before we #include any cereal files:

// Suppress compilation errors
#define _SILENCE_CXX23_ALIGNED_STORAGE_DEPRECATION_WARNING

// Cereal includes must come after
#include <cereal/archives/binary.hpp>
#include <cereal/types/memory.hpp>

int main() {
  // ...
}

Cereal Archives

The key objects within cereal that manage the serialization and deserialization process are called archives. Archives have a direction:

  • Output archives are for serializing our C++ objects, and then sending that data to an output stream
  • Input archives are for reading data from an input stream, and then deserializing it to create C++ objects

Cereal supports a few options for the data format we want to use, including binary, XML, JSON, and the ability to define our own. In this lesson, we’ll focus on binary serialization.

We #include archives from the cereal/archives directory.

Let's start by creating a binary output archive, which will send data to an output file stream:

#include <cereal/archives/binary.hpp>
#include <fstream>

int main() {
  std::ofstream File{"SaveFile"};
  cereal::BinaryOutputArchive OArchive(File);
}

Archives are callable, which is how we use them to serialize our data:

#include <cereal/archives/binary.hpp>
#include <fstream>

int main() {
  std::ofstream File{"SaveFile"};
  cereal::BinaryOutputArchive OArchive(File);

  int SomeInt{42};
  float SomeFloat{3.14f};
  bool SomeBoolean{true};

  OArchive(SomeInt, SomeFloat, SomeBoolean);
}

After running this code, we should have our three objects serialized to a file on our hard drive. Remember, binary data is not intended to be human-readable, so the contents of this file will likely appear to be junk.

But, we can deserialize it back into our application, to ensure everything is working correctly. Note, in the following example, our file stream is now an input stream, and our cereal archive is now an input archive:

#include <cereal/archives/binary.hpp>
#include <fstream>
#include <iostream>

int main() {
  std::ifstream File{"SaveFile"};
  cereal::BinaryInputArchive IArchive(File);

  int SomeInt;
  float SomeFloat;
  bool SomeBoolean;

  IArchive(SomeInt, SomeFloat, SomeBoolean);

  std::cout << "SomeInt: " << SomeInt;
  std::cout << "\nSomeFloat: " << SomeFloat;
  std::cout << "\nSomeBoolean: "
            << std::boolalpha << SomeBoolean;
}
SomeInt: 42
SomeFloat: 3.14
SomeBoolean: true

Archives Behave like Streams

We do not need to create our entire archive as a single expression. We can build our archives up over time:

#include <cereal/archives/binary.hpp>
#include <fstream>

int main() {
  std::ofstream File{"SaveFile"};

  cereal::BinaryOutputArchive OArchive(File);

  int SomeInt{42};
  OArchive(SomeInt);

  float SomeFloat{3.14f};
  OArchive(SomeFloat);

  bool SomeBoolean{true};
  OArchive(SomeBoolean);
}

Under the hood, they behave as streams. We can imagine them having an internal position, that advances on every invocation, ensuring our new data gets appended to the end.

This concept also applies when reading data from archives - we can do so incrementally. Every argument we pass to the archive’s () operator will be updated, and then an internal pointer is advanced.

As a result, the next time we call it, we get the next piece of data:

#include <cereal/archives/binary.hpp>
#include <fstream>
#include <iostream>

int main() {
  std::ifstream File{"SaveFile"};

  cereal::BinaryInputArchive IArchive(File);

  int SomeInt;
  IArchive(SomeInt);
  std::cout << "SomeInt: " << SomeInt;

  float SomeFloat;
  IArchive(SomeFloat);
  std::cout << "\nSomeFloat: " << SomeFloat;

  bool SomeBoolean;
  IArchive(SomeBoolean);
  std::cout << "\nSomeBoolean: "
            << std::boolalpha << SomeBoolean;
}
SomeInt: 42
SomeFloat: 3.14
SomeBoolean: true

Because of this, we need to be mindful of how data is ordered in our streams. We should deserialize data in the same order we serialized it, or we’ll get crashes and unpredictable behavior:

#include <cereal/archives/binary.hpp>
#include <fstream>

int main() {
  // Serialize
  {
    std::ofstream File{"SaveFile"};

    cereal::BinaryOutputArchive OArchive(File);

    int SomeInt{42};
    float SomeFloat{3.14f};

    OArchive(SomeInt, SomeFloat);
  }

  // Deserialize
  {
    std::ifstream File{"SaveFile"};

    cereal::BinaryInputArchive IArchive(File);

    int SomeInt;
    float SomeFloat;

    // Order has changed
    IArchive(SomeFloat, SomeInt);

    std::cout << "SomeInt: " << SomeInt;
    std::cout << "\nSomeFloat: " << SomeFloat;
  }
}
SomeInt: 1078523331
SomeFloat: 5.88545e-44

Serializing Standard Library Types

When dealing with more complex types, we need to define functions that describe how objects of that type will be serialized.

For most standard library types, Cereal has already done that for us. All we need to do is #include appropriate files, contained within the cereal/types directory.

For example, we can serialize std::string and std::vector objects by including string.hpp and vector.hpp respectively:

#include <cereal/archives/binary.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <fstream>

int main() {
  std::ofstream File{"SaveFile"};

  cereal::BinaryOutputArchive OArchive(File);

  std::vector SomeInts{1, 2, 3};
  std::string SomeString{"Hello"};

  OArchive(SomeInts, SomeString);
}

We also #include them when we need to deserialize:

#include <cereal/archives/binary.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <fstream>

int main() {
  std::ifstream File{"SaveFile"};

  cereal::BinaryInputArchive IArchive(File);

  std::vector<int> SomeInts;
  std::string SomeString;

  IArchive(SomeInts, SomeString);

  std::cout << "SomeInts: ";
  for (auto i : SomeInts) {
    std::cout << i << ", ";
  }
  std::cout << "\nSomeString: " << SomeString;
}
SomeInts: 1, 2, 3,
SomeString: Hello

Needing to know what type of data is on an archive before we deserialize it may seem problematic. But in practice, it tends not to be an issue.

As we’ll see, we tend to keep serialization and deserialization code within the same file, so we’re already including the required files anyway,

Serializing User-Defined Types

To add Cereal support to our custom types, we need to provide functions for serializing and deserializing them.

These functions are called save and load respectively, and they receive a reference to the archive we need to save or load our object from. The save function must be const.

Typically, the archive will be a templated type for these functions, to allow them to work across different archive types.

Remember, we may need additional #include directives to support the types we’re serializing and deserializing. In the following example, we’re serializing a std::string, so we need to include cereal/types/string.hpp:

// Character.h
#pragma once
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;

  template <class Archive>
  void save(Archive& Output) const {
    Output(Name, Level);
  }

  template <class Archive>
  void load(Archive& Input) {
    Input(Name, Level);
  }
};

Typically, we don’t want these functions to be part of the public API of our classes.

We can make them private, and give cereal access by including cereal/access.hpp, and then have our class befriend cereal::access:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;

 private:
  friend class cereal::access;
  template <class Archive>
  void save(Archive& Output) const {
    Output(Name, Level);
  }

  template <class Archive>
  void load(Archive& Input) {
    Input(Name, Level);
  }
};

The Combined serialize() Function

In this example, and many cases, the code in our save and load functions is effectively the same. Cereal allows us to create a combined serialize function to simplify things in scenarios like this:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;

 private:
  friend class cereal::access;
  template <class Archive>
  void serialize(Archive& Data) {
    Data(Name, Level);
  }
};

Now, we can serialize our custom objects as needed:

#include <cereal/archives/binary.hpp>
#include <fstream>
#include "Character.h"

int main() {
  Character Player{"Bob", 50};

  std::ofstream File{"SaveFile"};
  cereal::BinaryOutputArchive OArchive(File);
  OArchive(Player);
}

And deserialize them later:

#include <cereal/archives/binary.hpp>
#include <fstream>
#include "Character.h"

int main() {
  Character Player;

  std::ifstream File{"SaveFile"};
  cereal::BinaryInputArchive OArchive(File);
  OArchive(Player);

  std::cout << Player.Name << " (Level "
            << Player.Level << ")";
}
Bob (Level 50)

Serializing Pointers

Cereal does not support the serialization of raw pointers (eg int* ) or references (eg int&), but we can serialize smart pointers, such as std::unique_ptr<int>.

We can do this by including the cereal/types/memory.hpp header:

#include <cereal/archives/binary.hpp>
#include <cereal/types/memory.hpp>
#include <fstream>

int main() {
  // Serialize
  {
    auto Number{std::make_unique<int>(42)};

    std::ofstream File{"SaveFile"};
    cereal::BinaryOutputArchive OArchive(File);
    OArchive(Number);
  }

  // Deserialize
  {
    std::unique_ptr<int> Number;

    std::ifstream File{"SaveFile"};
    cereal::BinaryInputArchive IArchive(File);
    IArchive(Number);

    std::cout << *Number;
  }
}
42

Deserializing Types without Default Constructors

To create an object from our serialized data, cereal first default constructs the object (ie, it creates it with no arguments) and then calls our serialization function to update the object.

This only works if our object is default constructible. Below, we’ve updated our Character object to now require that a Name is provided at construction:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  Character(std::string Name) : Name{Name} {}
  std::string Name;
  int Level;

 private:
  friend class cereal::access;
  template <class Archive>
  void serialize(Archive& Data) {
    Data(Name, Level);
  }
};

If we update our main function to serialize and then deserialize a Character, we’ll see a compilation error:

#include <cereal/archives/binary.hpp>
#include <cereal/types/memory.hpp>
#include <fstream>

#include "Character.h"

int main() {
  // Serialize
  {
    auto Player{
        std::make_unique<Character>("Bob")};

    Player->Level = 50;

    std::ofstream File{"SaveFile"};
    cereal::BinaryOutputArchive OArchive(File);
    OArchive(Player);
  }

  // Deserialize
  {
    std::unique_ptr<Character> Player;

    std::ifstream File{"SaveFile"};
    cereal::BinaryInputArchive IArchive(File);
    IArchive(Player);
  }
}
error C2512: 'Character::Character':
no appropriate default constructor available

We have a few options for dealing with this. The most obvious solution is that we can provide a default constructor. It doesn’t need to be public - it just needs to be accessible to cereal::access, which can be done using the friend technique we’ve already been using:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  Character(std::string Name) : Name{Name} {}
  std::string Name;
  int Level;

 private:
  friend class cereal::access;
  Character(){};  // Default constuctor
  template <class Archive>
  void serialize(Archive& Data) {
    Data(Name, Level);
  }
};

Another option is to define a static load_and_construct method on our type. This will receive two arguments - the archive to load data from, and a cereal::construct object. This object allows us to both call the constructor for our type, and then access the constructed object:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  Character(std::string Name) : Name{Name} {}
  std::string Name;
  int Level;

 private:
  friend class cereal::access;

  template <class Archive>
  static void load_and_construct(
      Archive& Data,
      cereal::construct<Character>& Construct) {
    // Local variable to temporarily hold data
    std::string Name;

    // Update local variable from the archive
    Data(Name);

    // Call the Character constructor
    Construct(Name);

    // Now that our object is constructed, we
    // can access variables and methods using ->
    // Reading a value:
    std::cout << "Name:" << Construct->Name;

    // Writing a value:
    Data(Construct->Level);
    std::cout << "\nLevel:" << Construct->Level;
  }

  template <class Archive>
  void serialize(Archive& Data) {
    Data(Name, Level);
  }
};
Name:Bob
Level:50

Our last option is very similar to the previous - it just implements the load_and_construct function outside of our class, rather than as a static member function.

To do this, we provide a specialization for the LoadAndConstruct struct within the cereal namespace:

namespace cereal {
template <>
struct LoadAndConstruct<Character> {
  // ...
};
}

Within that specialization, we’d implement the load_and_construct method, in the same way as the previous example:

namespace cereal {
template <>
struct LoadAndConstruct<Character> {
  template <class Archive>
  static void load_and_construct(
      Archive& Data,
      cereal::construct<Character>& Construct) {
      // ...
  }
};
}

Serializing with Inheritance

Let's imagine we have an object of the Monster class, and Monster inherits from Character:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;

 private:
  friend class cereal::access;
  template <class Archive>
  void serialize(Archive& Data) {
    Data(Name, Level);
  }
};

class Monster : public Character {
 public:
  bool isHostile;

 private:
  friend class cereal::access;
  template <class Archive>
  void serialize(Archive& Data) {
    // TODO: Serialise inherited members too
    Data(isHostile);
  }
};

When we’re serializing a Monster object, we don’t just want the isHostile variable to be serialized. Our Monster is also a Character, so the Character variables (Name and Level) need to be serialized too.

We could reference those variables directly in our Monster’s serialize object:

void serialize(Archive& Data) {
  Data(isHostile, Name, Level);
}

But this is not a good design. If a new variable is added to Character, our code will have hard-to-detect bugs if we forget to add this new variable to the child classes’ serialize functions.

Another option would be to call the base class serialize function:

void serialize(Archive& Data) {
  Character::serialize();
  Data(isHostile);
}

But this is not always an option. Our base class might be using separate load and save methods instead. This approach would also require us to make our serialize functions protected rather than private.

Another option, which is frequently the best choice, is to use cereal’s cereal::base_class function instead. It receives the base class as a template parameter, and a pointer to the current object (via the this keyword) as a function parameter:

void serialize(Archive& Data) {
  Data(
    cereal::base_class<Character>(this),
    isHostile
  );
}

If the inheritance is virtual, we use cereal::virtual_base_class instead:

class Monster : virtual Character {
 using c = cereal;
 public:
  bool isHostile;

 private:
  friend class c::access;
  template <class Archive>
  void serialize(Archive& Data) {
    Data(
      c::virtual_base_class<Character>(this),
      isHostile
    );
  }
};

Over in our main function, we can now confirm both inherited and local members are being serialized and deserialized correctly:

#include <cereal/archives/binary.hpp>
#include <fstream>

#include "Character.h"

int main() {
  // Serialize
  {
    std::ofstream File{"SaveFile"};
    cereal::BinaryOutputArchive OArchive(File);

    Monster Enemy{"Goblin", 10, true};
    OArchive(Enemy);
  }

  // Deserialize
  {
    std::ifstream File{"SaveFile"};
    cereal::BinaryInputArchive IArchive(File);

    Monster Enemy;
    IArchive(Enemy);

    std::cout << Enemy.Name
              << (Enemy.isHostile
                      ? " is hostile"
                      : " is not hostile");
  }
}
Goblin is hostile

Deserializing with Polymorphic Types

An interesting problem arises when we’re serializing in scenarios where we're using run-time polymorphism.

Below, we have a Character pointer, which is specifically pointing to a Monster:

#include <cereal/archives/binary.hpp>
#include <cereal/types/memory.hpp>
#include <fstream>
#include "Character.h"

int main() {
  // Serialize
  {
    std::ofstream File{"SaveFile"};
    cereal::BinaryOutputArchive OArchive(File);

    // Character pointer pointing at a Monster
    std::unique_ptr<Character> Enemy{
        std::make_unique<Monster>()};

    Enemy->Name = "Goblin";

    OArchive(Enemy);
  }

  // Deserialize
  {
    std::ifstream File{"SaveFile"};
    cereal::BinaryInputArchive IArchive(File);

    // In the archive, this object is specifically
    // a Monster, not just a Character
    std::unique_ptr<Character> Enemy;
    IArchive(Enemy);

    Monster* EnemyMonsterPtr{
        dynamic_cast<Monster*>(Enemy.get())};

    std::cout << Enemy->Name
              << (EnemyMonsterPtr
                      ? " is a Monster"
                      : " is not a Monster");
  }
}

The serialization part of this code will work correctly. At run time, cereal knows what our Character pointer is specifically pointing at, so it can serialize it as the correct derived type - Monster, in this case.

However, when it comes to deserialization, cereal needs a little more help. It’s attempting to deserialize a Monster into a Character pointer, but it doesn’t inherently understand the relationship between these two types.

It needs enough knowledge of our inheritance tree to establish the link between Character and Monster. We can help it out in three ways:

First, we include the header file that contains the polymorphic utilities:

#include <cereal/types/polymorphic.hpp>

Next, we make cereal aware of our derived type, using the CEREAL_REGISTER_TYPE macro:

CEREAL_REGISTER_TYPE(Monster);

Finally, we tell cereal that our derived type has a polymorphic relationship with a base type, using the CEREAL_REGISTER_POLYMORPHIC_RELATION macro. We pass the base type first, and the derived type second:

CEREAL_REGISTER_POLYMORPHIC_RELATION(Character,
                                     Monster)

Establishing Relationships through Serialization Functions

Cereal can also establish the relationship between a derived and base class if the derived class calls the cereal::base_class or cereal::virtual_base_class functions, covered in the Serializing with Inheritance section above.

Therefore, if we're using those functions as part of our serialization/deserialization functions, using the macro to register the polymorphic relationship is optional.

Note, that both these macro calls need to happen after we #include the cereal archives we’ll be using. Our code is complying with this as we are calling the macros in Character.h which is included after cereal/archives/binary.hpp in our main file.

Our complete Character and Monster header file now looks like this. We've added a virtual destructor to make our Character class polymorphic:

#include <cereal/access.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/polymorphic.hpp>

class Character {
 public:
  std::string Name;
  int Level;
  virtual ~Character() = default;

 protected:
  friend class cereal::access;
  template <class Archive>
  void serialize(Archive& Data) {
    Data(Name, Level);
  }
};

class Monster : public Character {
 public:
  bool isHostile;

 private:
  friend class cereal::access;
  template <class Archive>
  void serialize(Archive& Data) {
    Data(cereal::base_class<Character>(this),
         isHostile);
  }
};

CEREAL_REGISTER_TYPE(Monster);
CEREAL_REGISTER_POLYMORPHIC_RELATION(Character,
                                     Monster)

With no changes to main.cpp, our code should now compile and run as expected, with the following output:

Goblin is a Monster

When working with multiple layers of inheritance, cereal can automatically understand relationships through intermediate classes - we don't need to explicitly define them.

For example, if we tell cereal that Character is a base class for Monster, and Monster is a base class of Goblin, cereal is automatically able to infer that Character is also a base class for Goblin.

Class Versioning

When working with serialization, we must be mindful of version mismatches between our archives and code. For example, if we’re making a game, any update we release is going to make changes to some of our classes.

Many of those changes would impact serialization. For example, if we add a field to our Character class, any object serialized before that field was added is not going to contain that value in the archive.

We need to come up with a strategy to mitigate this, as our players are not going to be happy if every update we release makes their previous save files unusable.

In other words, we want our program to be backward compatible - new versions of our program should be able to load old versions of our archives.

A common strategy to accomplish this is to include a Version integer in our classes, right from the very first version of our software.

#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;

 private:
  friend class cereal::access;

  // Setting the version
  static inline int Version{1};

  template <class Archive>
  void save(Archive& Data) {
    // Saving the version to the archive
    Data(Version, Name, Level);
  }

  template <class Archive>
  void load(Archive& Data) {
    // Reading the version from the archive
    int ArchiveVersion;
    Data(ArchiveVersion);
    std::cout << "Archive Version: "
              << ArchiveVersion
              << "\nClass Version: " << Version;
    Data(Name, Level);
  }
};
Archive Version: 1
Class Version: 1

When we need to make changes to our class, we can increase the version integer to 2.

Then, when we load an archive, we can check what version of our class created it, and take the steps we need to maintain backward compatibility. In the following example, version 2 of our class added the Health variable.

If our load function receives an archive version from before this time, it knows the archive won’t include the Health value, so it sets it to a fallback value of 100.

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;
  int Health;
  static inline int Version{2};

 private:
  friend class cereal::access;

  template <class Archive>
  void save(Archive& Data) const {
    Data(Version, Name, Level, Health);
  }

  template <class Archive>
  void load(Archive& Data) {
    int ArchiveVersion;
    Data(ArchiveVersion);
    std::cout << "Archive Version: "
              << ArchiveVersion
              << "\nClass Version: " << Version;

    if (ArchiveVersion < 2) {
      Data(Name, Level);
      Health = 100;
    } else {
      Data(Name, Level, Health);
    }
  }
};

Cereal supports this as a native feature. Rather than having a version integer in our class, we can instead call the CEREAL_CLASS_VERSION macro, passing in our class name, and the current version:

CEREAL_CLASS_VERSION(Character, 1)

Variants of all the serialization functions are available that provide the archive version as an additional uint32_t. This allows us to check which version of our class created the data we’re seeing:

#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>

class Character {
 public:
  std::string Name;
  int Level;
  int Health;

 private:
  friend class cereal::access;

  template <class Archive>
  void save(Archive& Data,
            std::uint32_t) const {
    Data(Name, Level, Health);
  }

  template <class Archive>
  void load(Archive& Data,
            std::uint32_t ArchiveVersion) {
    std::cout << "Archive Version: "
              << ArchiveVersion;

    if (ArchiveVersion < 2) {
      Data(Name, Level);
      Health = 100;
    } else {
      Data(Name, Level, Health);
    }
  }
};

CEREAL_CLASS_VERSION(Character, 2)

If the version of our software that generated the archive hadn’t specified the class version (using the CEREAL_CLASS_VERSION macro) for the object we’re trying to deserialize, the version passed to our function will be 0.

Note, when using separate save and load functions, we need to be consistent in whether we use the versioned or unversioned overload within the same class. In other words, if our load function has the archive version parameter, our save function will need it too, even though it typically won’t make use of it.

template <class Archive>
void save(Archive& Data, std::uint32_t) const {}

template <class Archive>
void load(Archive& Data,
          std::uint32_t ArchiveVersion) {}

If we’re not consistent, we will encounter issues:

  • Data serialized from a versioned save function is incompatible with an unversioned load function.
  • Data serialized from an unversioned save function is incompatible with a versioned load function.

An additional side effect of these constraints means that, if we ever want to use versioning in our class, we should try to enable it right from the start. If we enable it as part of an update, we’ll have a much harder time trying to deserialize data that was generated before that update.

Portable Binaries / Endianness

There are multiple ways that computers can read binary data - this difference is sometimes referred to as endianness, If the reason we’re serializing data is to have it read on a different computer, the possibility that the other machine is using a different endianness is something we need to consider.

Cereal can take care of this for us, by using a portable binary archive.

Portable variations are used in the same way as their non-portable counterparts. We just need to update our #include directives and types to use the portable variants:

#include <cereal/archives/portable_binary.hpp>
#include <fstream>
#include <iostream>

int main() {
  // Serialize
  {
    std::ofstream File{"SaveFile"};
    cereal::PortableBinaryOutputArchive
        OArchive(File);

    int Number{5};
    OArchive(Number);
  }

  // Deserialize
  {
    std::ifstream File{"SaveFile"};
    cereal::PortableBinaryInputArchive IArchive(
        File);

    int Number;
    IArchive(Number);

    std::cout << "Number: " << Number;
  }
}

Portable binaries incur some performance costs - they are larger, and take longer to serialize and deserialize. Therefore, in performance-critical contexts, we shouldn’t use them unless we know our archives are going to be used across machines that have different endianness.

Summary

This lesson has taken us through binary serialization in C++. Here's a recap of the key points we've covered:

  1. Concept of Serialization and Deserialization: We began by exploring the fundamental concepts of serialization and deserialization, understanding their role in saving and transmitting the state of objects in a program.
  2. Binary vs. JSON Serialization: We compared binary serialization with JSON, highlighting binary's efficiency and compactness, making it a preferred choice for performance-critical uses.
  3. Using Cereal Library: The cereal library was introduced as a tool for binary serialization in C++. We walked through its installation and basic usage, building a strong foundation for your serialization tasks.
  4. Practical Examples and Code Implementation: Through various examples, you've learned how to serialize basic data types, standard library types, and custom user-defined types.
  5. Handling Complex Scenarios: We delved into more advanced topics like serialization with inheritance, dealing with polymorphic types, and class versioning, preparing us for complex real-world projects.
  6. Addressing Portability and Endianness: Finally, we touched upon the concept of endianness and the use of portable binaries, ensuring our serialized data maintains its integrity across different systems.

Was this lesson useful?

Part 2 - Available Now

Professional C++

Advance straight from the beginner course, dive deeper into C++, and learn expert workflows

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Libraries and Dependencies
Part 2 - Available Now

Professional C++

Advance straight from the beginner course, dive deeper into C++, and learn expert workflows

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved