Skip to content

byebyebryan/lazy-serializable

Repository files navigation

lazy-serializable

CI MIT License C++20 Header Only

Provides the laziest way to add serialization to C++ data classes.

Adding serialization to a project often introduces unwanted friction: learning a complex IDL, setting up code generation steps, or maintaining repetitive load/save boilerplate.

lazy-serializable solves this by letting you declare fields once, inline, using a simple macro. It effectively "glues" your data structures to a wide range of formats—JSON, Binary, Text, YAML, TOML—without requiring you to change your data model or build process.

Designed for rapid prototyping and simple projects, it offers:

  • Zero Friction: Header-only, no external dependencies (for built-in adapters), no build steps.
  • Code-First: No .proto or schema files; your C++ struct is the source of truth.
  • Multi-Format: Switch between human-readable JSON/Text (for debugging) and compact Binary (for release) instantly.
  • Composition: Automatically handles nested objects, std::vector, and sealed third-party types.
  • Extensible: Don't see the format you need? Writing a custom adapter is trivial (often ~50 lines) and works instantly with all your existing data types.

What it doesn't do

  • No Pointer Chasing: Focuses on value types and composition; does not handle pointers, references, or object graphs with cycles.
  • No Schema Validation: Assumes data fits the structure; validation is left to the underlying backend or user logic.
  • No Built-in Versioning: You control your data evolution (e.g., by adding version fields).

Quick start (single header)

The recommended distribution is the amalgamated header include/lazy_serializable.h. Drop that file into your project (or include it via your package manager) and pick the adapter you need.

#include "lazy_serializable.h"

// Define your data with one macro per field
struct SensorConfig : lazy::JsonSerializable<SensorConfig> {
  LAZY_SERIALIZABLE_FIELD(std::string, name, "default");
  LAZY_SERIALIZABLE_FIELD(int, sample_rate_hz, 1);
  LAZY_SERIALIZABLE_FIELD(bool, enabled, true);
};

int main() {
  SensorConfig cfg;
  cfg.name = "temp";

  // Serialize to JSON
  std::cout << cfg; 
  // Output: {"name":"temp","sample_rate_hz":1,"enabled":true}

  // Deserialize from JSON
  std::stringstream input(R"({"name":"new","sample_rate_hz":10,"enabled":false})");
  input >> cfg;
}

Nested objects and sealed types

Nested types work automatically as long as they inherit from lazy::Serializable. For external/“sealed” types, use LAZY_SERIALIZABLE_TYPE.

// Standard lazy-serializable struct
struct Address : lazy::JsonSerializable<Address> {
  LAZY_SERIALIZABLE_FIELD(std::string, city, "");
};

// External struct (e.g. from a 3rd party lib)
struct User {
  std::string name;
  Address home;
};

// Register the external type non-intrusively
namespace lazy::serializable {
  LAZY_SERIALIZABLE_TYPE(JsonAdapter, User, name, home);
}

MultiSerializable (optional)

lazy::MultiSerializable registers fields for multiple adapters at once, allowing you to switch formats on the fly.

#include "lazy_serializable.h"

// Register for ALL enabled adapters
struct Record : lazy::MultiSerializable<Record> {
  LAZY_MULTI_SERIALIZABLE_FIELD(std::string, id, "");
  LAZY_MULTI_SERIALIZABLE_FIELD(int, value, 0);
};

Record rec;
rec.id = "A1";
rec.value = 42;

// Serialize to JSON
rec.serialize<lazy::serializable::JsonAdapter>(std::cout);
// Output: {"id":"A1","value":42}

// Serialize to Binary
rec.serialize<lazy::serializable::BinaryAdapter>(std::cout);
// Output: [compact binary data]

To control which adapters MultiSerializable uses, define LAZY_MULTI_SERIALIZABLE_ADAPTERS before including the header, or override the relevant LAZY_SERIALIZABLE_ENABLE_* toggles to restrict which adapters are compiled in.

Installation and CMake integration

Single-header distribution

  • Copy include/lazy_serializable.h into your project or install the package from your package manager.
  • Configure feature toggles either via preprocessor defines before the include or via CMake options if you use the project as a subdirectory.

As a subproject (add_subdirectory / FetchContent)

add_subdirectory(lazy-serializable)

add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE lazy::serializable)

All optional adapters that are enabled at configure time are available automatically; link their interface targets if you need them explicitly (e.g. lazy::serializable::rapid-json).

Installed package (find_package)

After installing the project (headers and CMake package config), you can consume it via:

find_package(lazy-serializable CONFIG REQUIRED)

add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE lazy::serializable)

When using the project as a subdirectory, adapter-specific interface targets such as lazy::serializable::rapid-json, lazy::serializable::nlohmann-json, lazy::serializable::yaml, and lazy::serializable::toml are also available when those adapters are enabled at configure time.

Adapter overview

Adapter Header Macro Dependencies CMake target
Lazy JSON (default) lazy/adapters/json_lazy.h LAZY_SERIALIZABLE_ENABLE_LAZY_JSON (default 1) none lazy::serializable
RapidJSON lazy/adapters/json_rapid.h LAZY_SERIALIZABLE_ENABLE_RAPID_JSON RapidJSON lazy::serializable::rapid-json
nlohmann/json lazy/adapters/json_nlohmann.h LAZY_SERIALIZABLE_ENABLE_NLOHMANN_JSON nlohmann/json lazy::serializable::nlohmann-json
Binary lazy/adapters/binary.h LAZY_SERIALIZABLE_ENABLE_BINARY (default 1) none lazy::serializable
Text (key/value) lazy/adapters/text.h LAZY_SERIALIZABLE_ENABLE_TEXT (default 1) none lazy::serializable
YAML lazy/adapters/yaml.h LAZY_SERIALIZABLE_ENABLE_YAML fkYAML lazy::serializable::yaml
TOML lazy/adapters/toml.h LAZY_SERIALIZABLE_ENABLE_TOML toml++ lazy::serializable::toml

