Sane C++ Libraries
C++ Platform Abstraction Libraries
Loading...
Searching...
No Matches
Serialization Text

🟨 Map reflected C++ data to and from JSON in caller-controlled storage

SaneCppSerializationText.h is the single-file distribution. Serialization Text currently means one concrete format: SC::SerializationJson, built on compile-time Reflection.

Dependencies

Dependency Graph

When It Fits

Use Serialization Text when an application already has reflected, value-like C++ structures and needs compact JSON without introducing a DOM, exceptions, or a mandatory allocator. It walks the object graph directly: reflected field names become JSON object keys, fixed and vector-like containers become arrays, and supported scalar and string values become JSON values.

This is a small mapper rather than a general JSON toolkit. It is a good fit for application state, configuration, and interchange data whose schema is controlled by the program. It is currently a weaker fit when you need arbitrary JSON trees, preservation of unknown fields, incremental I/O, rich format controls, or a mature compatibility layer.

The library is marked MVP. JSON is the only implemented format, the public operation reports only bool, and the supported type and conversion surface is intentionally narrower than a full JSON implementation.

The Model: Reflection Drives The Walk

Serialization Text does not inspect C++ declarations by itself. A type opts in through [Reflection](Reflection), including a stable text name for every field. This compiled test model mixes primitive values, a fixed C array, an owning string, and a vector of strings:

struct SC::Test
{
int x = 2;
float y = 1.5f;
int xy[2] = {1, 3};
String myTest = "asdf"_a8;
Vector<String> myVector = {"Str1"_a8, "Str2"_a8};
bool operator==(const Test& other) const
{
return x == other.x and y == other.y and //
xy[0] == other.xy[0] and xy[1] == other.xy[1] and //
myTest == other.myTest and myVector.size() == 2 and //
myVector.size() == other.myVector.size() and //
myVector[0] == other.myVector[0] and myVector[1] == other.myVector[1];
}
};
SC_REFLECT_STRUCT_VISIT(SC::Test)
SC_REFLECT_STRUCT_FIELD(0, x)
SC_REFLECT_STRUCT_FIELD(1, y)
SC_REFLECT_STRUCT_FIELD(2, xy)
SC_REFLECT_STRUCT_FIELD(3, myTest)
SC_REFLECT_STRUCT_FIELD(4, myVector)
SC_REFLECT_STRUCT_LEAVE()

SC::String and SC::Vector are not dependencies of this library. Include the appropriate [Containers Reflection](Containers Reflection) serialization adapters when those owning types appear in the graph. Keeping the adapter layer separate lets Serialization Text itself depend only on Reflection and lets applications provide the same traits for their own vector-like types.

Writing walks fields in reflection visit order and appends compact JSON to a caller-selected growable buffer:

constexpr StringView testJSON = R"({"x":2,"y":1.50,"xy":[1,3],"myTest":"asdf","myVector":["Str1","Str2"]})"_a8;
SmallBuffer<256> buffer;
Test test;
SC_TEST_EXPECT(SerializationJson::write(test, buffer));
// Note: SerializationJson::write will NOT null terminate the string
const StringView serializedJSON({buffer.data(), buffer.size()}, false, StringEncoding::Ascii);
SC_TEST_EXPECT(serializedJSON == testJSON);

The output is not null terminated. Use the buffer's actual size when constructing a view or writing it to a file. Options::floatDigits controls the number of digits printed for floating-point values; pretty printing and other JSON style controls are not currently exposed.

Choose The Read Contract Deliberately

Both readers parse from an in-memory StringSpan and mutate an existing object. They are not streaming readers, and they do not verify that no trailing input remains after the reflected value. They differ in how object fields are located:

Operation Field contract Intended use
loadExact Names and order must match the writer's reflected order; whitespace may differ Data produced and consumed by the same known schema
loadVersioned Known fields may be reordered or omitted; matching is by reflected field name Human-edited or schema-evolving text with controlled fields

