Skip to content

Compose the C++ XML de-serialization - #745

Merged
mristin merged 1 commit into
mainfrom
compose-cpp-xml-deserialization
Sep 22, 2026
Merged

mristin merged 1 commit into
mainfrom
compose-cpp-xml-deserialization

Conversation

@mristin

@mristin mristin commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

C++ is the last backend to have its XML read side decomposed, after C# (#686), Go (#688), Java (#690), Python (#694) and TypeScript (#698). #673 factored out the framing of an element; this factors out the framing of the property loop, which every <Cls>FromSequence re-emitted in full.

A property loses its duplicate check and its std::tie, and names only itself and what reads it.

// before
case properties::OfExtension::kSemanticId: {
  if (the_semantic_id.has_value()) {
    error = DuplicatePropertyError(name);
    break;
  }

  std::tie(
    the_semantic_id,
    error
  ) = ReferenceFromSequence<
    types::IReference
  >(reader);
  break;
}

// after
case properties::OfExtension::kSemanticId:
  return ReadInto(
    the_semantic_id,
    ReferenceFromSequence<
      types::IReference
    >(reader)
  );

The loop around it becomes one call. It could be lifted out at all only because the framing now returns the error alone, instead of NoInstanceAndDeserializationError<std::shared_ptr<T> >, and so no longer needs T.

// before, ~220 lines in each of the 38 ``<Cls>FromSequence``
while (true) {
  error = SkipWhitespace(reader);
  if (error.has_value()) { ...6 lines... }
  if (reader.node().kind() == xml_common::NodeKind::Stop) {
    break;
  } else if (reader.node().kind() != xml_common::NodeKind::Start) {
    ...10 lines naming IExtension...
  }
  const std::string name(...);
  reader.Read();
  auto it = properties::kMapOfExtension.find(name);
  if (it == properties::kMapOfExtension.end()) { ...12 lines... }
  switch (it->second) { ... }
  ...~60 lines of prepending and stop-element handling...
}

// after
common::optional<DeserializationError> error(
  ReadProperties<
    properties::kPropertyCountOfExtension
  >(
    reader,
    properties::kMapOfExtension,
    L"IExtension",
    [&](
      properties::OfExtension property
    ) -> common::optional<DeserializationError> {
      switch (property) { ... }
    }
  )
);

The switch stays inline because its branches assign the locals the constructor is called with. The lambda is a template argument taken by const&, so the call is statically bound and nothing is type-erased or allocated; g++ -O2 keeps ReadProperties itself out of line, one instantiation per class, which is as many function bodies as the loops it replaces.

The duplicate property element check is bookkeeping of the loop, not of the property, so it moved there, as a bit set sized to the class. It still precedes the read, so a duplicate is refused without its content ever being looked at.

std::bitset<kPropertyCount> seen;
...
if (seen[index]) {
  error = DuplicatePropertyError(name);
  PrependElementSegmentToDeserializationError(name, *error);
  return error;
}

seen[index] = true;

ReadInto takes the results of a read, not the reader. A tuple's item readers vary in number and in type, so no reader-taking signature serves them all, while one taking the result serves every read.

template <typename T>
common::optional<DeserializationError> ReadInto(
  common::optional<T>& target,
  std::pair<
    common::optional<T>,
    common::optional<DeserializationError>
  >&& read
);

A named union is framed like a class. The two framings were ~160 near-identical lines differing only in the value type, which every error factory they call is already generic in.

// before
template <typename T, typename DispatchT>
... DeserializeClassFromElement(reader, const std::wstring&, dispatch);
template <typename VariantT, typename DispatchT>
... DeserializeUnionFromElement(reader, const std::wstring&, dispatch);

// after
template <typename ValueT, typename DispatchT>
... DeserializeFromElement(reader, const wchar_t*, dispatch);

An element with a sole model type does not dispatch. 40 of the 51 <Cls>FromElement spelled out a one-case switch inside a fifteen-line lambda signature; the other 11 keep theirs.

return DeserializeSoleFromElement<
  std::shared_ptr<types::IExtension>
>(
  reader,
  L"IExtension",
  types::ModelType::kExtension,
  ExtensionFromSequence<types::IExtension>
);

The public <Cls>From were 51 copies of the same 54 lines: open the reader, read the root element, check that nothing but whitespace follows.

return DeserializeFrom<
  std::shared_ptr<types::IExtension>
>(
  is,
  options,
  ExtensionFromElement
);

interface_name is a const wchar_t*, not a const std::wstring&: every call site used to construct a std::wstring on every element read, success path included, for a message which is almost never built.

xmlization.cpp for the AAS meta-model goes 34,543 -> 24,634 lines and its read region 23,584 -> 13,675 (-42%).

C++ is the last backend to have its XML read side decomposed, after C#
(#686), Go (#688), Java (#690), Python (#694) and TypeScript (#698). #673
factored out the framing of an *element*; this factors out the framing of
the property *loop*, which every ``<Cls>FromSequence`` re-emitted in full.

**A property** loses its duplicate check and its ``std::tie``, and names
only itself and what reads it.

    // before
    case properties::OfExtension::kSemanticId: {
      if (the_semantic_id.has_value()) {
        error = DuplicatePropertyError(name);
        break;
      }

      std::tie(
        the_semantic_id,
        error
      ) = ReferenceFromSequence<
        types::IReference
      >(reader);
      break;
    }

    // after
    case properties::OfExtension::kSemanticId:
      return ReadInto(
        the_semantic_id,
        ReferenceFromSequence<
          types::IReference
        >(reader)
      );

**The loop** around it becomes one call. It could be lifted out at all only
because the framing now returns the error alone, instead of
``NoInstanceAndDeserializationError<std::shared_ptr<T> >``, and so no longer
needs ``T``.

    // before, ~220 lines in each of the 38 ``<Cls>FromSequence``
    while (true) {
      error = SkipWhitespace(reader);
      if (error.has_value()) { ...6 lines... }
      if (reader.node().kind() == xml_common::NodeKind::Stop) {
        break;
      } else if (reader.node().kind() != xml_common::NodeKind::Start) {
        ...10 lines naming IExtension...
      }
      const std::string name(...);
      reader.Read();
      auto it = properties::kMapOfExtension.find(name);
      if (it == properties::kMapOfExtension.end()) { ...12 lines... }
      switch (it->second) { ... }
      ...~60 lines of prepending and stop-element handling...
    }

    // after
    common::optional<DeserializationError> error(
      ReadProperties<
        properties::kPropertyCountOfExtension
      >(
        reader,
        properties::kMapOfExtension,
        L"IExtension",
        [&](
          properties::OfExtension property
        ) -> common::optional<DeserializationError> {
          switch (property) { ... }
        }
      )
    );

The ``switch`` stays inline because its branches assign the locals the
constructor is called with. The lambda is a template argument taken by
``const&``, so the call is statically bound and nothing is type-erased or
allocated; ``g++ -O2`` keeps ``ReadProperties`` itself out of line, one
instantiation per class, which is as many function bodies as the loops it
replaces.

**The duplicate property element check** is bookkeeping of the loop,
not of the property, so it moved there, as a bit set sized to
the class. It still precedes the read, so a duplicate is refused without
its content ever being looked at.

    std::bitset<kPropertyCount> seen;
    ...
    if (seen[index]) {
      error = DuplicatePropertyError(name);
      PrependElementSegmentToDeserializationError(name, *error);
      return error;
    }

    seen[index] = true;

**``ReadInto`` takes the results of a read, not the reader.** A tuple's item
readers vary in number *and* in type, so no reader-taking signature serves
them all, while one taking the result serves every read.

    template <typename T>
    common::optional<DeserializationError> ReadInto(
      common::optional<T>& target,
      std::pair<
        common::optional<T>,
        common::optional<DeserializationError>
      >&& read
    );

**A named union is framed like a class.** The two framings were ~160
near-identical lines differing only in the value type, which every error
factory they call is already generic in.

    // before
    template <typename T, typename DispatchT>
    ... DeserializeClassFromElement(reader, const std::wstring&, dispatch);
    template <typename VariantT, typename DispatchT>
    ... DeserializeUnionFromElement(reader, const std::wstring&, dispatch);

    // after
    template <typename ValueT, typename DispatchT>
    ... DeserializeFromElement(reader, const wchar_t*, dispatch);

**An element with a sole model type does not dispatch.** 40 of the 51
``<Cls>FromElement`` spelled out a one-case ``switch`` inside a fifteen-line
lambda signature; the other 11 keep theirs.

    return DeserializeSoleFromElement<
      std::shared_ptr<types::IExtension>
    >(
      reader,
      L"IExtension",
      types::ModelType::kExtension,
      ExtensionFromSequence<types::IExtension>
    );

**The public ``<Cls>From``** were 51 copies of the same 54 lines: open the
reader, read the root element, check that nothing but whitespace follows.

    return DeserializeFrom<
      std::shared_ptr<types::IExtension>
    >(
      is,
      options,
      ExtensionFromElement
    );

**``interface_name`` is a ``const wchar_t*``**, not a ``const std::wstring&``:
every call site used to construct a ``std::wstring`` on *every* element read,
success path included, for a message which is almost never built.

``xmlization.cpp`` for the AAS meta-model goes 34,543 -> 24,634 lines and
its read region 23,584 -> 13,675 (-42%).
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35737536379

Coverage increased (+0.005%) to 87.176%

Details

  • Coverage increased (+0.005%) from the base build.
  • Patch coverage: Could not be determined — this PR's diff is too large for GitHub to return (406 error at GitHub).
  • 42 coverage regressions across 1 file.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

42 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
aas_core_codegen/cpp/lib/_generate_xmlization.py 42 94.2%

Coverage Stats

Coverage Status
Relevant Lines: 42404
Covered Lines: 36966
Line Coverage: 87.18%
Coverage Strength: 2.62 hits per line

💛 - Coveralls

@mristin
mristin merged commit 8ff6cd5 into main Sep 22, 2026
5 checks passed
@mristin
mristin deleted the compose-cpp-xml-deserialization branch September 22, 2026 16:22
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.

2 participants