Adapter limitations

  • Lazy JSON: minimal JSON implementation, trades completeness for small size; precision is limited by std::stod and strings with \uXXXX escapes are round-tripped as ? in those positions.
  • Binary: compact, order-dependent format; uses host endianness (only portable across machines with the same endianness).
  • Text: human-readable key/value format; does not support arrays of objects or sealed types that contain arrays.
  • YAML/TOML/RapidJSON/nlohmann/json: rely on the underlying libraries for exact syntax/semantics; lazy-serializable adds only a thin mapping layer.

Choose the JSON backend by defining one of:

#define LAZY_SERIALIZABLE_JSON_BACKEND_RAPIDJSON      // requires LAZY_SERIALIZABLE_ENABLE_RAPID_JSON=1
// or
#define LAZY_SERIALIZABLE_JSON_BACKEND_NLOHMANN_JSON  // requires LAZY_SERIALIZABLE_ENABLE_NLOHMANN_JSON=1

If neither is defined, the lightweight builtin LazyJsonAdapter is used. When using the single header, set the corresponding LAZY_SERIALIZABLE_ENABLE_* macro to 1 before including it so the adapter code is available.

Feature toggles

All major components can be enabled/disabled via preprocessor defines before including the headers or via CMake options with the same names.

Macro Default (single header) Purpose
LAZY_SERIALIZABLE_ENABLE_MULTI 1 Provide lazy::MultiSerializable
LAZY_SERIALIZABLE_ENABLE_LAZY_JSON 1 Include LazyJsonAdapter
LAZY_SERIALIZABLE_ENABLE_BINARY 1 Include BinaryAdapter
LAZY_SERIALIZABLE_ENABLE_TEXT 1 Include TextAdapter
LAZY_SERIALIZABLE_ENABLE_RAPID_JSON 0 (dev CMake defaults to ON) Include RapidJSON adapter
LAZY_SERIALIZABLE_ENABLE_NLOHMANN_JSON 0 (dev CMake defaults to ON) Include nlohmann/json adapter
LAZY_SERIALIZABLE_ENABLE_YAML 0 (dev CMake defaults to ON) Include YAML adapter
LAZY_SERIALIZABLE_ENABLE_TOML 0 (dev CMake defaults to ON) Include TOML adapter

Define any of them to 0 to trim unused code from the single header. Define to 1 (and provide the required dependency) to enable optional adapters.

Development workflow

The modular headers under src/lazy/... are the canonical source for contributors. The amalgamated lazy_serializable.h is generated from them and should not be edited manually.

See AGENTS.md for contributor workflow, conventions, and project structure.

Generating the single header

  • Manually: python3 scripts/generate_single_header.py
  • Via CMake target: cmake --build build --target lazy-serializable-single-header
  • Via the helper script: ./build.sh --regen-single-header

Running the tests

build.sh and the provided CMakeLists.txt are intended for development. They:

  1. Enable all optional adapters by default (and fetch dependencies through FetchContent).
  2. Build from the modular headers.
  3. Provide an opt-in mode to run the test suite against the generated single header as well (./build.sh --single-header-tests or -DLAZY_SERIALIZABLE_TEST_SINGLE_HEADER=ON).

Use these scripts when contributing; for packaging, use the generated single header (or copy the few modular headers you need) and configure the feature toggles to match your environment.

Custom adapters

One of the core design goals is extensibility. If you need a format not supported out of the box (e.g., MessagePack, XML, or a custom protocol), you don't need to fork the library.

Just implement a class with four methods (fromStream, toStream, writeField, readField), and it will immediately work with all lazy::Serializable features (nested types, arrays, sealed types).

class CsvAdapter {
 public:
  static CsvAdapter fromStream(std::istream& is);
  void toStream(std::ostream& os) const;

  template <typename T>
  void writeField(const char* name, const T& value);

  template <typename T>
  void readField(const char* name, T& out);
};

struct CsvRow : lazy::Serializable<CsvRow, CsvAdapter> {
  LAZY_SERIALIZABLE_FIELD(std::string, symbol, "");
  LAZY_SERIALIZABLE_FIELD(double, price, 0.0);
};

Once your adapter implements writeField/readField and fromStream/toStream, all Serializable/MultiSerializable features (nested types, sealed types, vectors, etc.) work automatically.

Best practices

  • Always specify meaningful default values in LAZY_SERIALIZABLE_FIELD to keep backward compatibility when new fields are added.
  • Include versioning fields for on-disk formats to detect schema changes early.
  • When using MultiSerializable, keep the adapter list minimal to avoid unnecessary code size or dependency pulls.
  • For sealed/external types, prefer registering adapters near the type definition inside namespace lazy::serializable to ensure ADL finds the helpers.
  • BinaryAdapter uses the host’s endianness; if you need cross-endian portability, add an explicit byte-order conversion step on read/write.

License

lazy-serializable is available under the MIT License. See LICENSE for details.

Releases

Packages

Contributors

Languages