The versioned path leaves omitted fields at their current values. Initialize defaults before loading if that is the desired migration behavior. Despite its name, it is not a permissive catch-all parser: an unknown object field makes the load fail rather than being skipped. Fixed-size C arrays also retain exact array behavior.

This compiled test demonstrates reordered and missing fields; the missing xy field keeps the model's initialized default:

constexpr StringView scrambledJson =
R"({"y" : 1.50, "x": 2.0, "myVector" : ["Str1","Str2"], "myTest":"asdf"})"_a8;
Test test;
test.x = 0;
test.y = 0;
(void)test.myVector.resize(1);
(void)test.myTest.assign("FDFSA"_a8);
SC_TEST_EXPECT(SerializationJson::loadVersioned(test, scrambledJson));
SC_TEST_EXPECT(test == Test());

loadExact has a simpler walk, but no measured performance claim is made for it. Select it for its stricter data contract, then benchmark if speed matters.

Storage, Allocation, And Lifetime

The serializer itself does not own a JSON document or build a tree. The important storage behavior belongs to the objects supplied by the caller:

  • write appends to the supplied buffer. A fixed-capacity buffer performs no heap allocation and returns false if it cannot grow; an owning growable buffer may allocate through its configured allocator.
  • A failed write restores the destination buffer to its starting size. Existing bytes before that size are retained.
  • Reading owning strings and vector-like containers may resize them and therefore may allocate. Resize failure makes the load fail. Loading is not transactional: do not assume the destination object is unchanged after failure.
  • Loading into StringSpan or StringView borrows an unescaped slice of the input JSON. The input storage must outlive every resulting view. Escaped JSON strings cannot be represented by those borrowed types and cause loading to fail; use an owning SC::String when unescaping is required.
  • The reader is whole-buffer rather than streaming. The input must remain available for the duration of parsing, and longer when the result contains borrowed string views.

These distinctions are how the library preserves caller control: "no mandatory allocation" does not mean that a chosen owning output buffer or destination container can never allocate.

Boundaries And Neighboring Libraries

Reflection supplies field names and traversal metadata; Serialization Text supplies the JSON reader and writer. Containers Reflection opts SC containers and owning memory types into that traversal. Serialization Binary serves a different tradeoff: use it when compact machine-oriented storage and its binary schema/versioning model matter more than readable JSON.

Internally, a stateful tokenizer returns slices into the source instead of constructing a DOM. It recognizes JSON structure and token boundaries; type-specific readers then attempt the conversions they support. This keeps parsing storage small, but SC::JsonTokenizer is an implementation-oriented surface, not a replacement for a validating JSON document API.

Current practical boundaries include:

  • JSON only; the shared traversal machinery could support another structured format, but XML and YAML are not implemented;
  • no incremental input or output API;
  • no unknown-field skipping in loadVersioned;
  • no structured error object or byte position on failure;
  • no JSON DOM and no preservation of formatting or key order from input;
  • support for additional SC container families remains incomplete.

API Reference

SC::SerializationJson reads or writes C++ structures to / from json using Reflection information.


Let's consider the following structure described by Reflection:

