|
Unit Conversion and Dimensional Analysis Library 3.6.1
A compile-time, header-only C++23 dimensional-analysis library
|
Encode a quantity to a self-describing byte stream, and decode it on the other side without prior agreement on its type.
<units/serialization.h> writes a quantity to a compact binary stream that carries both the value and the dimension. A reader recovers the quantity from the bytes alone: it discovers what dimension the stream holds before it names a target type, so the two peers need no shared header, no schema, and no out-of-band agreement on the unit. The encoding is not limited to the built-in dimensions — any base dimension, including one you define yourself, round-trips.
This is a separate, opt-in header. It is not pulled in by <units.h>; include it only where you serialize.
Related how-to guides: JSON serialization, defining new units, chrono interop.
Write a quantity to a stream and read it back. serialize gives you the bytes to write; on the way back deserialize hands you the quantity, or a deserialize_error.
The stream is self-describing: the reader was never told the value was a velocity. It read the dimension out of the bytes, and to<kilometers_per_hour<double>>() succeeded because that dimension matched. Ask for a dimension the stream does not hold and the collapse fails cleanly rather than misreading the number.
Both serialize and deserialize center on any_unit: serialize(q) returns one (owning the encoded bytes), and deserialize returns one (wrapped in std::expected, since malformed bytes can fail). It is a value type, not a bag of bytes — it compares, orders within a dimension, hashes, and prints.
Streams — the terse I/O. operator<< writes an any_unit's binary bytes and operator>> (or deserialize(istream)) reads one back:
A record is self-delimiting, so writing several and reading them back in order just works (file << serialize(a) << serialize(b); file >> x >> y;). Reading rewinds the stream to just past each record, so the stream must be seekable (a file or memory stream, opened in binary); for a non-seekable socket, frame the records yourself and deserialize each frame.
Its bytes directly, two ways. When you are not going through a stream, any_unit owns the serialized form and exposes it as a type-safe span or a C-interface pair (both valid for the object's lifetime):
Caveat — the byte views are non-owning. bytes(), data(), and size() view the buffer inside the any_unit; they are valid only while that any_unit is alive. Keep it in a named variable rather than calling serialize(q).bytes() on a temporary. To hold the bytes past the any_unit, copy them into your own std::vector<std::byte>.
Comparison, ordering, hashing, text. any_unit behaves like the value it represents:
Equality is same dimension and same SI-base magnitude (using the same relative tolerance as the concrete unit comparison), so it is a comparison of quantities, not of unit names or of bytes. Ordering is a std::partial_ordering: quantities of one dimension order by magnitude, and quantities of different dimensions are unordered — so </<=/>/>= are all false between them (and any_unit is therefore an unordered_map/unordered_set key, not a std::set key across mixed dimensions).
For text, there are two renderings:
(operator<< on a stream writes the binary bytes, not text.)
Beyond the value-type surface above, these bring an any_unit down to a concrete typed quantity.
to<Unit>() — checked, the safe default. Returns std::expected<Unit, deserialize_error>; a dimension mismatch is a value, not an exception.
assign_to(out) — mismatch-tolerant, into an existing variable. Assigns into out and returns true iff the decoded dimension is out's dimension; on a mismatch it returns false and leaves out untouched. The target unit is deduced from out, so the value is not named twice, and a mismatch is an expected outcome rather than an error — the shape for fanning one erased quantity across several typed fields, assigning only where it fits. A value that would not fit out's underlying type (to's lossy_target) is likewise reported as not assigned, so out is written only with a value it represents exactly.
try_to<Unit>() — throwing. Same collapse, but throws std::runtime_error on a mismatch. Use it where the type is known and a mismatch is a programming error.
unit_cast<Unit>(v) — throwing free function. The free-function spelling of try_to, mirroring std::any_cast.
visit(f) — no target named. Invokes a generic callable with the canonical SI unit of whichever dimension the stream holds. You name no target type at the call site, yet every operation inside the visitor is still dimensionally checked at compile time. This is the tool when a stream may carry any of several dimensions and you want to branch on what arrived.
With no explicit candidates, every dimension the library defines is a candidate, so velocity, force, energy, and the rest resolve out of the box. Pass explicit candidates to resolve a user-defined dimension, to restrict the set the visitor must handle, or to disambiguate two dimensions that share a signature (torque and energy have the same base terms; the first candidate listed wins):
A visit with no matching candidate throws std::runtime_error.
The visitor body must compile for every candidate. visit instantiates the callable once per candidate dimension — with no explicit candidates, that is every dimension the library defines. So the body must be valid for any quantity: use the generic quantity API (value(), arithmetic, comparison, printing), not an assignment or a call that names one specific unit type. Naming a unit forces the body to make sense for length, mass, energy, and the rest all at once, which does not compile:
Restrict the candidate set to make the body specific. When you list exactly the dimensions you expect, the visitor is instantiated only for those, so it may name their units:
Sometimes you want to route or log an erased quantity without committing to a concrete type. Three accessors read it in place:
any_unit is a value type. It compares and hashes by quantity — dimension plus SI-base magnitude — not by unit name or by bytes, so 1000 m and 1 km are equal:
Equality uses the same relative tolerance as the concrete unit comparison, so an any_unit compares no more strictly than the units it erases. Ordering is a std::partial_ordering: quantities of one dimension order by magnitude; quantities of different dimensions are unordered, so </<=/>/>= are all false between them (hence any_unit is an unordered_map/unordered_set key, not a std::set key across mixed dimensions).
For text, prefer to_string(): when the library knows the decoded dimension it renders the SI-base magnitude in that dimension's canonical named unit (100 m, 9.81 m s^-2) — the same text operator<<(ostream, unit) produces. For a dimension the library cannot name (a user-defined make_dimension, whose name the wire's name-hash cannot recover), to_string() degrades to to_string_raw(), the always-available name-free form: the magnitude followed by the hashed base-dimension signature (#<hash>^<exponent>). Call to_string_raw() directly when you want that dimension-agnostic form unconditionally. (operator<< on a stream writes the raw binary bytes, not text.)
When the type is known ahead of time, deserialize<Unit>(bytes) decodes straight into it, skipping the erased intermediate. It is deserialize followed by to<Unit>(), in one call.
The stream is still self-describing — a dimension mismatch here surfaces as deserialize_error::dimension_mismatch rather than a misread value.
Every fallible entry point returns std::expected<..., deserialize_error> (the throwing collapses convert the error into a std::runtime_error). The reasons are exhaustive:
| deserialize_error | Meaning |
|---|---|
| truncated | the byte range ended before a complete quantity was read |
| bad_version | the stream's format-version byte is not one this build understands |
| dimension_mismatch | the stream's dimension does not match the requested target |
| unknown_base_dimension | reserved: a base-dimension code this build does not know |
| lossy_target | the value cannot be represented in the requested underlying type without loss |
An unknown base-dimension hash is not itself an error at decode time — the stream decodes into an any_unit carrying that dimension, and it surfaces as dimension_mismatch when you try to collapse it into a target whose signature it cannot match. The unknown_base_dimension code is part of the public enum for exhaustive switch coverage; the current decoder does not produce it.
lossy_target is the same guard the rest of the library applies to a lossy narrowing: collapsing a fractional value into an integer underlying type is an error, not a silent truncation.
The stream identifies each base dimension by an 8-byte FNV-1a hash of the dimension's name string, not by a position in a fixed table. Two consequences follow. There is no fixed set of dimensions the format can represent — a dimension the library has never seen still encodes and decodes. And there is no ceiling on how many base dimensions one quantity may compose; the signature is a variable-length list.
A base dimension you define with make_dimension serializes and round-trips with no registration anywhere:
The to<Unit>(), try_to<Unit>(), and unit_cast paths work for a user-defined dimension exactly as for a built-in one, because they compare the decoded signature against a named target type you supply.
visit, though, must be told the user dimension:
This is a fundamental limit, not an omission. visit resolves a runtime hash into a C++ type by trying its candidate list; with no explicit candidates it tries the library's own dimensions. C++ cannot materialize a type from a runtime value, so a dimension the compiler was never shown at the call site cannot be a candidate — the runtime-to-type wall. List your dimension as a candidate and the wall is gone.
The stream is a version byte, a one-byte header, a variable-length dimension signature, and the value in SI canonical base. Every integer is an LEB128 varint (zig-zag for signed values), so small magnitudes and small exponents cost few bytes.
The value is always stored in SI canonical base, so the stream is unit-agnostic within its dimension: 60_mph and 26.8224_mps serialize to the same value bytes, and either decodes to whichever velocity unit the reader asks for.
The numbers below are measured on the machine that built the docs (GCC 15, x86-64); treat them as representative, not as guarantees. Reproduce them with the snippets under examples/ compiled at -std=c++23 -I include.
Bytes per serialized quantity across a spread, next to a naive {"value":V,"unit":"U"} JSON string for the same quantity. The binary stream is self-describing where the JSON is not — the JSON needs both peers to agree on the unit out of band, whereas the binary carries the dimension.
| Quantity | Serialized bytes | Naive JSON string |
|---|---|---|
| 100.0_m (integer meters) | 14 | 24 |
| 5000.0_g (5 kg) | 13 | 23 |
| 1.0_GB | 17 | 23 |
| 100.0_TB | 19 | 25 |
| 2.5_A | 16 | — |
| 1.5_V | 43 | — |
| 20.0_degC | 20 | 26 |
| 100.0_psi | 38 | 26 |
| 60.0_mph | 29 | 25 |
| 9.81_mps2 | 29 | 29 |
| dimensionless<double>(0.25) | 7 | — |
| 42.0_spk (user-defined dimension) | 13 | — |
Size tracks the two things that vary: how many base-dimension terms the quantity has (each is an 8-byte hash plus its exponent), and whether the SI-base value lands as an integer varint, a 32-bit float, or a full double. A single-term integer quantity is small; a compound quantity whose base value is an irrational double (1.5_V decomposes into four base dimensions with a 64-bit value) is larger. The self-describing dimension is carried in the hash-keyed terms rather than a unit string, so it stays compact even for units with long names.
Best-of-three warmed incremental builds of a single translation unit, measured with a Python subprocess timer. These are warm numbers — the compiler and filesystem caches are hot — and are machine-dependent; absolute values will differ on your box, but the deltas are the point.
| Translation unit | Compile time | Delta |
|---|---|---|
| (a) #include <units.h> only | ~4.4 s | baseline |
| (b) + <units/serialization.h>, serialize + deserialize + to | ~4.9 s | +0.4 to +0.6 s |
| (c) + a visit() call | ~5.1 s | +0.14 to +0.18 s over (b) |
Adding serialization to a translation unit that already includes <units.h> costs a small fraction of a second; visit() adds less again. The bulk of the compile time is <units.h> instantiating its 48 dimensions, as it is without serialization — the serialization header adds little on top. If a translation unit's compile time matters, the larger lever is including only the per-dimension headers you use (see subset headers for compile time); the serialization header itself is close to free.
A tight loop of serialize → deserialize → to<Unit>() — the full round-trip, including the serialize allocation — runs at roughly 85 ns per round-trip at -O2 on the same machine. The dimension compare is over a handful of 8-byte hashes, and the value codec is a varint or a memcpy; the cost is dominated by the buffer allocation, not the encoding.