Skip to content

Avoid needless copies, allocations and exceptions when reading and writing - #767

Open
clockblocker wants to merge 5 commits into
munich-data-engineering:mainfrom
clockblocker:validators-avoid-copies
Open

clockblocker wants to merge 5 commits into
munich-data-engineering:mainfrom
clockblocker:validators-avoid-copies

Conversation

@clockblocker

@clockblocker clockblocker commented Nov 3, 2026 •

Copy link
Copy Markdown

Summary

Five pessimisations on the read and write paths, one commit each. Error messages are unchanged.

# Commit Pessimisation Fix
1 Move values through validators instead of copying them Validator(T&&), every AllOf step, Size and PatternValidator copied the value move it, and return early in Size and AllOf instead of chaining transform/and_then
2 Read validators without throwing an exception on failure a failed validation threw in the Validator constructor and was caught in the parser the parser uses Validator::from_value, which returns the error directly
3 Do not allocate when an untagged variant matches the error vector was reserved before any alternative was tried, and error messages were copied reserve when the first alternative fails, and move the messages
4 Read literals without throwing an exception on failure an unknown string threw in the Literal constructor and was caught in the parser the parser uses the existing Literal::from_string
5 Do not copy tagged union alternatives that contain the tag when writing wrap_if_necessary returned such alternatives by value write them directly

Whole PR, main → 552b1b6:

Benchmark (type from) Allocations ns per call Change
read Homer + 3 children (test_size) 5 → 4 2107 → 1963 −6.8%
read Person (test_email) 0 → 0 376 → 363 −3.5%
read Person, valid age (test_all_of) 0 → 0 226 → 219 −3.0%
read Person, age 200 fails (test_all_of) 13 → 11 8781 → 1932 4.5× faster
read std::variant, 1st alternative (test_variant) 1 → 0 211 → 155 −27%
read std::variant, 2nd alternative (test_variant) 9 → 8 981 → 929 −5.3%
read rfl::Variant, 1st alternative (test_rfl_variant) 1 → 0 220 → 153 −30%
read std::variant, no alternative matches (test_variant) 48 → 45 6023 → 5979 −0.7%
read Person, valid literal (test_literal) 0 → 0 220 → 216 −2.2%
read Person, "Ned" fails (test_literal) 19 → 17 8694 → 2032 4.3× faster
write Rectangle (test_tagged_union3) 2 → 2 542 → 546 noise (spread is 2–4%)

1. Move values through validators instead of copying them

 Validator(T&& value)
-  AllOf::validate(value)              copy
-    validate_impl(value)              copy
-      Size::validate(const T&)        copy into Result
-      .and_then(validate_impl)
-        Size::validate(const T&)      copy into Result
+  AllOf::validate(std::move(value))
+    validate_impl(std::move(value))
+      Size::validate(T)               moved into Result
+      if (!res) return res;
+      validate_impl(std::move(*res))
+        Size::validate(T)             moved into Result

The early returns matter. Moving the value while keeping the transform/and_then chains made short strings 60–75% slower, because each step creates another Result.

Benchmark (type from) Allocations ns per call
read Homer + 3 children (test_size) 5 → 4 2107 → 1950 (−7.4%)
read Person (test_email) 0 → 0 376 → 378 (noise; the email fits in the short-string buffer)
read Person, valid age (test_all_of) 0 → 0 226 → 223
copies of the elements, reading 2 into a validated vector (test_validator_copies) 8 → 0

2. Read validators without throwing an exception on failure

 Parser_default::read  (T has ReflectionType)
   read ReflectionType
-  T{value}                 throws on failure, caught, rewrapped via e.what()
+  T::from_value(value)     returns the error directly

Validator::from_value gets a T&& overload. Both overloads call validate and wrap the result instead of constructing a Validator and catching its exception. Both keep the try/catch, so a custom validator that throws is still handled.

Benchmark (type from) Allocations ns per call
read Person, age 200 fails (test_all_of) 13 → 11 8777 → 1918 (4.6× faster)
read Person, valid age (test_all_of) 0 → 0 223 → 219
read Person (test_email) 0 → 0 378 → 362 (−4.3%)
copies of the elements, from_value(std::move(v)) with 3 elements (test_validator_copies) 3 → 0

3. Do not allocate when an untagged variant matches

 ParserVariant::read / ParserRflVariant::read
   std::vector<std::string> errors;
-  errors.reserve(N);                      every read allocates
   for each alternative until one matches
     on failure:
+      errors.reserve(N);                  first failure only, no-op afterwards
-      errors.emplace_back(res.error().what());             copy
+      errors.emplace_back(std::move(res).error().what());  move

ParserRflVariant already had std::move(res.error().what()), but that moves a const std::string&, so it still copied.

Benchmark (type from) Allocations ns per call
read std::variant, 1st alternative (test_variant) 1 → 0 227 → 140 (−39%)
read std::variant, 2nd alternative (test_variant) 9 → 8 986 → 934 (−5.3%)
read rfl::Variant, 1st alternative (test_rfl_variant) 1 → 0 200 → 139 (−31%)
read std::variant, no alternative matches (test_variant) 48 → 45 6031 → 5963 (−1.1%)

