reflect-cpp

repository·main·Indexed 23 days ago

https://github.com/getml/reflect-cpp

A high-performance C++20 library for reflection-based serialization, deserialization, and validation. It supports a wide range of formats including JSON, YAML, TOML, Msgpack, CBOR, BSON, Avro, Cap'n Proto, and others, as well as CLI arguments and environment variables. The library provides tools for runtime struct inspection, JSON schema generation, and field customization via validators and renames.

Tokens
60.2K
Snippets
170
Records
268
Agent score
83%

What's inside reflect-cpp

  1. Overview of reflect-cpp

    main

    reflect-cpp is a C++20 library designed for high-performance serialization, deserialization, and validation using reflection. It is intended to minimize boilerplate code and enhance type safety during data exchange, similar to pydantic (Python), serde (Rust), or aeson (Haskell).

    Key features include:

    • Fast Performance: One of the fastest serialization libraries available.
    • Type-Safe Validation: Encode data requirements directly into the type system to ensure input data meets specifications upfront.
    • Standard Library Integration: Close integration with C++ standard library containers.
    • Extensibility: Easy to extend for custom classes and new serialization formats.
    • Built-in Support: Out-of-the-box support for JSON and support for default-valued fields via rfl::DefaultVal.
  2. Compare reflect-cpp performance with other JSON libraries

    main

    Reflect-cpp is designed to be among the fastest JSON libraries for C++, offering a significant usability advantage over competitors.

    Performance Summary:

    • vs. simdJSON and yyjson: reflect-cpp is currently slightly slower, but work is ongoing to reduce this overhead to zero.
    • vs. RapidJSON: reflect-cpp is consistently faster, typically about twice as fast.
    • vs. nlohmann/json: reflect-cpp is consistently about 10 times faster.

    Usability Advantage: While other libraries (including nlohmann/json) may require 50-90 lines of code to implement serialization and deserialization, reflect-cpp allows you to achieve the same result with a single line of code.

  3. Parquet limitations and unsupported types

    main

    Because Parquet is a tabular format, it has several strict limitations:

    Unsupported Processors

    The following processors will cause compilation errors:

    • rfl::AddTagsToVariants
    • rfl::NoOptionals
    • rfl::DefaultIfMissing
    • rfl::NoExtraFields
    • rfl::NoFieldNames

    Unsupported Types

    • Variant Types: std::variant, rfl::Variant, and rfl::TaggedUnion are not supported.
    • Nested Objects: Parquet cannot directly represent nested objects. Each field must be a primitive, enum, or simple container of primitives.
    • Arrays: Parquet does not support arrays of any type except for binary data (std::vector<char>). This means std::vector<std::string> or std::vector<int> are not supported.

    Workarounds

    • For Nested Objects: Use rfl::Flatten<T> to explicitly flatten a nested struct into the parent's columns.
    • For Arrays: Use std::vector<char> to store binary data (bytestrings).
    // ✅ Flattening nested objects
    struct Address {
        std::string street;
        std::string city;
    };
    
    struct Person {
        std::string first_name;
        rfl::Flatten<Address> address; // Results in columns: first_name, street, city
    };
    
    // ✅ Supported array (binary only)
    struct Person {
        std::vector<char> binary_data; 
    };
    
    // ❌ Unsupported array
    struct Person {
        std::vector<std::string> hobbies; 
    };
  4. Use rfl::Attribute to define XML attributes

    main

    XML distinguishes between nodes and attributes. To represent a field as an XML attribute instead of a child node, wrap the type in rfl::Attribute<T>.

    Constraints:

    • Only boolean, string, integral, or floating-point values can be represented as attributes.
    • rfl::Attribute acts as a thin wrapper. You can access the underlying value using .get(), .value(), operator()(), operator*(), or operator->().
    struct Person {
      // This will appear as <Person firstName="Homer" ...>
      rfl::Rename<"firstName", rfl::Attribute<std::string>> first_name;
      
      // This will appear as an attribute
      rfl::Attribute<std::string> town = "Springfield";
      
      // This will remain a child node
      std::vector<Person> child;
    };
    struct Person {
      rfl::Rename<"firstName", rfl::Attribute<std::string>> first_name;
      rfl::Rename<"lastName", rfl::Attribute<std::string>> last_name = "Simpson";
      rfl::Attribute<std::string> town = "Springfield";
      rfl::Attribute<rfl::Timestamp<"%Y-%m-%d">> birthday;
      rfl::Attribute<Age> age;
      rfl::Attribute<rfl::Email> email;
      std::vector<Person> child;
    };
  5. Handle optional fields in structs

    main

    By default, reflect-cpp treats all struct fields as required during serialization and deserialization. If a field is missing in the input data (e.g., JSON), a runtime error will occur.

    To make a field optional, wrap the type in one of the following:

    • std::optional<T>
    • std::shared_ptr<T>
    • std::unique_ptr<T>

    Behavior:

    • Serialization (Writing): If the std::optional is std::nullopt or the smart pointer is nullptr, the field will be omitted from the output (e.g., the JSON string).
    • Deserialization (Reading): The field is no longer required to be present in the input data.

    If you need to force fields to be required even if they use these types, use the rfl::NoOptionals processor.

    struct Person {
        rfl::Rename<"firstName", std::string> first_name;
        rfl::Rename<"lastName", std::string> last_name = "Simpson";
    
        // Indicates to the library that the field is optional.
        std::optional<std::vector<Person>> children;
    };
    
    const auto homer = Person{.first_name = "Homer",
                              .children = std::vector<Person>({bart, lisa, maggie})};
    
    const auto json_string = rfl::json::write(homer);
  6. Use Literals for efficient and safe string constraints

    main

    When a string field is known to only accept a limited set of values, use rfl::Literal instead of a raw std::string. This improves efficiency (literals are stored as uint8_t or uint16_t internally) and safety (enforces valid values during serialization/deserialization).

    To declare a literal, use the rfl::Literal template with string literals as arguments:

    using MyLiteral = rfl::Literal<"option1", "option2", ...>;
    using MyLiteral = rfl::Literal<"option1", "option2", ...>;
  7. Maintain backwards compatibility with struct changes

    main

    When evolving your C++ structs, you can maintain compatibility with previously serialized data by following specific rules regarding field types.

    In reflect-cpp, a field is considered optional if its type is std::optional, std::shared_ptr, or std::unique_ptr. All other field types are considered required.

    To ensure you can still interact with data generated by previous versions of your structs, follow these rules:

    ActionOptional Fields (std::optional, std::shared_ptr, std::unique_ptr)Required Fields (All other types)
    AddAllowedProhibited
    RemoveAllowedAllowed (if no longer needed)
    RenameAllowedProhibited
    ReorderAllowedAllowed

    Summary of constraints:

    • Do not add any required fields.
    • Do not change the names of any required fields.
  8. Combine multiple processors using rfl::Processors

    main
    When performing serialization or deserialization, you can apply multiple processors simultaneously by passing them as a template parameter list. To improve readability and maintainability when using many processors, use the rfl::Processors type alias to group them into a single type.
  9. Use flag enums for non-mutually exclusive values

    main

    To model enumerations where multiple values can coexist (bitmasks), use flag enums.

    Requirements

    1. Bitwise OR Operator: You must define operator| for the enum. This is how reflect-cpp detects it is a flag enum.
    2. Power-of-Two Values: The primary/base values must be powers of two (e.g., 1, 2, 4, 8, 16, 256, 512, etc.).

    Serialization Behavior

    • Combinations are serialized as a pipe-separated string of the base (power-of-two) names: "base1|base2|base3".
    • If a value is a combination of base colors (e.g., orange = red | yellow), it is automatically decomposed into its base components during serialization.
    • If an enum value cannot be matched to a name, it is represented as a combination of its power-of-two components and any remaining integer value.
    // Base colors must be 2^N
    enum class Color {
      red = 256,
      green = 512,
      blue = 1024,
      yellow = 2048,
      orange = (256 | 2048)  // red + yellow
    };
    
    inline Color operator|(Color c1, Color c2) {
      return static_cast<Color>(static_cast<int>(c1) | static_cast<int>(c2));
    }
    
    // Usage:
    // Color::blue | Color::green -> "blue|green"
    // Color::orange              -> "red|yellow"
  10. Implement custom TOML constructors for faster compilation

    main

    To reduce compilation times in large systems, you can move TOML parsing logic into separate compilation units by implementing a custom static constructor.

    For TOML, you must define a static function named from_toml_obj within your struct or class. This function must:

    1. Accept rfl::toml::Reader::InputVarType as its argument.
    2. Return the class type or an rfl::Result wrapping the class.

    This approach forces the compiler to only instantiate the TOML-specific parsing logic when the specific source file containing the implementation is compiled.

    // In your header file
    struct Person {
        rfl::Rename<"firstName", std::string> first_name;
        rfl::Rename<"lastName", std::string> last_name;
        rfl::Timestamp<"%Y-%m-%d"> birthday;
    
        using TOMLVar = typename rfl::toml::Reader::InputVarType;
        static rfl::Result<Person> from_toml_obj(const TOMLVar& _obj);
    };
    
    // In your source file
    rfl::Result<Person> Person::from_toml_obj(const TOMLVar& _obj) {
        const auto from_nt = [](auto&& _nt) {
            return rfl::from_named_tuple<Person>(std::move(_nt));
        };
        return rfl::toml::read<rfl::named_tuple_t<Person>>(_obj)
            .transform(from_nt);
    }
  11. Implement `read_array` and `read_object` in a Reader

    main

    When implementing a custom Reader, the read_array and read_object methods are used to drive the recursive parsing of nested structures. They expect specific visitor-like objects (ArrayReader and ObjectReader).

    Implementing read_array

    read_array receives an ArrayReader. This reader must have a read method with the signature: std::optional<Error> read(const InputVarType& _var) const noexcept;

    Task: Iterate through the InputArrayType provided to read_array and call array_reader.read(_var) for each element. If any call returns an error, return that error immediately.

    Implementing read_object

    read_object receives an ObjectReader. This reader must have a read method with the signature: void read(const std::string_view& _name, const InputVarType& _var) const noexcept;

    Task: Iterate through the key-value pairs in the InputObjectType and call object_reader.read(_name, _var) for each pair.

  12. Compose complex validation logic with AnyOf, AllOf, and OneOf

    main

    When simple constraints like rfl::Minimum or rfl::PatternValidator are insufficient, you can compose complex validation rules using logical operators. These operators can be used to create nested, sophisticated validation logic for any type used within an rfl::Validator.

    Available composition operators:

    • rfl::AnyOf: Requires that at least one of the contained conditions is true (OR logic).
    • rfl::AllOf: Requires that all of the contained conditions are true (AND logic).
    • rfl::OneOf: Requires that exactly one of the contained conditions is true (XOR logic).
    using Age = rfl::Validator<
        int,
        rfl::AnyOf<rfl::AllOf<rfl::Minimum<0>, rfl::Maximum<10>>,
                   rfl::AllOf<rfl::Minimum<40>, rfl::Maximum<130>>]>;