Serialization¶
NUClear's serialization system handles the conversion between in-memory C++ types and byte sequences suitable for network transmission.
It's built around a single template — Serialise<T> — that provides three operations: serialize, deserialize, and hash.
The Serialise Template¶
template <typename T>
struct Serialise {
static std::vector<uint8_t> serialise(const T& in);
static T deserialise(const std::vector<uint8_t>& in);
static uint64_t hash();
};
These three methods form the complete interface:
serialise— converts aTinto bytes for transmissiondeserialise— reconstructs aTfrom received byteshash— returns a 64-bit identifier used for type routing
The Serialization Pipeline¶
flowchart LR
subgraph Sender
T1[Type T instance] --> SER["Serialise< T >::serialise()"]
T1 --> HASH["Serialise< T >::hash()"]
SER --> PAYLOAD[payload bytes]
HASH --> ROUTE[type hash for routing]
end
PAYLOAD --> NET[Network]
ROUTE --> NET
subgraph Receiver
NET --> LOOKUP["Look up reactions by hash"]
NET --> DESER["Serialise< T >::deserialise()"]
DESER --> T2[Type T instance]
LOOKUP --> DISPATCH[Dispatch to matching reactions]
end
The hash travels in the packet header so the receiver knows what type was sent. The payload is the serialized bytes. On arrival, the receiver looks up which reactions are registered for that hash, then deserializes the payload back into the original type.
Built-in Strategies¶
NUClear provides three automatic specializations of Serialise<T> selected via SFINAE (template metaprogramming).
You don't need to write any serialization code if your type matches one of these.
Trivially Copyable Types¶
template <typename T>
struct Serialise<T, std::enable_if_t<std::is_trivially_copyable<T>::value, T>> {
static std::vector<uint8_t> serialise(const T& in) {
std::vector<uint8_t> out(sizeof(T));
std::memcpy(out.data(), &in, sizeof(T));
return out;
}
static T deserialise(const std::vector<uint8_t>& in) {
return *reinterpret_cast<const T*>(in.data());
}
};
For types like int, float, simple structs with no pointers — anything where memcpy gives you a valid copy — this "just works".
It's the fastest possible serialization: zero overhead, no parsing.
Caveat: This is not portable across different architectures. Endianness, struct padding, and alignment can all differ. Only use this when sender and receiver are:
- The same machine (local IPC), or
- Identical hardware with identical compiler settings
Iterables of Trivial Types¶
// Matches: std::vector<int>, std::array<float, 3>, etc.
template <typename T>
struct Serialise<T, std::enable_if_t<
std::is_trivially_copyable<iterator_value_type_t<T>>::value, T>>
If your type is iterable (has begin()/end()) and its elements are trivially copyable, NUClear serializes it element-by-element.
This handles std::vector<float>, std::array<double, 3>, and similar containers automatically.
Deserialization reconstructs the container by dividing the byte count by element size.
Google Protocol Buffers¶
template <typename T>
struct Serialise<T, std::enable_if_t<
std::is_base_of<google::protobuf::Message, T>::value, T>>
If your type inherits from google::protobuf::Message or MessageLite, NUClear automatically uses Protobuf's built-in serialization:
- Serialize:
SerializeToArray - Deserialize:
ParseFromArray - Hash: based on
GetTypeName()(the Protobuf message name)
This gives you portable, schema-evolving serialization with no extra work — just define your .proto file and use the generated class.
Type Hashing¶
Every serializable type gets a 64-bit hash used for routing messages to the correct reactions:
static uint64_t hash() {
const std::string type_name = demangle(typeid(T).name());
return xxhash64(type_name.c_str(), type_name.size(), 0x4e55436c);
}
Key details:
- Algorithm: xxHash64 — extremely fast, good distribution
- Input: the demangled C++ type name (e.g.,
"MyNamespace::SensorData") - Seed:
0x4e55436c— the ASCII bytes for "NUCl"
Important Implications¶
Both sender and receiver must have exactly the same demangled type name. This means:
- Share the header file defining the type between all nodes
- The type must be in the same namespace
- Template parameters must match exactly
- Different compilers/platforms may demangle differently (though in practice GCC and Clang agree for most types)
For Protobuf types, the hash uses the Protobuf message name (GetTypeName()) rather than the C++ class name.
This is more stable across compiler differences.
Custom Serialization¶
For types that don't fit the built-in strategies, specialize Serialise<T>:
namespace NUClear {
namespace util {
namespace serialise {
template <>
struct Serialise<MyComplexType> {
static std::vector<uint8_t> serialise(const MyComplexType& in) {
std::vector<uint8_t> out;
// Custom encoding logic
// e.g., write fields in a portable order with known sizes
return out;
}
static MyComplexType deserialise(const std::vector<uint8_t>& in) {
MyComplexType out;
// Custom decoding logic matching the encoding
return out;
}
static uint64_t hash() {
// Usually fine to use the default name-based hash:
const std::string name = demangle(typeid(MyComplexType).name());
return xxhash64(name.c_str(), name.size(), 0x4e55436c);
}
};
} // namespace serialise
} // namespace util
} // namespace NUClear
You need a custom specialization when:
- Your type has pointers or references (can't memcpy)
- You need cross-architecture portability for a non-Protobuf type
- You have a complex nested structure
- You want to use a different serialization library (FlatBuffers, MessagePack, etc.)
When to Use What¶
flowchart TD
START[Need to send type T over network] --> TRIVIAL{Is T trivially copyable?}
TRIVIAL -->|yes| ARCH{Same architecture on all nodes?}
ARCH -->|yes| USE_TRIVIAL[Use default - zero cost memcpy]
ARCH -->|no| NEED_PORTABLE[Need portable format]
TRIVIAL -->|no| PROTO{Is T a Protobuf message?}
PROTO -->|yes| USE_PROTO[Use default - Protobuf handles it]
PROTO -->|no| USE_CUSTOM[Write custom Serialise< T >]
NEED_PORTABLE --> PROTO2{Can define as Protobuf?}
PROTO2 -->|yes| USE_PROTO2[Define .proto and use generated class]
PROTO2 -->|no| USE_CUSTOM2[Write custom Serialise< T >]
style USE_TRIVIAL fill:#6bcb77,color:#fff
style USE_PROTO fill:#4d96ff,color:#fff
style USE_PROTO2 fill:#4d96ff,color:#fff
style USE_CUSTOM fill:#ff922b,color:#fff
style USE_CUSTOM2 fill:#ff922b,color:#fff
| Scenario | Strategy | Pros | Cons |
|---|---|---|---|
| Same machine / identical targets | Trivially copyable (default) | Zero overhead, no code needed | Not portable |
| Cross-architecture, schema evolution needed | Protobuf | Portable, versioning, widely supported | Requires .proto definitions |
| Cross-architecture, no Protobuf | Custom Serialise<T> |
Full control | Manual implementation |
| Containers of primitives | Iterable specialization (default) | Automatic for vectors/arrays | Still not portable across architectures |
Relationship to Networking¶
The serialization system is used by two DSL words:
emit<Scope::NETWORK>— callsSerialise<T>::serialise()andSerialise<T>::hash()to prepare data for sendingNetwork<T>— callsSerialise<T>::deserialise()to reconstruct received data, useshash()at bind time to register interest
The NUClearNetwork engine itself is serialization-agnostic — it only sees uint64_t hash and std::vector<uint8_t> payload.
The type-aware serialization happens in the DSL layer above it.