4. Read literals without throwing an exception on failure

 Parser_default::read  (T has ReflectionType)
-  T{string}                throws on an unknown string, caught
+  T::from_string(string)   returns the error directly
Benchmark (type from) Allocations ns per call
read Person, "Ned" fails (test_literal) 19 → 17 8701 → 2065 (4.2× faster)
read Person, valid literal (test_literal) 0 → 0 218 → 216

5. Do not copy tagged union alternatives that contain the tag when writing

 ParserTaggedUnion::write_wrapped(val)
-  wrapped = wrap_if_necessary(val)   returns val by value if it contains the tag
-  write(wrapped)
+  if val contains the tag: write(val)
+  else: write(wrap_if_necessary(val))
Benchmark (type from) Allocations ns per call
write Rectangle (test_tagged_union3) 2 → 2 544 → 546 (noise)
copies of the alternative per write (test_tagged_union_copies) 1 → 0

The test_tagged_union3 alternatives hold a literal and two doubles, so the copy was cheap. For alternatives with strings or containers it was a deep copy on every write.

Evidence

New tests that fail on main and pass on this branch:

  • test_validator_copies_nothing_when_reading: 8 → 0 copies
  • test_validator_copies_nothing_when_moved_in: 12 → 0 copies
  • test_tagged_union_copies_nothing_when_writing: 1 → 0 copies

test_validator_reports_error_when_reading and test_literal_reports_unsupported_string pin the exact error messages. They pass before and after.

Each commit compiles, and passes the tests it adds. On the final commit, local runs (Apple clang 17, REFLECTCPP_USE_VCPKG=OFF) give JSON 270, CLI 71, generic 98, env 41, json_c_arrays_and_inheritance 7, all passing. The touched tests also compile with C++23 and REFLECTCPP_USE_STD_EXPECTED.

Benchmark method: the types and inputs are copied from the tests named in each row. benchmarks/ has no validators, untagged variants or tagged unions. Allocations are counted by replacing operator new for one call. Time is the median of 3 interleaved runs, each the median of 15 rounds. Apple clang 17, -O2.

Benchmark source
// Benchmarks for PR #767, using the types from reflect-cpp's own tests.
#include <rfl.hpp>
#include <rfl/json.hpp>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <new>
#include <string>
#include <variant>
#include <vector>

static size_t g_allocs = 0;
void* operator new(size_t n) { ++g_allocs; if (void* p = std::malloc(n)) return p; throw std::bad_alloc(); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, size_t) noexcept { std::free(p); }

// tests/json/test_size.cpp
namespace test_size {
struct Person {
  rfl::Rename<"firstName", std::string> first_name;
  rfl::Rename<"lastName", std::string> last_name;
  rfl::Timestamp<"%Y-%m-%d"> birthday;
  rfl::Validator<std::vector<Person>,
                 rfl::Size<rfl::AnyOf<rfl::EqualTo<0>, rfl::EqualTo<3>>>>
      children;
};
}  // namespace test_size

// tests/json/test_email.cpp
namespace test_email {
struct Person {
  rfl::Rename<"firstName", std::string> first_name;
  rfl::Rename<"lastName", std::string> last_name;
  rfl::Email email;
};
}  // namespace test_email

// tests/json/test_all_of.cpp
namespace test_all_of {
using Age = rfl::Validator<unsigned int, rfl::Minimum<0>, rfl::Maximum<130>>;
struct Person {
  rfl::Rename<"firstName", std::string> first_name;
  rfl::Rename<"lastName", std::string> last_name;
  Age age;
};
}  // namespace test_all_of

// tests/json/test_variant.cpp and tests/json/test_rfl_variant.cpp
namespace test_variant {
struct Circle { double radius; };
struct Rectangle { double height; double width; };
struct Square { double width; };
using Shapes = std::variant<Circle, Rectangle, std::unique_ptr<Square>>;
using RflShapes = rfl::Variant<Circle, Rectangle, std::unique_ptr<Square>>;
}  // namespace test_variant

// tests/json/test_literal.cpp
namespace test_literal {
using FirstName = rfl::Literal<"Homer", "Marge", "Bart", "Lisa", "Maggie">;
using LastName = rfl::Literal<"Simpson">;
struct Person {
  rfl::Rename<"firstName", FirstName> first_name;
  rfl::Rename<"lastName", LastName> last_name;
  std::vector<Person> children;
};
}  // namespace test_literal

// tests/json/test_tagged_union3.cpp
namespace test_tagged_union3 {
struct Circle { rfl::Literal<"circle", "Circle"> shape; double radius; };
struct Rectangle { rfl::Literal<"rectangle", "Rectangle", "rect"> shape; double height; double width; };
struct Square { rfl::Literal<"square", "Square"> shape; double width; };
struct Empty {};
using Shapes = rfl::TaggedUnion<"shape", Circle, Square, Rectangle, Empty>;
}  // namespace test_tagged_union3