struct SC::Test
{
int x = 2;
float y = 1.5f;
int xy[2] = {1, 3};
String myTest = "asdf"_a8;
Vector<String> myVector = {"Str1"_a8, "Str2"_a8};
bool operator==(const Test& other) const
{
return x == other.x and y == other.y and //
xy[0] == other.xy[0] and xy[1] == other.xy[1] and //
myTest == other.myTest and myVector.size() == 2 and //
myVector.size() == other.myVector.size() and //
myVector[0] == other.myVector[0] and myVector[1] == other.myVector[1];
}
};
SC_REFLECT_STRUCT_VISIT(SC::Test)
SC_REFLECT_STRUCT_FIELD(0, x)
SC_REFLECT_STRUCT_FIELD(1, y)
SC_REFLECT_STRUCT_FIELD(2, xy)
SC_REFLECT_STRUCT_FIELD(3, myTest)
SC_REFLECT_STRUCT_FIELD(4, myVector)
SC_REFLECT_STRUCT_LEAVE()
struct SC::EscapedStringTest
{
String value;
};
SC_REFLECT_STRUCT_VISIT(SC::EscapedStringTest)
SC_REFLECT_STRUCT_FIELD(0, value)
SC_REFLECT_STRUCT_LEAVE()
struct SC::BorrowedStringTest
{
StringSpan spanValue;
StringView viewValue;
};
SC_REFLECT_STRUCT_VISIT(SC::BorrowedStringTest)
SC_REFLECT_STRUCT_FIELD(0, spanValue)
SC_REFLECT_STRUCT_FIELD(1, viewValue)
SC_REFLECT_STRUCT_LEAVE()
struct SC::VersionedVectorItem
{
int first = 0;
String second;
};
SC_REFLECT_STRUCT_VISIT(SC::VersionedVectorItem)
SC_REFLECT_STRUCT_FIELD(0, first)
SC_REFLECT_STRUCT_FIELD(1, second)
SC_REFLECT_STRUCT_LEAVE()
struct SC::VersionedVectorTest
{
Vector<VersionedVectorItem> items;
};
SC_REFLECT_STRUCT_VISIT(SC::VersionedVectorTest)
SC_REFLECT_STRUCT_FIELD(0, items)
SC_REFLECT_STRUCT_LEAVE()

This is how you can serialize the class to JSON

constexpr StringView testJSON = R"({"x":2,"y":1.50,"xy":[1,3],"myTest":"asdf","myVector":["Str1","Str2"]})"_a8;
SmallBuffer<256> buffer;
Test test;
SC_TEST_EXPECT(SerializationJson::write(test, buffer));
// Note: SerializationJson::write will NOT null terminate the string
const StringView serializedJSON({buffer.data(), buffer.size()}, false, StringEncoding::Ascii);
SC_TEST_EXPECT(serializedJSON == testJSON);
constexpr StringView escapedJSON = R"({"value":"quote\"slash\\line\nunicode A"})"_a8;
EscapedStringTest escaped;
escaped.value = "quote\"slash\\line\nunicode A"_a8;
buffer.clear();
SC_TEST_EXPECT(SerializationJson::write(escaped, buffer));
const StringView serializedEscapedJSON({buffer.data(), buffer.size()}, false, StringEncoding::Ascii);
SC_TEST_EXPECT(serializedEscapedJSON == escapedJSON);
constexpr StringView borrowedJSON = R"({"spanValue":"quote\"span","viewValue":"line\nview"})"_a8;
BorrowedStringTest borrowed;
borrowed.spanValue = "quote\"span"_a8;
borrowed.viewValue = "line\nview"_a8;
buffer.clear();
SC_TEST_EXPECT(SerializationJson::write(borrowed, buffer));
const StringView serializedBorrowedJSON({buffer.data(), buffer.size()}, false, StringEncoding::Ascii);
SC_TEST_EXPECT(serializedBorrowedJSON == borrowedJSON);

This is how you can de-serialize the class from JSON, matching fields by their label name, even if they come in different order than the original class or even if there are missing field.

