Binary Serialization using Cereal
A detailed and practical tutorial for binary serialization in modern C++ using the 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.
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.
Installing vcpkg on Windows
An introduction to C++ package managers, and a step-by-step guide to installing vcpkg on Windows and Visual Studio.
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:
Working with the File System
Create, delete, move, and navigate through directories and files using the std::filesystem
library.
File Streams
A detailed guide to reading and writing files in C++ using the standard library's fstream
type
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);
}
};
Friend Classes and Functions
An introduction to the friend
keyword, which allows classes to give other objects and functions enhanced access to its members
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>
.
Smart Pointers and std::unique_ptr
An introduction to memory ownership using smart pointers and std::unique_ptr
in C++
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) {
// ...
}
};
}
Template Specialization
A practical guide to template specialization in C++ covering full and partial specialization, and the scenarios where they're useful
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)
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 unversionedload
function. - Data serialized from an unversioned
save
function is incompatible with a versionedload
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.