From Cereal
Cereal and the JaiScript archives share a shape: a type describes itself once, in a serialize
function or a save/load pair, and an archive drives it in both directions. Three things
are more than spelling:
- The files. The archives write their own JSON and binary formats, so a file Cereal wrote is converted once: loaded with Cereal, saved with JaiScript.
- Registration. A polymorphic type gets its identity from a
jai::property_ownerbase or ajai::registrar, in place ofCEREAL_REGISTER_TYPE. - Versions. Both formats name every property in every record, so most of what
CEREAL_CLASS_VERSIONdid is unnecessary.
A Boost.Serialization codebase takes the same path; From Boost.Serialization lists its spellings. The archives are documented in Serialization.
The concept map
Headers: <jaiscript/serialization/convenience.hpp> for the archives and the one-call entry
points, <jaiscript/serialization/construct.hpp> for load_and_construct,
<jaiscript/core/registrar.hpp> for registrars, and
<jaiscript/properties/macros.hpp> with
<jaiscript/properties/property_serialization.hpp> for properties.
Archives and entry points
| Cereal | JaiScript |
|---|---|
cereal::JSONOutputArchive ar(os); ar(value); | std::string text = jai::to_json(*eng, value); (an optional third argument is the indent: 2 by default, 0 for one line) |
cereal::JSONInputArchive ar(is); ar(value); | T value = jai::from_json<T>(*eng, text); |
cereal::BinaryOutputArchive, cereal::PortableBinaryOutputArchive | std::vector<std::uint8_t> blob = jai::to_binary(*eng, value);, or to_binary_string, to_base64; little-endian on every platform, so already portable |
cereal::BinaryInputArchive, cereal::PortableBinaryInputArchive | jai::from_binary<T>(*eng, blob), from_binary_string, from_base64 |
cereal::XMLOutputArchive, cereal::XMLInputArchive | no XML format: JSON or binary |
| an archive object, complete when it is destroyed | jai::serialization::json_archive_writer / json_archive_reader and binary_archive_writer / binary_archive_reader, for a root you lay out by hand (below); the result is ar.str() or ar.data() once the root record closes |
a std::ostream / std::istream | a std::string or a std::vector<std::uint8_t>; the host reads and writes the file |
| (nothing) | an engine (auto eng = jai::engine::make();): every entry point takes one, because a payload can hold script classes and bound types it resolves. One engine serves every save and load. |
Names
| Cereal | JaiScript |
|---|---|
CEREAL_NVP(hp) | JAI_NVP(hp): the key is the argument's text, "hp" |
cereal::make_nvp("hp", hp) | ar("hp", hp), or jai::serialization::make_nvp("hp", hp) |
ar(CEREAL_NVP(a), CEREAL_NVP(b)) | ar(JAI_NVP(a), JAI_NVP(b)); the two-argument form ar("name", value) takes one pair per call |
ar(hp, mana), which Cereal names value0, value1 | no positional form: every field has a name |
Serialization functions
| Cereal | JaiScript |
|---|---|
member serialize(Archive&), member save(Archive&) const with load(Archive&) | unchanged |
the member forms with a std::uint32_t const version parameter | accepted, and preferred over the one-parameter forms, so no forwarding shims are needed. The number is not a record version (Versions). |
free serialize(Archive&, T&), save(Archive&, const T&), load(Archive&, T&) found by ADL | unchanged in the two-parameter form. A free function with a version parameter is not found: drop the parameter. |
save_minimal / load_minimal | no minimal form: a type with a function always writes a record. Convert in the owning function (ar("tint", tint.to_hex())) or store the primitive. |
cereal::specialize<Archive, T, cereal::specialization::...>, CEREAL_SPECIALIZE_FOR_ALL_ARCHIVES | deleted: the choice is fixed (a member save/load before a member serialize, members before free functions) |
ar(cereal::base_class<Base>(this)) | Base::serialize(ar); (or Base::save(ar) / Base::load(ar)): the base's fields join the derived class's record |
ar(cereal::virtual_base_class<Base>(this)) | the virtual base's function called once, from the most-derived class |
cereal::make_size_tag(n) in a hand-written container | ar.begin_array(n), ar.write_element(v) per element, ar.end_array(); a reader takes std::size_t n = ar.begin_array() and ar.read_element(v) |
cereal::binary_data(ptr, size) | a std::vector<std::uint8_t> or std::string field |
Versions and polymorphism
| Cereal | JaiScript |
|---|---|
CEREAL_CLASS_VERSION(T, n) | no class version for a C++ type: properties are found by name. A type whose meaning changes writes a version as a property of its own (Versions). |
CEREAL_REGISTER_TYPE(Derived) with CEREAL_REGISTER_POLYMORPHIC_RELATION(Base, Derived) | class Derived : public jai::property_owner<Derived, Base> declares both; a plain class takes static jai::registrar<Derived, void, jai::bases<Base>> reg("Derived"); |
CEREAL_REGISTER_TYPE_WITH_NAME(Derived, "name") | the registrar's name argument, or JAI_PROPERTY_OWNER_AS(Derived, "name") inside a property_owner |
#include <cereal/types/polymorphic.hpp> | <jaiscript/core/registrar.hpp> for a registrar; a property_owner needs nothing more |
CEREAL_REGISTER_DYNAMIC_INIT, CEREAL_FORCE_DYNAMIC_INIT | no macro: a registrar is a static object, so it lives in a translation unit the program links (a linker drops a static library's object file nothing references) |
a std::unique_ptr<Base> holding a derived object | a unique_ptr is written by its static type, with no $type, so a polymorphic owner is a std::shared_ptr<Base> |
Pointers and identity
| Cereal | JaiScript |
|---|---|
std::shared_ptr<T> (with <cereal/types/memory.hpp>) | built in: the first write is {"$id": N, "$val": {...}}, every later write of the same object is {"$id": N}, and the load re-aliases them; through a base pointer, "$type" names the derived class |
std::weak_ptr<T> | {"$ref": N}, with the object inline when the weak pointer is its first reference in the save; an expired one is null |
std::unique_ptr<T> | {"$val": {...}}, by the static type |
| raw pointers (rejected) | rejected too: the object lives in a smart pointer |
| one archive per tracking scope | the same: objects stay shared only when one call saves the whole graph |
Containers and standard types
| Cereal | JaiScript |
|---|---|
#include <cereal/types/vector.hpp> and the other type headers | nothing to include: vector, list, deque, forward_list, array, map, unordered_map, set, unordered_set, pair, tuple, optional, variant and the three smart pointers are built in |
| maps | string keys as an object, other keys as an array of [key, value] pairs |
std::optional, std::variant | [has_value, value] and [index, value] |
| enums | the underlying integer, as Cereal writes them |
C arrays, std::bitset, std::chrono durations, std::complex, std::queue, std::stack, std::valarray | no built-in form: a std::array in place of a C array, and the rest converted in the owning function (a duration as its count, a bitset as its string) |
Construction, access, context and errors
| Cereal | JaiScript |
|---|---|
friend class cereal::access; | friend jai::access; (both lines while both libraries are in the build) |
static void load_and_construct(Archive&, cereal::construct<T>&) | the same static member over jai::serialization::construct<T>, with two parameters (no version); it builds a std::shared_ptr<T> whose T has no default constructor |
a cereal::LoadAndConstruct<T> specialization for a type you cannot edit | no external form: wrap the type, or give it a default constructor |
cereal::UserDataAdapter and cereal::get_user_data<T>(ar) | jai::from_json<T>(*eng, text, ctx) (and the binary forms) carry a context; inside a load, ar.template get_user_context<Context>() returns a pointer to it, or null when none was passed |
cereal::Exception | jai::serialization_error, a jai::runtime_error (which derives from std::runtime_error) |
A worked conversion
A small save game, five ways: items, an inventory with private fields, and a player that holds the inventory
through a shared_ptr and derives hpFraction on load. Every form but
JAI_PROPERTY, which is shorter, keeps each type and field at the same line number.
Cereal with the functions inside each type. Player splits save from load because a load also derives hpFraction.
#include <cereal/archives/json.hpp>
#include <cereal/types/memory.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <sstream>
struct Item {
std::string name;
int count = 1;
template<class Archive>
void serialize(Archive& ar) {
ar(CEREAL_NVP(name));
ar(CEREAL_NVP(count));
}
};
class Inventory {
public:
void add(const Item& item) { items.push_back(item); }
private:
friend class cereal::access;
std::vector<Item> items;
int capacity = 20;
template<class Archive>
void serialize(Archive& ar) {
ar(CEREAL_NVP(items));
ar(CEREAL_NVP(capacity));
}
};
struct Player {
std::string name;
int hp = 100;
std::shared_ptr<Inventory> inventory = std::make_shared<Inventory>();
float hpFraction = 1.0f;
template<class Archive>
void save(Archive& ar) const {
ar(CEREAL_NVP(name));
ar(CEREAL_NVP(hp));
ar(CEREAL_NVP(inventory));
}
template<class Archive>
void load(Archive& ar) {
ar(CEREAL_NVP(name));
ar(CEREAL_NVP(hp));
ar(CEREAL_NVP(inventory));
hpFraction = hp / 100.0f;
}
};
int main() {
Item potion;
potion.name = "potion";
potion.count = 3;
Player player;
player.name = "Vex";
player.hp = 40;
player.inventory->add(potion);
std::stringstream stream;
{
cereal::JSONOutputArchive ar(stream);
ar(player);
}
std::string text = stream.str();
Player loaded;
{
cereal::JSONInputArchive ar(stream);
ar(loaded);
}
}Cereal with the functions after each type, found by argument-dependent lookup. The types hold only data.
#include <cereal/archives/json.hpp>
#include <cereal/types/memory.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <sstream>
struct Item {
std::string name;
int count = 1;
};
template<class Archive>
void serialize(Archive& ar, Item& item) {
ar(cereal::make_nvp("name", item.name));
ar(cereal::make_nvp("count", item.count));
}
class Inventory {
public:
void add(const Item& item) { items.push_back(item); }
private:
template<class Archive> friend void serialize(Archive& ar, Inventory& inventory);
std::vector<Item> items;
int capacity = 20;
};
template<class Archive>
void serialize(Archive& ar, Inventory& inventory) {
ar(cereal::make_nvp("items", inventory.items));
ar(cereal::make_nvp("capacity", inventory.capacity));
}
struct Player {
std::string name;
int hp = 100;
std::shared_ptr<Inventory> inventory = std::make_shared<Inventory>();
float hpFraction = 1.0f;
};
template<class Archive>
void save(Archive& ar, const Player& player) {
ar(cereal::make_nvp("name", player.name));
ar(cereal::make_nvp("hp", player.hp));
ar(cereal::make_nvp("inventory", player.inventory));
}
template<class Archive>
void load(Archive& ar, Player& player) {
ar(cereal::make_nvp("name", player.name));
ar(cereal::make_nvp("hp", player.hp));
ar(cereal::make_nvp("inventory", player.inventory));
player.hpFraction = player.hp / 100.0f;
}
int main() {
Item potion;
potion.name = "potion";
potion.count = 3;
Player player;
player.name = "Vex";
player.hp = 40;
player.inventory->add(potion);
std::stringstream stream;
{
cereal::JSONOutputArchive ar(stream);
ar(player);
}
std::string text = stream.str();
Player loaded;
{
cereal::JSONInputArchive ar(stream);
ar(loaded);
}
}The port of Cereal's intrusive form: the same member functions in the same places, with JAI_NVP for CEREAL_NVP.
#include <jaiscript/core/engine.hpp>
#include <jaiscript/serialization/convenience.hpp>
#include <memory>
#include <string>
#include <vector>
struct Item {
std::string name;
int count = 1;
template<class Archive>
void serialize(Archive& ar) {
ar(JAI_NVP(name));
ar(JAI_NVP(count));
}
};
class Inventory {
public:
void add(const Item& item) { items.push_back(item); }
private:
friend jai::access;
std::vector<Item> items;
int capacity = 20;
template<class Archive>
void serialize(Archive& ar) {
ar(JAI_NVP(items));
ar(JAI_NVP(capacity));
}
};
struct Player {
std::string name;
int hp = 100;
std::shared_ptr<Inventory> inventory = std::make_shared<Inventory>();
float hpFraction = 1.0f;
template<class Archive>
void save(Archive& ar) const {
ar(JAI_NVP(name));
ar(JAI_NVP(hp));
ar(JAI_NVP(inventory));
}
template<class Archive>
void load(Archive& ar) {
ar(JAI_NVP(name));
ar(JAI_NVP(hp));
ar(JAI_NVP(inventory));
hpFraction = hp / 100.0f;
}
};
int main() {
Item potion;
potion.name = "potion";
potion.count = 3;
Player player;
player.name = "Vex";
player.hp = 40;
player.inventory->add(potion);
auto eng = jai::engine::make();
std::string text = jai::to_json(*eng, player);
Player loaded = jai::from_json<Player>(*eng, text);
}Each type derives jai::property_owner and declares its fields with JAI_PROPERTY, which also makes them reflected and visible to scripts. The one function left is the load that derives hpFraction.
#include <jaiscript/core/engine.hpp>
#include <jaiscript/properties/macros.hpp>
#include <jaiscript/properties/property_serialization.hpp>
#include <jaiscript/serialization/convenience.hpp>
#include <memory>
#include <string>
#include <vector>
struct Item : jai::property_owner<Item> {
JAI_PROPERTY((std::string), name);
JAI_PROPERTY((int), count, 1);
};
class Inventory : public jai::property_owner<Inventory> {
public:
void add(const Item& item) { items->push_back(item); }
private:
JAI_PROPERTY((std::vector<Item>), items);
JAI_PROPERTY((int), capacity, 20);
};
struct Player : jai::property_owner<Player> {
JAI_PROPERTY((std::string), name);
JAI_PROPERTY((int), hp, 100);
JAI_PROPERTY((std::shared_ptr<Inventory>), inventory, std::make_shared<Inventory>());
JAI_TRANSIENT_PROPERTY((float), hpFraction, 1.0f);
template<class Archive>
void load(Archive& ar) {
load_owned_properties(ar);
hpFraction = hp / 100.0f;
}
};
int main() {
Item potion;
potion.name = "potion";
potion.count = 3;
Player player;
player.name = "Vex";
player.hp = 40;
player.inventory->add(potion);
auto eng = jai::engine::make();
std::string text = jai::to_json(*eng, player);
Player loaded = jai::from_json<Player>(*eng, text);
}The port of Cereal's non-intrusive form: the same free functions in the same places, with ar("name", value) for cereal::make_nvp.
#include <jaiscript/core/engine.hpp>
#include <jaiscript/serialization/convenience.hpp>
#include <memory>
#include <string>
#include <vector>
struct Item {
std::string name;
int count = 1;
};
template<class Archive>
void serialize(Archive& ar, Item& item) {
ar("name", item.name);
ar("count", item.count);
}
class Inventory {
public:
void add(const Item& item) { items.push_back(item); }
private:
template<class Archive> friend void serialize(Archive& ar, Inventory& inventory);
std::vector<Item> items;
int capacity = 20;
};
template<class Archive>
void serialize(Archive& ar, Inventory& inventory) {
ar("items", inventory.items);
ar("capacity", inventory.capacity);
}
struct Player {
std::string name;
int hp = 100;
std::shared_ptr<Inventory> inventory = std::make_shared<Inventory>();
float hpFraction = 1.0f;
};
template<class Archive>
void save(Archive& ar, const Player& player) {
ar("name", player.name);
ar("hp", player.hp);
ar("inventory", player.inventory);
}
template<class Archive>
void load(Archive& ar, Player& player) {
ar("name", player.name);
ar("hp", player.hp);
ar("inventory", player.inventory);
player.hpFraction = player.hp / 100.0f;
}
int main() {
Item potion;
potion.name = "potion";
potion.count = 3;
Player player;
player.name = "Vex";
player.hp = 40;
player.inventory->add(potion);
auto eng = jai::engine::make();
std::string text = jai::to_json(*eng, player);
Player loaded = jai::from_json<Player>(*eng, text);
}Inventory's fields are private, so the non-intrusive forms declare the free function a friend (or
make the fields public); JAI_PROPERTY declarations need no friend.
| Cereal: intrusive | Cereal: non-intrusive | JaiScript: intrusive | JaiScript: JAI_PROPERTY | JaiScript: non-intrusive | |
|---|---|---|---|---|---|
| Headers | cereal/archives/json.hpp and a cereal/types/ header per standard type | convenience.hpp alone | plus properties/macros.hpp and property_serialization.hpp | convenience.hpp alone | |
| Access to private fields | friend class cereal::access; | a friend declaration of the free function | friend jai::access; (left out: a compile error, as in Cereal) | none | a friend declaration of the free function |
| Where the member list lives | functions inside the type | functions after the type | functions inside the type | the declarations themselves | functions after the type |
| How a member is named | CEREAL_NVP(hp) | cereal::make_nvp("hp", player.hp) | JAI_NVP(hp) | by its declaration | ar("hp", player.hp) |
| Save and load split | member save const and load | free save and load | member save const and load | a load alone, calling load_owned_properties(ar) | free save and load |
| The never-saved member | a plain field the functions leave out | JAI_TRANSIENT_PROPERTY: reflected, never saved | a plain field the functions leave out | ||
| How the root is written | ar(player) on a JSONOutputArchive, complete when the archive is destroyed | jai::to_json(*eng, player) returns the text | |||
| The JSON | the record under "value0", in call order; the pointer as "ptr_wrapper" with "id" and "data" | the record at the root, in call order; the pointer as "$id" and "$val" | the same, in name order | as the member functions write it | |
Both Cereal forms write:
{
"value0": {
"name": "Vex",
"hp": 40,
"inventory": {
"ptr_wrapper": {
"id": 2147483649,
"data": {
"items": [
{
"name": "potion",
"count": 3
}
],
"capacity": 20
}
}
}
}
}The JaiScript member functions and free functions write:
{
"name": "Vex",
"hp": 40,
"inventory": {
"$id": 1,
"$val": {
"items": [
{
"name": "potion",
"count": 3
}
],
"capacity": 20
}
}
}The JAI_PROPERTY form writes the same record with each level in name order:
{
"hp": 40,
"inventory": {
"$id": 1,
"$val": {
"capacity": 20,
"items": [
{
"count": 3,
"name": "potion"
}
]
}
},
"name": "Vex"
}A second shared_ptr to the inventory in the same save writes {"$id": 1} and loads as the
same object. A class with a CEREAL_CLASS_VERSION gate ports without it
(Versions).
A polymorphic hierarchy
#include <cereal/types/polymorphic.hpp>
#include <cereal/types/base_class.hpp>
struct Effect {
virtual ~Effect() = default;
double remaining = 0;
template<class Archive>
void serialize(Archive& ar) { ar(CEREAL_NVP(remaining)); }
};
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar) { ar(cereal::base_class<Effect>(this), CEREAL_NVP(damagePerTick)); }
};
CEREAL_REGISTER_TYPE(Burn)
CEREAL_REGISTER_POLYMORPHIC_RELATION(Effect, Burn)#include <jaiscript/core/registrar.hpp>
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar) { Effect::serialize(ar); ar(JAI_NVP(damagePerTick)); }
};
static jai::registrar<Burn, void, jai::bases<Effect>> burnRegistrar("Burn");Effect changes only its NVP. The registrar, a static object, gives the class the name the archives write as $type and records
the edge to Effect, which a load checks before it hands a Burn back through an
Effect pointer. An element of a std::vector<std::shared_ptr<Effect>> is
written as {"$id": N, "$type": "Burn", "$val": {"remaining": 3.5, "damagePerTick": 2}}. The base's
fields land in the derived class's record, where Cereal nests them in a record of their own, so keep field names
distinct across a hierarchy. The same registrar binds the class into an engine through
jai::bind_registrar
(Serialization).
Root objects and files
jai::to_json(*eng, value) writes one root value. Cereal code that writes several
(ar(settings, world)) gathers them into one struct or opens the root record by hand:
jai::serialization::json_archive_writer out(2, eng.get()); // indent, engine
out.begin_object("", 1);
out("settings", settings);
out("world", world);
out.end_object();
std::string text = out.str();
jai::serialization::json_archive_reader in(text, eng.get());
std::string typeName;
std::uint32_t version = 0;
in.begin_object(typeName, version);
in("settings", settings);
in("world", world);
in.end_object();The binary pair takes the same calls: binary_archive_writer out(eng.get()) with
out.data(), and binary_archive_reader in(bytes, eng.get()).
The property route: no serialize functions
A class that derives jai::property_owner and declares its fields with JAI_PROPERTY
needs no function: the archives save and load every property by name, the same declaration makes the field
reflected and script-visible (dynamic_binder), and the class
carries its own polymorphic identity. It suits types whose functions only list their fields.
#include <jaiscript/properties/macros.hpp>
#include <jaiscript/properties/property_serialization.hpp>
class Effect : public jai::property_owner<Effect> {
public:
JAI_PROPERTY((double), remaining, 0.0);
};
class Burn : public jai::property_owner<Burn, Effect> {
public:
JAI_PROPERTY((int), damagePerTick, 1);
};
class Player : public jai::property_owner<Player> {
public:
JAI_PROPERTY((std::string), name, "hero");
JAI_PROPERTY((int), hp, 100);
JAI_PROPERTY((std::shared_ptr<Inventory>), inventory, std::make_shared<Inventory>());
JAI_PROPERTY((std::vector<std::shared_ptr<Effect>>), effects);
JAI_TRANSIENT_PROPERTY((float), hpFraction, 1.0f);
};
Player player;
std::string json = jai::to_json(*eng, player);
Player loaded = jai::from_json<Player>(*eng, json);- A load reads the properties the record and the class share. A property the record lacks keeps its
initializer, and one the class no longer declares is skipped, so adding or removing a
JAI_PROPERTYneeds no migration code. JAI_NAMED_PROPERTY((type), "saved_name", member)keeps a key stable through a rename.property_owner<Burn, Effect>recordsBurn's name and its edge toEffect, so no registrar is needed. The name is the bare class name;JAI_PROPERTY_OWNER_AS(Burn, "Burn")pins it.- A
property_ownercopies and moves as its members do, so it is held by value, in a container or through a pointer. By value it is written as its static type; a derived object behind a base keeps its$typethrough astd::shared_ptr, as the effects do. - Members that are not properties are not saved. A class that needs more keeps a
save/loadpair, which takes precedence, and callssave_owned_properties(ar)andload_owned_properties(ar)in it for every level's properties (property_mgr.save(ar)covers only the declaring level). A property reads ashp.get()where an expression needs the plain value.
Versions in a self-describing format
Cereal's binary archives are positional: a reader asks for what the writer wrote, in order, and
CEREAL_CLASS_VERSION tells it which layout it holds. Both JaiScript formats name every property in
every record (the binary one through a table of names and sizes ahead of the values), so a reader finds a property
by name and skips the rest:
- Reordering fields changes nothing, except among fields that hold the same shared object (Pitfalls).
- A removed field is skipped.
- An added field is read under
if (ar.has_property("capacity")) { ar(JAI_NVP(capacity)); }, and keeps its initializer for an older record (aproperty_ownerdoes this for every property).
What is left for a version is a field whose meaning changes: a unit, an encoding, one field split into two. The archives record no class version for a C++ type, so such a type writes one as a property:
template<class Archive>
void save(Archive& ar) const {
ar("format", std::uint32_t(2));
ar(JAI_NVP(metersPerSecond));
}
template<class Archive>
void load(Archive& ar) {
std::uint32_t format = 1;
if (ar.has_property("format")) { ar("format", format); }
if (format >= 2) {
ar(JAI_NVP(metersPerSecond));
} else {
double kilometersPerHour = 0;
ar("speed", kilometersPerHour);
metersPerSecond = kilometersPerHour / 3.6;
}
}A Cereal version history does not carry over. Had the worked conversion's
inventory gained capacity in its second version, its Cereal function would gate the field:
template<class Archive>
void serialize(Archive& ar, std::uint32_t const version) {
ar(CEREAL_NVP(items));
if (version >= 2) { ar(CEREAL_NVP(capacity)); }
}
};
CEREAL_CLASS_VERSION(Inventory, 2)The gate serves Cereal files written before capacity was. Those files reach JaiScript through the
converter, which reads them with Cereal and its version branch
(below), so the first JaiScript layout holds every current field and the
JaiScript function is the ungated one the worked conversion shows. A versioned member function still compiles and
is called, but the number it receives is not a record version, so leave it unused on the JaiScript side.
A migration path
- Add JaiScript beside Cereal. Link the library and make one engine the save code can reach. Nothing changes on disk.
- Make the leaf types dual (below), starting with types that hold only primitives, strings and containers of those. Cereal's output does not change.
- Move up through the owners: a type is ready when every type it holds is.
- Register the polymorphic hierarchies with a registrar or a
property_ownerbase beside eachCEREAL_REGISTER_TYPE, which the converter still needs. - Prove each type round-trips through both formats:
jai::from_json<T>(*eng, jai::to_json(*eng, value))andjai::from_binary<T>(*eng, jai::to_binary(*eng, value)), compared with the original. The two formats run different code, and a field one drops can hide behind the other. - Switch the writers. New files go out through JaiScript under a new name (an extension, a folder); the loader falls back to Cereal for the old name.
- Convert the existing data once (below).
- Remove Cereal when no old file remains: its branches, registrations, includes and
friendlines. Types whose functions only list fields can then take the property route.
Both libraries in one function
jai::serialization::jai_archive<Archive> is true for the JaiScript archives and false for every
Cereal archive, so an if constexpr on it keeps each library's spelling on its own side, and the branch
not taken is never instantiated:
struct Burn : Effect {
int damagePerTick = 1;
template<class Archive>
void serialize(Archive& ar) {
if constexpr (jai::serialization::jai_archive<Archive>) {
Effect::serialize(ar);
ar(JAI_NVP(damagePerTick));
} else {
ar(cereal::base_class<Effect>(this), CEREAL_NVP(damagePerTick));
}
}
};
CEREAL_REGISTER_TYPE(Burn)
CEREAL_REGISTER_POLYMORPHIC_RELATION(Effect, Burn)
static jai::registrar<Burn, void, jai::bases<Effect>> burnRegistrar("Burn");- Where the two bodies share nothing, write two overloads: one constrained with
requires jai::serialization::jai_archive<Archive>, the other withrequires jai::serialization::not_jai_archive<Archive>. - A Cereal function with a version parameter keeps it. The JaiScript archives call the versioned member form too, so the version branches stay inside the Cereal side.
- A type with
load_and_constructkeeps one per library. They take differentconstructtypes, so they overload without constraints. - Code that already holds a
cereal::NameValuePair<T>hands a JaiScript archive its two halves:ar(pair.name, pair.value).
Converting existing files
Existing data crosses once, through a program that links both libraries and the dual types. Cereal loads each file exactly as the old code wrote it (the same archive type, the same root shape, its registrations compiled in), and JaiScript saves it:
#include <cereal/archives/binary.hpp>
#include <jaiscript/serialization/convenience.hpp>
void convertSave(jai::engine& eng, const std::filesystem::path& from, const std::filesystem::path& to) {
SaveGame save;
{
std::ifstream is(from, std::ios::binary);
cereal::BinaryInputArchive in(is);
in(save);
}
std::string text = jai::to_json(eng, save);
SaveGame check = jai::from_json<SaveGame>(eng, text);
if (jai::to_json(eng, check) != text) {
throw std::runtime_error("lossy conversion: " + from.string());
}
std::ofstream(to) << text;
}The check loads the new text back and saves it again, so a field the JaiScript load misses shows up
as a difference. (A type holding an unordered_map or unordered_set can list the same
entries in another order after a reload; compare those by content.) Content that ships with the program converts
in one batch.
Files you cannot convert in advance
Saves on players' machines convert on first load: ship releases that read the old name through Cereal, write the new name through JaiScript, and read the new name from then on. Tell the formats apart by name, never by trying one reader and catching its error (first pitfall).
The JaiScript formats belong to the library's 0.x surface: data saved by one 0.x release is not promised to load in the next (Stability). Keep the converter and the original files until the format your data depends on has settled.
From Boost.Serialization
Everything above applies to a Boost codebase. Boost's member functions keep their signatures, because
serialize(Archive&, const unsigned int) is the versioned member form the JaiScript archives
call. What changes is inside them: Boost streams fields with &, << and
>>, and the JaiScript archives take named fields through their call operator.
| Boost.Serialization | JaiScript |
|---|---|
boost::archive::text_oarchive ar(os); ar << value;, and the binary_ and xml_ archives | jai::to_json(*eng, value), jai::to_binary(*eng, value); no XML format |
boost::archive::text_iarchive ar(is); ar >> value; | jai::from_json<T>(*eng, text), jai::from_binary<T>(*eng, blob) |
ar & hp;, and ar << hp / ar >> hp in a split pair | ar(JAI_NVP(hp)); in every function: each field has a name |
ar & BOOST_SERIALIZATION_NVP(hp);, ar & boost::serialization::make_nvp("hp", hp); | ar(JAI_NVP(hp));, ar("hp", hp); |
member serialize(Archive&, const unsigned int version), member save(...) const / load(...) | the same signatures; the number they receive is not a record version (Versions) |
BOOST_SERIALIZATION_SPLIT_MEMBER() | not needed: the archives call a member save/load pair directly, so the macro compiles beside them and goes when Boost does |
free serialize(Archive&, T&, const unsigned int), BOOST_SERIALIZATION_SPLIT_FREE(T) | free serialize(Archive&, T&), or free save / load, with two parameters: drop the version |
boost::serialization::base_object<Base>(*this) | Base::serialize(ar, version); (or Base::save / Base::load): the base's fields join the derived record |
BOOST_CLASS_EXPORT(T), BOOST_CLASS_EXPORT_GUID(T, "name"), BOOST_CLASS_EXPORT_KEY / _IMPLEMENT, ar.register_type<T>() | static jai::registrar<T, void, jai::bases<Base>> reg("name"); or a property_owner base, once per program |
boost::serialization::void_cast_register<Derived, Base>() | the registrar's jai::bases<Base> |
BOOST_CLASS_VERSION(T, n) | no class version (Versions) |
BOOST_CLASS_TRACKING, BOOST_CLASS_IMPLEMENTATION | nothing to set: a shared_ptr's object is always tracked, and nothing else is |
| raw pointers, tracked by address and created on load | rejected: the object lives in a std::shared_ptr (or a std::unique_ptr, written by its static type) |
| an object saved by value and again through a pointer, restored as one object | two objects: identity belongs to shared_ptrs alone |
save_construct_data / load_construct_data | a static load_and_construct over jai::serialization::construct<T> |
#include <boost/serialization/vector.hpp>, shared_ptr.hpp and the other type headers | nothing to include (Containers) |
boost::serialization::make_array(p, n), make_binary_object(p, n) | a std::vector or std::array field |
friend class boost::serialization::access; | friend jai::access; |
boost::archive::archive_exception | jai::serialization_error |
During the transition one function serves both libraries, as it does for Cereal
(above): jai_archive<Archive> is false for every
Boost archive, so the else branch keeps ar << name << hp; or
ar >> name >> hp;, the class carries both friend lines, and a
save/load pair keeps its BOOST_SERIALIZATION_SPLIT_MEMBER() until Boost leaves
the build.
A Boost text or binary archive is positional and reads only through the code and class versions that wrote it, so the converter loads each old file with Boost and saves it with JaiScript (Converting existing files).
Pitfalls
- Reading an old file with the new loader. A
property_ownerload reads the properties it recognizes and keeps the initializers of the rest, so a Cereal file handed to it can load as an object of defaults without an error. Keep the formats under different names. - Keys from
JAI_NVP. It takes its argument's text as the key:JAI_NVP(m_hp)saves"m_hp"andJAI_NVP(this->hp)saves"this->hp".ar("hp", m_hp)spells the key you want. - A field the record lacks.
ar("name", v)on a missing property throwsjai::serialization_error(Property 'name' not found during deserialization). A field a Cereal function wrote conditionally is read underar.has_property("name"). - A polymorphic
unique_ptr. Astd::unique_ptr<Effect>holding aBurnsaves theEffectfields and loads anEffect, with no error. A polymorphic owner is astd::shared_ptr. - An unregistered derived class. Saving one through a base pointer throws
Cannot serialize unregistered polymorphic type '...' through pointer-to-base '...'; loading a$typethe program does not know throwsUnknown polymorphic type: Name, and a$typenaming a registered class that does not derive from the requested base is rejected. - The name is in the file. An automatic
$typename follows the C++ class name, so renaming the class breaks old saves. Pin a name that must outlive a rename (the registrar's argument,JAI_PROPERTY_OWNER_AS) before the first save. - A registration a binary never sees. A
property_ownerregisters its name when the program constructs or binds the type somewhere; a tool that only loads a type registers it with ajai::registrar. A registrar in a static library's object file that nothing references is dropped by the linker, as Cereal's registrations are. - A versioned free function. A free
serialize(Archive&, T&, std::uint32_t)is not found: the type has no route, and handing it to an archive is a compile error that lists the routes. - A forgotten
friend jai::access;. A compile error that names the function, as in Cereal, wherever an archive is handed the type. Aproperty_ownerlevel with the function and no friend is that error too. AJAI_PROPERTYwhose type forgot it compiles (its mode is a run-time value) and its save throwsjai::serialization_error(dispatch). - Base and derived fields share a record. A derived field named like a base field writes the
same key twice. (In a
property_ownerchain the derived level wins, as C++ name hiding does.) - A property saved twice. A hand-written
savethat callssave_owned_properties(ar)and also writes aJAI_PROPERTYfield by name writes that key twice. - Shared objects read out of order. A
shared_ptrwrites its object at the first reference the save reaches (aweak_ptrthat comes first writes it as{"$ref": N, "$val": {...}}) and{"$id": N}at every later one, and the load resolves ids in the orderloadreads its fields. When the save writesinventorythenequipped, both holding one object, aloadthat readsequippedfirst throwsjai::serialization_error(Dangling shared_ptr reference: {"$id": N} names an object this archive never defined (...)); aweak_ptrread before the field that carries its object loads empty. Read such fields in the order the save writes them. Aproperty_owner's automatic save and load walk its properties in one order (each level by name, base levels first), so this concerns a hand-written pair. - One graph per call. A
shared_ptrsaved by two separateto_jsoncalls loads as two objects, as with two Cereal archives. - Exception types.
catch (const cereal::Exception&)becomescatch (const jai::serialization_error&);std::exceptioncovers both while both libraries run. - Enums are numbers. Both libraries write the underlying integer, so an enumerator inserted mid-list renumbers every value after it in old files. Give enumerators explicit values, or save a name.