|
Unit Conversion and Dimensional Analysis Library 3.6.1
A compile-time, header-only C++23 dimensional-analysis library
|
This page explains internals. To use the library — including defining your own units — you do not need any of this; see defining new units. This document is the reference for unit authors and contributors: how the readable-diagnostics machinery is built. Nothing here is required to write correct code with the library.
The 3.x line produces readable compiler diagnostics: an error names meters<double>, not unit<conversion_factor<std::ratio<1>, dimension_t<...>>, double, linear_scale>. That naming is not cosmetic string formatting — it falls out of a type-design decision (named units are classes, not aliases) supported by a trait layer that keeps the name attached as values flow through construction, conversion, and arithmetic. This page walks that machinery.
All line references are to include/units/core.h unless noted.
The pivotal decision: a named unit such as meters is a class template deriving from its unit<...>, generated by UNIT_ADD_SCALED_UNIT_DEFINITION (core.h:211):
In 2.x a named unit was an alias template (using meters = unit<...>). An alias is transparent: the compiler sees straight through it to the aliased type, so a diagnostic printed the full unit<...> spelling. A class is opaque — it has its own name — so the compiler prints meters<double>. This is the entire reason the type is a class. The class adds no data members and no behavior beyond what it inherits; it exists to be a name.
UNIT_ADD (core.h:360) composes the full definition: the strong conversion factor (UNIT_ADD_STRONG_CONVERSION_FACTOR), the class (UNIT_ADD_UNIT_DEFINITION → UNIT_ADD_SCALED_UNIT_DEFINITION), the name/abbreviation traits, the reverse-map registration (UNIT_REGISTER_NAMED_CLASS), the literals, and the constant.
Note: the pure dimensionless unit (dimensionless, ratio 1) is the one exception — it stays a plain alias to unit<...> (core.h:3230), because it must remain identity-equal to its base and fully interchangeable with int/double. Named ratio-dimensionless units (percent, ppm, …) are still classes: they carry a meaningful name.
Because a named unit is now a class template, writing the bare name requires class template argument deduction (CTAD). An alias template resolved a bare meters through its default template argument; a class template does not do that on its own on every compiler (GCC 13, for instance, will not deduce from an arithmetic argument without help). UNIT_ADD_SCALED_UNIT_DEFINITION therefore emits four deduction guides (core.h:248–268):
Each guide is constrained so it never competes with another: the arithmetic guide only fires for scalars, the from-unit guide only for same-dimension units, the chrono guide only for std::chrono::duration. The from-unit guide's deduced underlying is computed by detail::deduced_named_underlying_t (core.h:2293): the source's own underlying when the conversion is lossless, otherwise its floating-point promotion — so radians(degrees{1}) deduces radians<double> (the degrees→radians conversion is not integer-lossless), while a lossless same-underlying conversion keeps the source's type.
The unit's first template parameter is the strong conversion factor — the named tag such as meters_ rather than a raw conversion_factor<...>. traits::strong_t<Cf> (core.h:916) resolves a conversion_factor to its registered strong type. The resolution mechanism is central to the design; this section describes how it is built.
traits::strong<T> (core.h:905) does not specialize a trait. It performs an unqualified call to a function strong_name and takes its return type:
There is a fallback overload (detail::strong_name, core.h:756) that is the worst match — it deduces the conversion factor from its pointer argument and has a trailing ellipsis:
Each dimension header, via UNIT_ADD_STRONG_CONVERSION_FACTOR (core.h:164), declares a better-matching overload — an exact-pointer parameter with no ellipsis — that returns the named strong type:
The call is unqualified so that argument-dependent lookup (ADL) finds every such overload: a conversion_factor's associated namespaces are units and units::detail, exactly where the registrations live. Overload resolution then picks the exact-parameter registration when its dimension header is visible, and the ellipsis fallback (identity) otherwise. All of these functions are declared, never defined — they are used only inside decltype.
Design rationale (#357 — "explicit specialization after instantiation"): the earlier design used an explicit specialization of a trait to register a strong type. That is order-sensitive: once strong<Cf> is implicitly instantiated, a later-included header that specializes it for the same Cf is ill-formed ("explicit specialization after instantiation"). An overload set has no such rule. A header included later merely contributes another candidate to strong_name; overload resolution re-runs at each instantiation point and finds the better match. There is no "declared after instantiation" trap because nothing is being specialized — this is the structural fix for issue #357, and it is why an expression that reduces to a not-yet-included dimension still compiles cleanly and then names the named type once the dimension header is in scope.
A named unit is a derived class. The exact-pattern trait specializations that the library relies on — std::common_type, replace_underlying, floating_point_promotion — are written against the literal pattern unit<Cf, T, Ns>, which a derived class does not match. Without help, every trait result would decay to the plain unit<...> base and the name would be lost the moment you did arithmetic. Four pieces keep it attached.
detail::unit_base_t<T> (core.h:2753) reconstructs the canonical unit<Cf, U, Ns> from any unit type's inherited member typedefs:
For a named unit this yields its base; for a plain unit<...> it is the identity. The exact-pattern traits use it to unwrap first, so they work uniformly for named and plain units.
detail::is_named_unit_v<T> (core.h:2768) is true exactly when T is a unit that is not its own canonical base — i.e. a derived, named class. It is guarded on traits::is_unit first so a plain arithmetic type (which has no conversion_factor) yields false rather than a hard error (core.h:2760).
Arithmetic operators compute a plain unit<Cf, U, Ns> result (e.g. multiplying two meters gives a unit<square_meters_, …>). To report that result under its named type, detail::rewrap_to_named_t (core.h:2801) maps a conversion factor forward to its named class using a reverse-map ADL function, detail::named_class_of. UNIT_REGISTER_NAMED_CLASS (core.h:300) emits one registration per named unit:
Note (keyed on conversion factor and scale): the map takes two pointer parameters — the conversion factor and the numerical scale. This is required because the linear and decibel forms of a unit share one conversion factor: watts and dBW are both watts_, differing only by scale (core.h:303). Keying the reverse map on the conversion factor alone would make watts and dBW collide. Adding the scale as a second key disambiguates them, which is also why UNIT_ADD_DECIBEL registers its own class and UNIT_REGISTER_NAMED_CLASS is applied to both the linear and decibel names independently.
Like strong_name, named_class_of is an ADL overload set with an identity fallback (core.h:2792, returning void to signal "no named class for this CF"), declared and never defined. The forward map rebinds the registered class to the result's underlying type so the storage type flows through (core.h:2806). The unit's own name() and abbreviation() members go through rewrap_to_named_t first (core.h:2715, core.h:2726) so a named unit reports its name instead of the base's null.
For std::common_type, detail::rewrap_named_t<Base, Named> (core.h:2775) re-wraps a computed base result into a candidate operand's named type when they share a conversion factor (via the class's rebind). The std::common_type specialization for named units (core.h:3014) computes the common type of the canonical bases, then tries to re-wrap it into the left operand's name, then the right's — so common_type<meters<int>, meters<double>> is meters<double>, not the plain unit<...>. It is SFINAE-constrained to fire only when both are units, at least one is named, and a base common type actually exists — matching the plain unit<...> behavior for mismatched dimensions (no type member).
replace_underlying (core.h:2822) and floating_point_promotion (core.h:2834) get analogous named-unit specializations that rebind the name to the new underlying instead of decaying to the base.
A unit must stay a trivial value type — memcpy-able, zero-overhead, indistinguishable from its underlying scalar at run time. Making the named unit a class threatens that: declaring the converting constructor unitName(const base&) would suppress the trivial default constructor, and inheriting the base's constructors (using base::base;) leaves the special members implicit. The macro therefore explicitly defaults all of them (core.h:223–227):
That restores std::is_trivial. Combined with __declspec(empty_bases) on MSVC (core.h:2381) so empty base classes take no space, a named unit is the same size as its underlying storage:
Design rationale: the name is a compile-time property of the type; it imposes zero run-time cost. Explicitly defaulting the special members is what keeps the class the name is built on the same size and triviality as the double it stores. The readable diagnostics carry no run-time cost.