static size_t sink = 0;

template <class F>
void bench(const char* name, F&& f, int iters) {
  g_allocs = 0;
  f();
  const size_t allocs = g_allocs;
  std::vector<double> v;
  for (int r = 0; r < 15; ++r) {
    auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < iters; ++i) f();
    v.push_back(std::chrono::duration<double, std::nano>(std::chrono::steady_clock::now() - t0).count() / iters);
  }
  std::sort(v.begin(), v.end());
  std::printf("%s,%zu,%.1f\n", name, allocs, v[v.size() / 2]);
}

template <class T>
void bench_read(const char* name, const std::string& json, int iters = 50000) {
  bench(name, [&] { sink += rfl::json::read<T>(json).has_value(); }, iters);
}

int main() {
  const std::string homer =
      R"({"firstName":"Homer","lastName":"Simpson","birthday":"1987-04-19","children":[{"firstName":"Bart","lastName":"Simpson","birthday":"1987-04-19","children":[]},{"firstName":"Lisa","lastName":"Simpson","birthday":"1987-04-19","children":[]},{"firstName":"Maggie","lastName":"Simpson","birthday":"1987-04-19","children":[]}]})";
  bench_read<test_size::Person>("size_read", homer);
  bench_read<test_email::Person>("email_read", R"({"firstName":"Homer","lastName":"Simpson","email":"homer@simpson.com"})");
  bench_read<test_all_of::Person>("allof_read_ok", R"({"firstName":"Homer","lastName":"Simpson","age":45})");
  bench_read<test_all_of::Person>("allof_read_fail", R"({"firstName":"Homer","lastName":"Simpson","age":200})", 10000);
  bench_read<test_variant::Shapes>("variant_read_1st", R"({"radius":2.0})");
  bench_read<test_variant::Shapes>("variant_read_2nd", R"({"height":10.0,"width":5.0})");
  bench_read<test_variant::RflShapes>("rflvariant_read_1st", R"({"radius":2.0})");
  bench_read<test_variant::Shapes>("variant_read_fail", R"({"length":1.0})", 20000);
  const std::string bart = R"({"firstName":"Bart","lastName":"Simpson","children":[]})";
  bench_read<test_literal::Person>("literal_read_ok", bart);
  bench_read<test_literal::Person>("literal_read_fail", R"({"firstName":"Ned","lastName":"Simpson","children":[]})", 10000);
  const auto rect = test_tagged_union3::Shapes(test_tagged_union3::Rectangle{.shape = rfl::Literal<"rectangle", "Rectangle", "rect">::make<"rect">(), .height = 10, .width = 5});
  bench("taggedunion_write", [&] { sink += rfl::json::write(rect).size(); }, 50000);
  std::fprintf(stderr, "sink %zu\n", sink);
}

Merge Danger

Door: two-way. Header-only changes, nothing persisted.

Blast Radius: small.

  • Size::validate and PatternValidator::validate now take their argument by value. Calls keep compiling, but taking their address as a function pointer of the old type would not.
  • Validator::from_value(T&&) is a new overload. A call with an rvalue now picks it, with the same result.
  • Validators that return a different value than they were given still work. The returned value is what gets stored, as before.

AnyOf and OneOf still copy once per alternative, because every alternative has to see the original value. Changing that would change the validator interface, so it's not part of this PR.

🤖 Generated with Claude Code

clockblocker and others added 5 commits November 3, 2026 16:52
Validating a value copied it several times: once in each step of AllOf,
once in Size and PatternValidator, and once more because Validator(T&&)
did not move its argument. Validating a string with two Size
constraints made four extra copies of it. The value is now moved
through the validators instead.

Size and AllOf return early on failure rather than chaining transform
and and_then. The chains create more Result temporaries than the copies
they would save, which made short strings slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When reading, a failed validation made the Validator constructor throw
and the parser catch the exception, which costs several microseconds.
The parser now uses Validator::from_value, which returns the error
directly. from_value gets an overload for rvalues and no longer throws
internally. The error messages stay the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading an untagged variant reserved space for an error message per
alternative before trying any of them, so every read allocated, even
when the first alternative matched. The space is now reserved when the
first alternative fails. The error messages of failed alternatives are
also moved instead of copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When reading, a string that is not one of the literal's values made
the Literal constructor throw and the parser catch the exception. The
parser now uses Literal::from_string, which returns the error directly.
The error message stays the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When writing a tagged union, wrap_if_necessary returned an alternative
that already contains the discriminator field by value, which copied
the whole object on every write. Such alternatives are now written
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clockblocker
clockblocker force-pushed the validators-avoid-copies branch from d2231dc to 552b1b6 Compare November 3, 2026 16:02
@clockblocker clockblocker changed the title Avoid copying values and throwing exceptions in validators Avoid needless copies, allocations and exceptions when reading and writing Nov 3, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant