cereal
library.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.
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:
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:
std::aligned_storage
and std::aligned_storage_t
are deprecated in C++23When 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() {
// ...
}
The key objects within cereal that manage the serialization and deserialization process are called archives. Archives have a direction:
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
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
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,
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);
}
};
serialize()
FunctionIn 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)
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
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) {
// ...
}
};
}
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
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)
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
.
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:
save
function is incompatible with an unversioned load
function.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.
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.
This lesson has taken us through binary serialization in C++. Here's a recap of the key points we've covered:
A detailed and practical tutorial for binary serialization in modern C++ using the cereal
library.
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.