constexpr StringView scrambledJson =
R"({"y" : 1.50, "x": 2.0, "myVector" : ["Str1","Str2"], "myTest":"asdf"})"_a8;
Test test;
test.x = 0;
test.y = 0;
(void)test.myVector.resize(1);
(void)test.myTest.assign("FDFSA"_a8);
SC_TEST_EXPECT(SerializationJson::loadVersioned(test, scrambledJson));
SC_TEST_EXPECT(test == Test());
constexpr StringView escapedJSON = R"({"value":"quote\"slash\\line\nunicode \u0041"})"_a8;
EscapedStringTest escaped;
SC_TEST_EXPECT(SerializationJson::loadVersioned(escaped, escapedJSON));
SC_TEST_EXPECT(escaped.value == "quote\"slash\\line\nunicode A");
constexpr StringView borrowedJSON = R"({"viewValue":"view","spanValue":"span"})"_a8;
BorrowedStringTest borrowed;
SC_TEST_EXPECT(SerializationJson::loadVersioned(borrowed, borrowedJSON));
SC_TEST_EXPECT(borrowed.spanValue == "span"_a8);
SC_TEST_EXPECT(borrowed.viewValue == "view"_a8);
constexpr StringView escapedBorrowedJSON = R"({"spanValue":"line\n","viewValue":"view"})"_a8;
SC_TEST_EXPECT(not SerializationJson::loadVersioned(borrowed, escapedBorrowedJSON));
constexpr StringView versionedVectorJSON = R"({"items":[{"second":"b","first":1},{"second":"c","first":2}]})"_a8;
VersionedVectorTest versionedVector;
SC_TEST_EXPECT(SerializationJson::loadVersioned(versionedVector, versionedVectorJSON));
SC_TEST_EXPECT(versionedVector.items.size() == 2);
SC_TEST_EXPECT(versionedVector.items[0].first == 1);
SC_TEST_EXPECT(versionedVector.items[0].second == "b");
SC_TEST_EXPECT(versionedVector.items[1].first == 2);
SC_TEST_EXPECT(versionedVector.items[1].second == "c");

This is a special loader to deserialize the class from the exact same JSON that was output by the serializer itself. Whitespace changes are fine, but changing the order in which two fields exists or removing one will make deserialization fail. If these limitations are fine for the usage (for example the generated json files are not meant to be manually edited by users) than maybe it could be worth using it, as this code path is a lot simpler (*) than SC::SerializationJson::loadVersioned.

constexpr StringView testJSON = R"({"x":2,"y":1.50,"xy":[1,3],"myTest":"asdf","myVector":["Str1","Str2"]})"_a8;
Test test;
test.x = 1;
test.y = 3.22f;
test.xy[0] = 4;
test.xy[1] = 4;
test.myTest = "KFDOK";
test.myVector = {"LPDFSOK", "DSAFKO"};
SC_TEST_EXPECT(SerializationJson::loadExact(test, testJSON));
SC_TEST_EXPECT(test == Test());
constexpr StringView escapedJSON = R"({"value":"quote\"slash\\line\nunicode \u0041"})"_a8;
EscapedStringTest escaped;
SC_TEST_EXPECT(SerializationJson::loadExact(escaped, escapedJSON));
SC_TEST_EXPECT(escaped.value == "quote\"slash\\line\nunicode A");
constexpr StringView borrowedJSON = R"({"spanValue":"span","viewValue":"view"})"_a8;
BorrowedStringTest borrowed;
SC_TEST_EXPECT(SerializationJson::loadExact(borrowed, borrowedJSON));
SC_TEST_EXPECT(borrowed.spanValue == "span"_a8);
SC_TEST_EXPECT(borrowed.viewValue == "view"_a8);
constexpr StringView escapedBorrowedJSON = R"({"spanValue":"line\n","viewValue":"view"})"_a8;
SC_TEST_EXPECT(not SerializationJson::loadExact(borrowed, escapedBorrowedJSON));
Note
(*) simpler code probably means faster code, even if it has not been properly benchmarked yet so the hypothetical performance gain is yet to be defined.

Statistics

LOC counts exclude comments. Library counts files physically under Libraries/SerializationText. Single File counts SaneCppSerializationText.h. Standalone counts SaneCppSerializationTextStandalone.h and intentionally includes dependency payloads.

Metric Header Source Sum
Library 632 471 1103
Single File 1385 468 1853
Standalone 2315 468 2783