Understanding Safety Levels in Physical Units Libraries¶
Physical quantities and units libraries exist primarily to prevent errors at compile time. However, not all libraries provide the same level of safety. Some focus only on dimensional analysis and unit conversions, while others go further to prevent representation errors, semantic misuse of same-dimension quantities, and even errors in the mathematical structure of equations.
This article explores six distinct safety levels that a comprehensive quantities and units library can provide. We'll examine each level in detail with practical examples, then compare how leading C++ libraries and units libraries from other languages perform across these safety dimensions. Finally, we'll analyze the performance and memory costs associated with different approaches, helping you understand the trade-offs between safety guarantees and runtime efficiency.
We'll pay particular attention to the upper safety levels—especially quantity kind safety (distinguishing dimensionally equivalent concepts such as work vs. torque, or Hz vs. Bq) and quantity safety (enforcing correct quantity hierarchies and scalar/vector/tensor mathematical rules)—which are well-established concepts in metrology and physics, yet remain widely overlooked in the C++ ecosystem. Most units library authors and users simply do not realize these guarantees are achievable, or how much they matter in practice. These levels go well beyond dimensional analysis, preventing subtle semantic errors that unit conversions alone cannot catch, and are essential for realizing truly strongly-typed numerics in C++.
Quick Overview
For a quick introduction to safety features in mp-units, see the Safety Features page in Getting Started. This blog post provides in-depth analysis, library comparisons, and the rationale behind each safety level.
The Six Safety Levels¶
A comprehensive physical quantities and units library can provide six distinct layers of safety:
- Dimension Safety - Prevents mixing incompatible dimensions
- Unit Safety - Prevents unit mismatches and eliminates manual scaling factors
- Representation Safety - Protects against overflows and precision loss
- Quantity Kind Safety - Prevents arithmetic on quantities of different kinds
- Quantity Safety - Enforces correct quantity relationships and equations
- Mathematical Space Safety - Distinguishes points, absolute quantities, and deltas
The first five levels form a progression in dimensional analysis, type safety, and numeric safety, building upon the previous levels to create increasingly sophisticated protection against errors.
Mathematical Space Safety addresses an orthogonal concern: the mathematical distinction between points and deltas (and, with V3, also absolute quantities on a ratio scale), providing complementary protection that works alongside the other safety levels.
Level 1: Dimension Safety¶
All major C++ units libraries provide dimension safety—this is the foundational feature that enables dimensional analysis.
What It Prevents?¶
Without dimension safety:
auto speed = 100; // 100 what? No dimension information
auto time = 2; // 2 what? No dimension information
auto wrong = speed + time // Wrong logic, but compiles!
auto distance = speed / time; // Wrong logic! Should be speed * time, but compiles
Dimension safety provides:
- Dimensional analysis: Operations check dimension compatibility
- can't add
length + time
- can't add
- Dimension verification: Values have units of a correct dimensions
- meters for length
- seconds for time
- Derived dimensions: Arithmetic produces correct dimensions
speed / timedoes not result itdistance
Plain double daily reality
Any C++ codebase that uses plain double (or any other fundamental type) to express
quantities has exactly the above issues — and this is still surprisingly common in
production code today. Strong types that carry dimension information are the only
reliable way to express independent physical abstractions at the type level.
Example¶
quantity speed = 100 * km / h;
quantity time = 2 * h;
// quantity<si::kilo<si::metre>> distance = 2 * h; // ❌ Compile-time error!
// quantity<si::kilo<si::metre>> distance = speed / time; // ❌ Compile-time error!
quantity<si::kilo<si::metre>> distance = speed * time; // ✅ OK
// quantity result = distance + time; // ❌ Compile-time error!
// Error: cannot add length and time (incompatible dimensions)
Level 2: Unit Safety¶
Unit safety ensures that quantities are constructed, assigned, and passed across interface boundaries only with compatible units. It covers function arguments, return types, and component integration.
What It Prevents?¶
Even when the dimension is correct, using the wrong unit silently produces wrong values:
void set_speed_limit(double speed_km_h); // Expects km/h, but what if you pass m/s?
set_speed_limit(25.0); // 25 what? km/h? m/s? mph? Compiles but dangerous!
double diameter_in = 12.5; // inches
double diameter_mm = diameter_in * 25.4; // Is this magic number really correct?
double area_cm2 = diameter_mm * diameter_mm; // Bug: forgot to convert mm → cm first!
Common scenarios where unit safety matters:
- Function arguments: Passing meters when kilometers expected
- Return types: Misinterpreting returned values
- Component integration: Mixing units between modules
- Value extraction: Getting raw numbers without unit verification
- Refactoring: Changing function argument units without updating call sites
- Manual conversions: Error-prone scaling factors
Historical Mishap: Mars Climate Orbiter (1999)
In 1999, NASA's Mars Climate Orbiter was lost due to unit confusion between its subsystems: one software component produced thruster impulse data in pound-force-seconds (lbf·s, imperial), while the navigation software expected newton-seconds (N·s, SI). The spacecraft entered the Martian atmosphere at the wrong angle and was destroyed.
Cost: $327 million. Unit safety would have made this a compile-time error.
Real-World Consequence: The 'Magic 1.0' Problem in HEP
Different HEP frameworks silently use different base units for the same quantity:
| Quantity | Geant4 | ROOT |
|---|---|---|
| Length | 1.0 = 1 mm |
1.0 = 1 cm |
| Time | 1.0 = 1 ns |
1.0 = 1 s |
| Angle | 1.0 = 1 rad |
1.0 = 1 deg |
| Energy | 1.0 = 1 MeV |
1.0 = 1 GeV |
Bridge code between frameworks requires manual scaling factors everywhere.
One missing / CLHEP::cm makes a detector geometry 10× smaller — silently, at runtime:
double radius = rootVolume->GetRmax(); // Returns 10.0 — meaning 10 cm in ROOT
G4Tubs g4Tube("Tube", 0, radius, ...); // Geant4 reads it as 10 mm!
With mp-units, ROOT and Geant4 quantities carry their unit in the type, so mixing them automatically applies the correct conversion — no manual scaling factors, no silent bugs.
Example: The Real Cost of Missing Unit Safety¶
Unit errors arise at construction, assignment, and interface boundaries:
// Interface: caller must manually track the expected unit
double avg_speed(double distance_m, double time_s) { return distance_m / time_s; }
double distance_m = 220'000.0; // 220 km in meters
double distance_km = distance_m / 1000.0; // converted to km (220.0)
double time_h = 2.0; // 2 hours
double speed_mps = avg_speed(distance_km, time_h); // Bug! km and h passed, m and s expected
// avg_speed(220.0, 2.0) = 110 — not ~30.56 m/s (silently wrong: km treated as m, h as s)
double speed_kmph = speed_mps / 3.6; // Bug! should be × 3.6 (m/s → km/h)
// Interface: units are enforced at every call site
quantity<m / s> avg_speed(quantity<m> d, quantity<s> t) { return d / t; }
quantity<km> distance = 220'000.0 * m; // ✅ automatic conversion (220 km)
quantity time = 2.0 * h; // 2 hours
quantity<km / h> speed = avg_speed(distance, time); // ✅ auto-converted (km → m, h → s, m/s → km/h)
// avg_speed(220 km, 2 h) = 110 km/h — correct result, no manual scaling needed
The Value Extraction Problem¶
A critical aspect of unit safety is how libraries handle extracting raw numeric values.
Consider std::chrono::duration:
std::chrono::seconds sec(100);
auto count = sec.count(); // Returns 100 - but is it seconds? milliseconds?
// No unit verification! User must remember and hope it's correct and will not change in the future
std::chrono::duration_cast is not the right solution
The std::chrono designers were aware of this problem — std::chrono::duration_cast
was introduced in C++11 precisely to force an explicit unit acknowledgement before
extracting a raw value.
In practice, however, this discipline is rarely maintained consistently. Engineers
skip the cast because they don't know about it, forget it when the current source and
destination units happen to match, or assume it isn't needed "just this once." The
result is that count() is called without a cast throughout production codebases.
The root cause is the interface itself: .count() should never have been callable
without specifying a unit. Relying on user discipline to compensate for an unsafe
API does not scale.
For a complete guide on safely extracting values for legacy interfaces, see Working with Legacy Interfaces.
mp-units takes a stricter approach:
quantity distance = 1500. * m;
// auto value = distance.count(); // Error: no count() method!
auto value = distance.numerical_value_in(km); // Must specify unit explicitly
// Forces users to think about and document the unit being used
Unit Safety Does Not Distinguish Same-Dimension Kinds
Unit safety catches mismatches between different dimensions, but it cannot distinguish between quantities that share the same dimension. Even a library rated Full at this level will silently accept:
radandsr(both dimensionless)HzandBq(both s⁻¹)GyandSv(both J/kg = m²·s⁻²)
Addition, subtraction, and comparison between these pairs all compile without error, despite being physically meaningless. This limitation is fundamental — unit safety operates on dimensions, not on quantity kinds. Addressing it requires Level 4: Quantity Kind Safety.
Level 3: Representation Safety¶
Representation safety protects against numerical issues: overflow, underflow, and precision loss during conversions and arithmetic.
What It Prevents?¶
Many libraries carry unit types but do not validate the representation type on conversion. Truncation and overflow happen silently:
namespace bu = boost::units;
using kilometer_unit = bu::make_scaled_unit<bu::si::length, bu::scale<10, bu::static_rational<3>>>::type;
bu::quantity<bu::si::length, int> distance_m = 32500 * bu::si::meter;
bu::quantity<kilometer_unit, std::int8_t> distance_km(distance_m); // Unit conversion (m → km): Truncation: 32, not 32.5
bu::quantity<bu::si::length, std::int8_t> back_to_m(distance_km); // Unit conversion (km → m): Overflow: wraps around
Two Approaches to Representation Safety¶
Truncation Prevention¶
mp-units follows std::chrono::duration's approach for truncation:
quantity length = 1500 * m;
quantity<km, int> distance = length; // ❌ Compile-time error!
// Error: 1500 m → 1.5 km truncates with int
// Must be explicit about potentially lossy conversions
quantity distance2 = length.force_in(km); // ✅ OK: explicit truncation
auto km_count = length.force_numerical_value_in(km); // ✅ OK: returns raw int (1)
quantity distance3 = length.in<double>(km); // ✅ OK: floating point avoids loss
// Value-preserving conversions work implicitly
quantity<mm, int> length_mm = length; // ✅ OK: 1'500'000 mm (no truncation)
Scaling Overflow Detection¶
mp-units detects when unit scaling exceeds representation limits:
quantity length1 = std::int8_t{2} * m;
quantity<mm, std::int8_t> length_mm1 = length1; // ❌ Compile-time error!
// Error: 2 m → 2000 mm overflows int8_t (max 127)
quantity length2 = std::int16_t{2} * m;
quantity<mm, std::int16_t> length_mm2 = length2; // ✅ OK: 2000 fits in int16_t
Compile-Time Limitations for Runtime Overflow Detection
The values involved in arithmetic operations are only known at runtime, so the compiler cannot predict whether overflow will occur:
quantity<m, std::int8_t> small = 100 * m;
quantity overflow = small * 2; // ⚠️ Runtime overflow! No compile-time detection possible
Solution: Built-in Runtime Safety Infrastructure
mp-units ships built-in tools for runtime overflow and bounds protection:
safe_int<T>— a drop-in integer wrapper that detects arithmetic overflow at runtime (#include <mp-units/safe_int.h>)constrained<T, ErrorPolicy>— a transparent wrapper that tags a representation type with an error policy, enabling guaranteed bounds enforcement viaconstraint_violation_handler(#include <mp-units/constrained.h>)quantity_bounds<Origin>— a customization point that attaches a validation policy (e.g.check_in_range,clamp_to_range,wrap_to_range,reflect_in_range) to a quantity point origin
#include <mp-units/safe_int.h>
#include <mp-units/constrained.h>
// Built-in safe_int detects arithmetic overflow at runtime
quantity distance = safe_int{std::int16_t{100}} * m;
// quantity overflow = distance * std::int16_t{1'000}; // throws std::overflow_error at runtime
// Bounds-checked quantity points with guaranteed enforcement
inline constexpr struct equator final : absolute_point_origin<geo_latitude> {} equator;
template<>
inline constexpr auto mp_units::quantity_bounds<equator> = check_in_range{-90 * deg, 90 * deg};
using safe_double = mp_units::constrained<double, mp_units::throw_policy>;
using latitude = quantity_point<geo_latitude[deg], equator, safe_double>;
latitude lat{95.0 * deg, equator}; // throws std::domain_error (out of [-90, 90])
For the full walkthrough, see Ensure Ultimate Safety.
Should Libraries Check Floating-Point Unit Scaling Overflow?
A more subtle issue: no library currently checks whether unit scaling factors would cause floating-point overflow or underflow during conversions:
quantity<m, double> huge = 1.5e308 * m; // Near double's max (~1.8e308)
quantity<mm, double> overflow = huge; // ⚠️ 1.5e308 × 1000 → inf (overflow!)
quantity<km, double> tiny = 1e-320 * km; // Near double's min
quantity<m, double> underflow = tiny; // ⚠️ 1e-320 × 1000 → 0 (underflow!)
Should libraries detect and prevent this? The question remains open:
- Most real-world values are nowhere near floating-point limits
- Checking would add runtime overhead or complex compile-time analysis
- IEEE 754 provides
infand gradual underflow, which may be acceptable behavior - No consensus exists in the community on whether this is worth addressing
This is an area where library designers must balance safety, performance, and practical utility. Feedback and use cases from the community would help inform future decisions.
Comparison: Au's Approach to Representation Safety
Au differs from mp-units in two compile-time aspects:
Magic number heuristic
The Au library
uses a stricter threshold than mp-units: it blocks conversions where the scaling factor
would overflow their chosen "magic number" value of 2'147, catching cases where the
scaling factor is within the type's range but a typical value might still overflow. The trade-off: the heuristic produces false positives (blocking valid
conversions), which is why Au must also provide an
opt-out mechanism.
Once the opt-out is used, the safety guarantee is gone — the same silence returns.
Critically, neither Au's heuristic nor its runtime checkers cover overflow in hidden
common-unit arithmetic (e.g. m + yd with int32_t) — the most surprising case for users.
mp-units uses the simpler threshold model — the only value that survives a definitely-overflowing
conversion is zero, so blocking it is unambiguous and requires no opt-out. For runtime
coverage of all arithmetic including common-unit operations, use safe_int<T> as the
representation type.
Integral division disallowed
Au rejects integer Quantity / Quantity division
whenever the denominator's unit is not quantity-equivalent to the numerator's —
covering both cross-dimension cases (meters(60) / (miles/hour)(65)) and
same-dimension cases with different magnitudes (hours(8) / minutes(40)). The hazard
is not ordinary truncation but divide-before-convert: hours(8) / minutes(40)
computes 8 / 40 = 0 in integer arithmetic and then applies the unit division,
giving (hours/minute)(0) instead of the expected 12. The escape hatches are
unblock_int_div(denominator) (opt-in, use with caution) and
divide_using_common_unit(a, b) (converts both operands to their common unit first,
which is safe). Division by a raw integer or by a quantity-equivalent unit is always
allowed.
mp-units currently permits all integer quantity division, consistent with plain C++ behaviour. Whether to adopt Au's stricter stance is an open question that may be evisited based on ISO C++ Committee guidance or production experience.
Unit-Qualified Construction¶
Beyond conversions, representation safety also governs construction itself. std::chrono::duration
provides an explicit constructor from a raw integer, which prevents implicit conversions but
does not protect against in-place construction via emplace_back:
std::vector<std::chrono::milliseconds> delays;
delays.emplace_back(42); // ✅ Compiles — stores 42ms
// Refactor: switch to microseconds for higher precision
std::vector<std::chrono::microseconds> delays;
delays.emplace_back(42); // ✅ Still compiles — but now stores 42µs!
// Silent 1000× regression, zero diagnostic
explicit makes the intent clear at declaration sites, but direct in-place construction
bypasses it entirely. Changing the element type causes a silent, factor-of-1000 regression
with no clue from the compiler.
mp-units takes a stricter stance: quantity construction always requires both a number
and a unit. A raw integer never constructs a quantity — not through direct initialization,
assignment, or emplace_back:
std::vector<quantity<si::milli<si::second>>> delays;
// delays.emplace_back(42); // ❌ Compile-time error — unit required!
delays.emplace_back(42 * ms); // ✅ Unit is explicit — intent is unambiguous
delays.emplace_back(42, ms); // ✅ OK
// Refactor: switch to microseconds
std::vector<quantity<si::micro<si::second>>> delays;
// delays.emplace_back(42); // ❌ Compile-time error — still requires a unit!
delays.emplace_back(42 * ms); // ✅ 42ms → 42000µs: explicit, value-preserving conversion
delays.emplace_back(42, ms); // ✅ OK
delays.emplace_back(42 * us); // ✅ Or 42µs if that was the actual intent
delays.emplace_back(42, us); // ✅ OK
There is no construction path that silently discards unit information.
Non-Negative Quantity Tracking¶
Beyond numeric representation, mp-units also encodes domain constraints at the quantity specification level. Many physical quantities are inherently non-negative: length, mass, duration, thermodynamic temperature, amount of substance, and luminous intensity can never be negative. The ISQ base quantity definitions in mp-units carry this fact as a compile-time property:
static_assert(is_non_negative(isq::length));
static_assert(is_non_negative(isq::mass));
static_assert(is_non_negative(isq::duration));
static_assert(!is_non_negative(isq::electric_current)); // can be negative
This property propagates through derived equations and is automatically inherited by named real-scalar children — if all factors in a multiplication or division are non-negative, the result is too, and any named specialization that does not change the quantity character carries the constraint forward:
static_assert(is_non_negative(isq::speed)); // length / duration — both non-negative
static_assert(is_non_negative(isq::area)); // length * length — both non-negative
static_assert(is_non_negative(isq::distance)); // named real-scalar child of length
static_assert(!is_non_negative(isq::velocity)); // vector character — excluded from inheritance
Note
kind_of<QS> is never non-negative, even when QS itself is tagged non_negative,
because kind_of<QS> represents the entire quantity tree including vector quantities
and signed coordinates. This matters when using CTAD with bare SI units:
This metadata is automatically enforced at runtime for quantity_point types: when a
quantity_point uses a natural origin and the associated quantity spec is non-negative,
the library automatically attaches a check_non_negative policy — no explicit
quantity_bounds specialization is needed. You can still override the default by
providing a full specialization before the type is first instantiated:
// Replace error-on-negative with silent clamp-to-zero (e.g., for FP rounding noise):
template<>
inline constexpr auto mp_units::quantity_bounds<natural_point_origin<isq::length>> = clamp_non_negative{};
The metadata is also available for tooling, documentation, and static analysis — for example, to automatically select signed vs. unsigned representations.
Bounded Quantity Points¶
Representation safety also extends to quantity points (Level 6). The library's
quantity_bounds<Origin> customization point lets you attach a validation policy to
any quantity point origin, so geographic coordinates, sensor ranges, and similar
constrained domains are validated at construction and during arithmetic:
inline constexpr struct geo_latitude final : quantity_spec<isq::angular_measure> {} geo_latitude;
inline constexpr struct equator final : absolute_point_origin<geo_latitude> {} equator;
// Latitude must stay within [-90°, 90°] — reflects at boundaries
template<>
inline constexpr auto mp_units::quantity_bounds<equator> = reflect_in_range{-90 * deg, 90 * deg};
using latitude = quantity_point<geo_latitude[deg], equator>;
latitude lat{95.0 * deg}; // reflects to 85° (boundary mirror)
The library ships six overflow policies (check_in_range, clamp_to_range,
wrap_to_range, reflect_in_range, check_non_negative, clamp_non_negative),
and the interface is extensible — you can write your own policy (e.g. a one-sided policy
for custom bounds that are neither zero-based nor symmetric)
as long as it provides V operator()(V). Combined with the constrained<T, ErrorPolicy>
wrapper described above, check_in_range provides guaranteed enforcement in every
build mode — not just debug builds.
check_non_negative and clamp_non_negative are specifically designed for halflines
[0, +∞): the former reports a violation when the value is negative, while the latter
silently clamps negative values to zero.
For the complete walkthrough, see Range-Validated Quantity Points (including Custom Policies) and Ensure Ultimate Safety.
Level 4: Quantity Kind Safety¶
Quantity kind safety distinguishes between quantities that share the same dimension but represent different physical concepts—different "kinds" of quantities.
What It Prevents?¶
Many physical quantities share dimensions but are conceptually distinct:
- Absorbed dose (Gy) and Dose equivalent (Sv): Both
length²/time²(dimension L²T⁻²) - Frequency (Hz) and Activity (Bq): Both
1/time(dimension T⁻¹) - Plane angle (rad) and Solid angle (sr): Both dimensionless ratios
- Area (m²) and Fuel consumption (L/100km): Both
length²(dimension L²) - Fluid head (m) and Water head (m): Both
length(dimension L) - Various counts and ratios: All
dimensionless(dimension 1)
Why This Is Challenging?¶
Quantity kind safety requires going beyond the seven base dimensions and recognizing that quantities sharing the same dimension can represent conceptually distinct physical concepts. This applies both to quantities with special unit names (Gy vs. Sv, Hz vs. Bq) and to those without (area vs. fuel consumption, various dimensionless counts and ratios). mp-units is the only C++ library implementing this level of safety fully.
Quantity Kind Correctness¶
The following examples illustrate how easily same-dimension quantities can be confused in practice—and why keeping them as distinct types is essential for correctness.
Distinguishing among doses¶
Absorbed dose (Gy = J/kg) measures the raw energy deposited in tissue by ionizing radiation. Dose equivalent (Sv = J/kg) weights that energy by a biological effectiveness factor that depends on the type of radiation—a neutron causes far more cellular damage than an X-ray delivering the same absorbed dose. Numerically, 1 Gy of neutrons may correspond to 20 Sv of dose equivalent. Treating them as interchangeable is not just imprecise—in radiation protection, it can directly lead to under- or over-estimating health risk, with life-safety consequences. Yet both quantities share the same dimension (L²T⁻²), so without quantity kind safety, any library will silently allow assigning one to the other.
namespace bu = boost::units;
bu::quantity<bu::si::absorbed_dose> absorbed_dose = 2.5 * bu::si::gray;
bu::quantity<bu::si::dose_equivalent> dose_equivalent = absorbed_dose; // Oops! Compiles!
auto equal = (absorbed_dose == 2.5 * bu::si::sievert); // Oops! Compiles!
// Danger: Sv (dose equivalent) accounts for biological effectiveness; Gy (absorbed dose) doesn't!
quantity absorbed_dose = 1.5 * Gy;
quantity dose_equivalent = 2.0 * Sv;
// auto result = absorbed_dose + dose_equivalent; // ❌ Compile-time error!
// Error: cannot add absorbed dose and dose equivalent
// auto equal = (absorbed_dose == dose_equivalent); // ❌ Compile-time error!
// Error: cannot compare absorbed dose and dose equivalent (no interop between different quantity kinds)
How Serious Is This Problem?
The risk of confusing Gy and Sv is considered so serious that some libraries have chosen to omit one of these units entirely rather than risk users mixing them up. For example, the Au library decided not to provide the Sievert unit specifically because it shares the same dimension as Gray but represents a fundamentally different concept. This design choice—sacrificing completeness for safety—highlights why quantity kind safety is essential for libraries working in safety-critical domains like medical physics and radiation protection.
Distinguishing hydraulic heads¶
In hydraulic engineering, hydraulic head can be expressed in two distinct ways that both have the dimension of length but must not be mixed: fluid head (expressed in terms of the actual fluid) and water head (normalized against specific gravity, expressed in equivalent meters of water). Mixing them silently produces numerically plausible but physically meaningless results—for example, 2 m of mercury fluid head is equivalent to ~27.2 m of water head, not 2 m.
This is a real-world use case reported by mp-units users.
With quantity kind safety, both can be modelled as sub-kinds of isq::height.
To convert between them, a function accepting the specific gravity (SG) must be called
explicitly — the type system enforces that the conversion is intentional.
namespace bu = boost::units;
// Both are just 'length' in Boost.Units — it cannot distinguish fluid from water head:
bu::quantity<bu::si::length> fluid_head = 2.0 * bu::si::meters;
bu::quantity<bu::si::length> water_head = 4.0 * bu::si::meters;
auto wrong_sum = fluid_head + water_head; // Oops! Compiles! 6 m, but physically meaningless
if (fluid_head < water_head) { /* ... */ } // Oops! Compares magnitudes, not water equivalents
// (2 m of mercury ≈ 27.2 m of water!)
// No way to enforce that conversion via specific gravity must happen first:
use_water_head(fluid_head); // Oops! Passes fluid head where water head expected
inline constexpr struct fluid_head final : quantity_spec<isq::height, is_kind> {} fluid_head;
inline constexpr struct water_head final : quantity_spec<isq::height, is_kind> {} water_head;
inline constexpr struct specific_gravity final : quantity_spec<dimensionless> {} specific_gravity;
constexpr QuantityOf<water_head> auto to_water_head(QuantityOf<fluid_head> auto h_fluid,
QuantityOf<specific_gravity> auto sg)
{
// We explicitly cast the result to water_head because we know the physics is correct
return water_head(isq::height(h_fluid) * sg);
}
quantity fluid = fluid_head(2 * m);
quantity water = water_head(4 * m);
use_water_head(water); // ✅ OK
// use_fluid_head(water); // ❌ Compile-time error!
// use_water_head(fluid); // ❌ Compile-time error!
// quantity q = fluid + water; // ❌ Compile-time error!
// if (fluid == water) {} // ❌ Compile-time error!
use_height(isq::height(fluid)); // ✅ OK: explicit upcast to generic length
use_height(isq::height(water)); // ✅ OK: explicit upcast to generic length
// Convert mercury fluid head to equivalent water head
quantity sg_mercury = specific_gravity(13.6);
quantity mercury_as_water = to_water_head(fluid, sg_mercury);
Distinguishing between counts¶
Quantity kind safety is also essential for strongly-typed numeric types. Using dimensionless quantity kinds, you can easily create distinct types for counts, identifiers, ratios, and other numeric values that shouldn't be mixed:
namespace bu = boost::units;
// Both are just 'dimensionless' in Boost.Units — it cannot distinguish different counts:
bu::quantity<bu::si::dimensionless> items = 10.0 * bu::si::si_dimensionless;
bu::quantity<bu::si::dimensionless> widgets = 20.0 * bu::si::si_dimensionless;
auto mixed = items + widgets; // Oops! Compiles! But mixes different kinds
bu::quantity<bu::si::dimensionless> bad = items; // Oops! Silently assigns item count to widget count variable
inline constexpr struct item_count final : quantity_spec<dimensionless, is_kind> {} item_count;
inline constexpr struct widget_count final : quantity_spec<dimensionless, is_kind> {} widget_count;
quantity items = item_count(10);
quantity widgets = widget_count(20);
// auto mixed = items + widgets; // ❌ Compile-time error!
// Error: cannot mix different quantity kinds (even though both dimensionless)
// quantity<widget_count[one]> bad = item_count(10); // ❌ Compile-time error!
// Error: cannot initialize with a different kind (even though both dimensionless)
For detailed examples, see Using dimensionless quantities as strongly-typed numeric types.
Level 5: Quantity Safety¶
Quantity safety is the highest and most sophisticated level, ensuring that the right quantity in the right form is used in each equation. This encompasses two complementary aspects:
- Quantity Type Correctness - Hierarchies, conversions, and quantity equation ingredients validation
- Quantity Character Correctness - Representation types and character-specific operations
Both depend fundamentally on quantity hierarchies.
Why Quantity Safety Matters: Insights from Library Authors¶
In a recent discussion with authors of other powerful C++ units libraries, the question arose: what would we lose without quantity hierarchies?
Without hierarchies, we lose the ability to:
- Distinguish specialized lengths: height, width, depth, radius, wavelength
- Differentiate energy types: kinetic, potential, thermal, enthalpy
- Separate angular quantities: plane angle vs. solid angle (critical distinction!)
- Validate correct ingredients: requiring height (not generic length) for gravitational potential energy
- Discriminate dimensionless quantities: storage capacity, fuel consumption, efficiency ratios, counts
- Track quantity characters: scalar, vector, tensor
- Enforce correct equations:
workfromscalar_product(force, displacement), not fromvector_product - Distinguish power types and their units: active power (W), reactive power (var), complex power and apparent power (VA)
- Use quantities as strongly-typed numeric types: for domain-specific counts and values
Important
Quantity safety is essential for making mp-units a proposal for the strongly-typed numeric types that C++ has long needed.
Quantity Type Correctness¶
Quantity hierarchies allow distinguishing specialized quantities within the same dimension.
Energy Hierarchy Example¶
namespace bu = boost::units;
// No way to require a specific kind of energy in a function signature:
void process_potential_energy(bu::quantity<bu::si::energy> e) { /* ... */ }
// Boost.Units has a single 'energy' type — potential and kinetic are indistinguishable:
bu::quantity<bu::si::energy> Ep = 100.0 * bu::si::joules; // potential energy
bu::quantity<bu::si::energy> Ek = 50.0 * bu::si::joules; // kinetic energy
bu::quantity<bu::si::energy> total = Ep + Ek; // 150 J — but what kind of energy?
// Silently assigns kinetic energy to a potential energy variable:
Ep = Ek; // Oops! Compiles without complaint
process_potential_energy(Ek); // Oops! Passes kinetic energy — compiles!
// Functions requiring specific quantity types won't accept siblings
void process_potential_energy(quantity<isq::potential_energy[J]> pe) { /* ... */ }
// Potential and kinetic energy are siblings in the hierarchy under mechanical_energy
quantity Ep = isq::potential_energy(100 * J);
quantity Ek = isq::kinetic_energy(50 * J);
// Can add siblings - result is their first common parent (mechanical_energy)
quantity<isq::mechanical_energy[J]> total = Ep + Ek; // ✅ OK: 150 J
// Implicit conversion from child to parent
quantity<isq::mechanical_energy[J]> mech1 = Ep; // ✅ OK: upcast
quantity<isq::energy[J]> e1 = Ep; // ✅ OK: upcast to grandparent
// Explicit conversion needed from parent to child
// quantity<isq::potential_energy[J]> wrong = total; // ❌ Error: downcast not allowed
quantity<isq::potential_energy[J]> pe = isq::potential_energy(total); // ✅ OK: explicit
// Siblings cannot be directly converted
// quantity<isq::kinetic_energy[J]> bad = Ep; // ❌ Error: cannot convert between siblings
quantity<isq::kinetic_energy[J]> ke = quantity_cast<isq::kinetic_energy>(Ep); // ✅ OK: forced cast
process_potential_energy(Ep); // ✅ OK: exact match
// process_potential_energy(Ek); // ❌ Error: kinetic_energy is not potential_energy
process_potential_energy(isq::potential_energy(total)); // ✅ OK: explicit conversion
Ingredient Validation¶
Some quantities require specific inputs, not just dimensionally correct ones:
namespace bu = boost::units;
// Boost.Units has no concept of 'height' vs generic 'length':
bu::quantity<bu::si::mass> mass = 10.0 * bu::si::kilograms;
bu::quantity<bu::si::acceleration> g0 = 9.81 * bu::si::meters_per_second_squared;
bu::quantity<bu::si::length> height = 5.0 * bu::si::meters; // height
bu::quantity<bu::si::length> width = 2.0 * bu::si::meters; // generic length
// Both compute fine — no way to enforce that 'height' (not 'width') is required:
auto Ep1 = mass * g0 * height; // OK
auto Ep2 = mass * g0 * width; // Oops! Compiles! Physics is wrong but types are satisfied
inline constexpr struct gravitational_potential_energy final :
quantity_spec<isq::potential_energy,
isq::mass * isq::acceleration_of_free_fall * isq::height> {} gravitational_potential_energy;
constexpr quantity g0 = isq::acceleration_of_free_fall(1 * si::standard_gravity);
quantity<isq::mass[kg]> mass = 10 * kg;
quantity<isq::height[m]> height = 5 * m;
// Gravitational potential energy requires height specifically
quantity<gravitational_potential_energy[J]> Ep = mass * g0 * height; // ✅ OK
// Cannot substitute generic length for height
quantity<isq::length[m]> width = 2 * m;
// quantity<gravitational_potential_energy[J]> wrong = mass * g0 * width; // ❌ Error: generic length
Quantity Character Correctness¶
Quantities have intrinsic character based on their physical nature:
- Real scalar quantities: Real magnitude only (speed, mass, temperature, work)
- Complex scalar quantities: Complex magnitude with real and imaginary parts (complex power, impedance)
- Vector quantities: Magnitude and direction (velocity, force, displacement, momentum)
- Tensor quantities: Multi-dimensional arrays (stress tensor, moment of inertia)
Key distinctions:
- Velocity vs. Speed: Velocity is vector; speed is its scalar magnitude
- Work vs. Moment of force: Work is real scalar from
scalar_product(force, displacement); moment of force is vector fromvector_product(position_vector, force) - Complex power: Complex scalar with real part (active power), imaginary part (reactive power), and modulus (apparent power)
Representation Type Validation¶
A library with quantity character awareness can enforce that the representation type matches the expected character of a quantity — preventing a scalar from being used where a vector is required, or a real type where a complex one is required, and vice versa.
Scalar vs. Vector¶
namespace bu = boost::units;
using vec3 = cartesian_vector<double>;
// Boost.Units has no quantity character concept — any Rep is accepted for any quantity:
auto m = bu::quantity<bu::si::mass, vec3>::from_value(vec3{10., 0., 0.}); // Oops! Vector mass — compiles!
auto t = bu::quantity<bu::si::time, vec3>::from_value(vec3{5., 0., 0.}); // Oops! Vector time — compiles!
// 'speed' and 'velocity' are the same unit (length/time), both accept scalar or vector Rep:
auto speed = bu::quantity<bu::si::velocity, vec3>::from_value(vec3{0., 0., -60.}); // Oops! Vector speed — compiles!
auto velocity = bu::quantity<bu::si::velocity, double>::from_value(60.0); // OK
// No way to enforce that one should be speed (scalar) and the other velocity (vector)
using vec3 = cartesian_vector<double>;
// quantity<isq::mass[kg], vec3> m = /* ... */; // ❌ Compile-time error!
// quantity<isq::duration[s], vec3> t = /* ... */; // ❌ Compile-time error!
quantity q1 = isq::speed(60 * km / h); // ✅ OK: scalar speed
// quantity q2 = isq::speed(vec3{0, 0, -60} * km / h); // ❌ Compile-time error!
// Error: speed is a scalar quantity; vector representation not allowed
quantity q3 = isq::velocity(vec3{0, 0, -60} * km / h); // ✅ OK: vector velocity
quantity q4 = isq::velocity(60 * km / h); // ✅ OK: scalar acting as 1D vector
Scalars as one-dimensional vectors
It is a common engineering practice to use fundamental types for vector quantities when we know
the direction of the vector. For example, in many cases, we use double to express velocity
or acceleration. In such cases, negative values mean moving backward or decelerating. This
is why we also decided to allow such use cases in the library. A scalar representation type
that provides abs() member or non-member function or works with std::abs() is considered
a one-dimensional vector type.
Scalar base quantities do not preclude vector children
ISQ defines base quantities such as mass and duration as scalars, and the library
enforces this for isq::mass and isq::duration themselves. However, it does not
prevent users from adding specialized child quantities with a different character further
down their own hierarchy.
The simple quantity syntax reflects this: vec3{1, 2, 3} * kg is accepted because * kg
creates a quantity of the entire kind tree rooted at mass, not specifically
isq::mass. Whether any physics actually justifies a vector or complex mass is a
separate question — but libraries exist to serve their users, and we never know what
ingenious (or just unusual) domain model someone might need to express. The framework
deliberately stays out of the way when the quantity type itself carries no ISQ-mandated
character constraint.
Real vs. Complex¶
AC circuit power involves both real-valued scalar quantities (active power,
reactive power, apparent power) and a complex-valued one (complex power).
A library with quantity character awareness requires std::complex for complex
quantities and rejects it for real ones — preventing silent representation
mismatches even before any equations are evaluated.
namespace bu = boost::units;
// Boost.Units has no concept of complex quantity character
bu::quantity<bu::si::power> P = 800.0 * bu::si::watts; // Oops! Incorrect unit for active power
bu::quantity<bu::si::power> Q = 600.0 * bu::si::watts; // Oops! Incorrect unit for reactive power
bu::quantity<bu::si::power> S = 1000.0 * bu::si::watts; // Oops! Incorrect unit for apparent power
auto complex1 = P + Q; // Oops! Nonsense!
// No knowledge about which units should match which power quantity
auto complex2 = std::complex{Q.value(), P.value()} * bu::si::watts; // Oops! Incorrect unit and reversed arguments
// Nothing enforces which rep is appropriate — complex rep accepted for any power quantity:
bu::quantity<bu::si::power> complex3 = /* ... */; // Oops! `double` representation for complex power
bu::quantity<bu::si::power, std::complex<double>> active = /* ... */; // Oops! `std::complex` representation for active power
constexpr auto VA = V * A;
quantity P = isq::active_power(800. * W); // ✅ OK: real scalar and correct unit
quantity Q = isq::reactive_power(600. * var); // ✅ OK: real scalar and correct unit
quantity S = isq::apparent_power(1000. * VA); // ✅ OK: real scalar and correct unit
// Complex power — must use complex representation
quantity complex = isq::complex_power((800. + 600.i) * VA); // ✅ OK: complex scalar and correct unit
// Cannot use complex representation for real power quantities:
// quantity<isq::active_power[W], std::complex<double>> wrong1 = /* ... */; // ❌ Compile-time error!
// quantity<isq::reactive_power[var], std::complex<double>> wrong2 = /* ... */; // ❌ Compile-time error!
// quantity<isq::apparent_power[VA], std::complex<double>> wrong3 = /* ... */; // ❌ Compile-time error!
// Cannot use real representation for complex power:
// quantity<isq::complex_power[VA]> wrong4 = /* ... */; // ❌ Compile-time error!
Character-Specific Operations (V3 Planned)¶
In mp-units V3, operations will be restricted to appropriate quantity characters:
speed vs velocity¶
Velocity is a vector quantity — it carries both magnitude and direction. Speed is its
scalar magnitude. They share the same dimension (LT⁻¹) and the same unit (m/s), yet are
fundamentally different: assigning a velocity to a speed silently discards direction,
and calling magnitude() on an already-scalar speed is physically meaningless. Without
character enforcement, both mistakes compile unnoticed.
// Velocity is a vector quantity
quantity velocity = isq::velocity(vec3{3, 4, 0} * m / s);
// Speed is the scalar magnitude
quantity<isq::speed[m/s]> speed = magnitude(velocity); // ✅ 5 m/s
// Cannot accidentally mix them
// quantity<isq::speed[m/s]> s = velocity; // ❌ Compile-time error!
// quantity<isq::velocity[m/s]> v1 = speed; // ❌ Compile-time error!
// quantity<isq::velocity[m/s]> v2 = magnitude(velocity); // ❌ Compile-time error!
// Cannot call magnitude() twice — speed is already a scalar, not a vector:
// quantity<isq::speed[m/s]> s2 = magnitude(magnitude(velocity)); // ❌ Compile-time error!
// quantity<isq::speed[m/s]> s3 = magnitude(speed); // ❌ Compile-time error!
No vector multiplication¶
ISQ defines three related but distinct N·m quantities relevant here:
| Quantity | Kind | Unit | Formula | Character |
|---|---|---|---|---|
| Work | energy transferred by a force over a displacement | J | \(W = \vec{F} \cdot \vec{d}\) | scalar |
| Moment of force | 3D rotational effect of a force about a reference axis | N·m | \(\vec{M} = \vec{r} \times \vec{F}\) | vector |
| Torque | scalar magnitude of moment of force | N·m | \(\tau = \|\vec{r}\|\|\vec{F}\|\sin\theta\) | scalar |
All three share the same dimension (N·m = J), yet are physically and mathematically distinct. Work and moment of force further differ in which input vector is involved (\(\vec{d}\) — displacement of the application point, vs. \(\vec{r}\) — lever arm from the reference axis) and in the operation applied (dot product vs. cross product):
operator* on two scalar quantities cannot represent either of these correctly — the angle
and the distinction between \(\vec{d}\) and \(\vec{r}\) are both lost. The result is just a
product of magnitudes that is dimensionally valid for any of the four quantities:
namespace bu = boost::units;
// Scalar case — all libraries accept this, but it is fundamentally ambiguous:
bu::quantity<bu::si::force> force_1d = 10. * bu::si::newton;
bu::quantity<bu::si::length> disp_1d = 5. * bu::si::meter;
auto result = force_1d * disp_1d; // ✅ All libraries: 50 N·m
// No library can tell: is this work? mechanical energy? moment of force? torque?
// The angle and the input vector identity are both gone.
Only when the representation type carries the full 3D vector does the distinction become
meaningful — and then operator* is no longer sufficient, because vec3 * vec3 is
undefined:
namespace bu = boost::units;
using vec3 = cartesian_vector<double>;
// Vector case — fails to compile in every library:
auto force_3d = bu::quantity<bu::si::force, vec3>::from_value(vec3{10., 0., 0.});
auto disp_3d = bu::quantity<bu::si::length, vec3>::from_value(vec3{ 5., 0., 0.});
// auto wrong = force_3d * disp_3d; // ❌ All libraries: compile error!
// `vec3 * vec3` is not defined — the error comes from the representation type,
// not from the units library itself. The fix is to use scalar_product / vector_product.
The 'vector of quantities' workaround — and why it falls short
Faced with this limitation, a common workaround is to store a physical vector quantity
not as a quantity of a vector (quantity<isq::force[N], vec3>) but as a
vector of quantities (cartesian_vector<quantity<isq::force[N]>>).
This makes operator* available component-wise, but it is semantically wrong for
describing a single physical quantity with direction:
- A
cartesian_vector<quantity<N>>is just a container. It carries no information that the three components together form a single force vector. - The units library can no longer enforce character rules — nothing stops you from multiplying a "force array" by a "displacement array" component-wise, which is not the dot product.
- Adding or subtracting arrays of different quantity kinds compiles silently.
Vector of quantities is a perfectly valid pattern for collections — state vectors in a Kalman filter, rows of a matrix, or per-axis sensor readings. It is not a substitute for a quantity whose physical nature is inherently directional. For that, only a quantity of a vector correctly captures the semantic.
mp-units V3 solves this at the library level: with a proper vector representation type,
operator* between two vector quantities is disabled entirely — the only valid operations
are scalar_product and vector_product, which produce the correct result type:
quantity force = isq::force(vec3{10., 0., 0.} * N);
quantity displacement = isq::displacement(vec3{5., 0., 0.} * m);
quantity position = isq::position_vector(vec3{1., 2., 0.} * m);
// quantity wrong = force * displacement; // ❌ Compile-time error!
// Work is scalar product → scalar result
quantity<isq::work[J]> work = scalar_product(force, displacement); // Returns scalar
// Moment of force is vector product → vector result
quantity<isq::moment_of_force[N * m], vec3> moment = vector_product(position, force); // Returns vector
// ❌ Compile-time errors!
// quantity<isq::work[J]> work = vector_product(force, displacement);
// quantity<isq::work[J]> work = magnitude(vector_product(force, displacement));
// quantity<isq::moment_of_force[N * m], vec3> moment = vector_product(force, position);
// Torque is the scalar magnitude of moment of force
quantity<isq::torque[N * m]> torque = magnitude(moment); // Returns scalar
// Even though work and torque are both scalars with the same dimension (N·m = J),
// they are distinct quantity kinds — assigning one to the other is an error:
// quantity<isq::torque[N * m]> wrong = work; // ❌ Compile-time error!
// quantity<isq::work[J]> wrong = torque; // ❌ Compile-time error!
// quantity<isq::work[J]> wrong = magnitude(moment); // ❌ Compile-time error!
AC Power Relationships¶
AC circuit analysis involves four power quantities that are dimensionally compatible yet physically and mathematically distinct:
| Quantity | Unit | Character | Relationship |
|---|---|---|---|
| Active power | W | real scalar | energy actually consumed per unit time |
| Reactive power | var | real scalar | energy oscillating between source and load |
| Complex power | VA | complex scalar | \(\underline{S} = P + jQ\) — full phasor |
| Apparent power | VA | real scalar | \(S = \|\underline{S}\| = \sqrt{P^2 + Q^2}\) — magnitude of complex power |
Although W, var, and VA are all dimensionally equivalent to J/s, they are not interchangeable: active and reactive power cannot be added directly (their sum is physically meaningless), apparent power must be computed via \(\sqrt{P^2 + Q^2}\), and complex power requires a specific argument order. Without quantity safety, all four collapse to a single "watts" type and every mistake silently compiles:
namespace bu = boost::units;
// All four power quantities collapse to the same type — no distinction is possible:
bu::quantity<bu::si::power> P = 800. * bu::si::watts;
bu::quantity<bu::si::power> Q = 600. * bu::si::watts; // Oops! var, not W
bu::quantity<bu::si::power> S = 1000. * bu::si::watts; // Oops! VA, not W
auto nonsense = P + Q; // Oops! Compiles, but physically meaningless
// apparent power should be hypot(P, Q), not P + Q:
bu::quantity<bu::si::power> S_wrong = P + Q; // Oops! Wrong formula, wrong unit — silently accepted
// No way to enforce argument order for complex power:
auto complex1 = std::complex{P.value(), Q.value()} * bu::si::watts; // OK
auto complex2 = std::complex{Q.value(), P.value()} * bu::si::watts; // Oops! Reversed — silently accepted
Production Feedback: Why This Matters?
At CppCon, an engineer from the power systems domain emphasized: similar errors mixing active, reactive, and apparent power are prevalent in their field. He stated that any units library—even if standardized—would be useless in production unless it correctly prevents these mistakes at compile time. This real-world feedback highlights why quantity safety is not just theoretical—it's essential for domains where such distinctions are safety-critical.
mp-units V3 addresses all of the above concerns directly:
quantity P = isq::active_power(800. * W);
// quantity Q = isq::reactive_power(600 * W); // ❌ Error: wrong unit
quantity Q = isq::reactive_power(600. * var); // ✅ OK
// Apparent power computed from proper equation
quantity<isq::apparent_power[VA]> S = hypot<VA>(P, Q); // ✅ OK
// quantity wrong = S.in(W); // ❌ Error: wrong unit
// Cannot mix them directly
// auto sum = P + Q; // ❌ Error: invalid operation
// Need to use make_complex_quantity<Rep, Unit>() factory function
quantity<isq::complex_power[VA], std::complex<double>> complex =
make_complex_quantity(P, Q); // ✅ OK
// quantity<isq::complex_power[VA], std::complex<double>> complex =
make_complex_quantity(Q, P); // ❌ Error: wrong order
// quantity<isq::apparent_power[VA]> S2 = imag(complex); // ❌ Error: wrong quantity kind
quantity<isq::apparent_power[VA]> S2 = modulus(complex); // ✅ OK
// Can extract active power from real part
// quantity<isq::active_power[W]> P2 = imag(complex); // ❌ Error: wrong quantity kind
// quantity<isq::reactive_power[var]> Q2 = real(complex); // ❌ Error: wrong quantity kind
quantity<isq::active_power[W]> P2 = real(complex); // ✅ OK: 800 W
quantity<isq::reactive_power[var]> Q2 = imag(complex); // ✅ OK: 600 var
For more details, see the blog post Bringing Quantity-Safety To The Next Level.
Level 6: Mathematical Space Safety¶
Mathematical space safety distinguishes between fundamental abstractions based on their mathematical properties:
- Points - Values on an interval scale with an arbitrary or conventional origin (e.g., temperatures in °C, positions relative to sea level)
- Deltas - Differences between values (e.g., temperature changes, displacements)
- Absolute quantities (V3 planned) - Ratio-scale amounts anchored at a true physical zero (e.g., mass in kg, temperature in K, length as a size); distinct from both point and deltas
What It Prevents?¶
Historical Mishap: Hochrheinbrücke (2003–2004)
During construction of the Hochrheinbrücke bridge between Germany and Switzerland, engineers needed to account for different national height reference systems: Germany's NHN (Normalhöhennull) and Switzerland's "Meter über Meer", which differ by 27 cm. The plan was to build the Swiss abutment 27 cm higher to compensate. Due to a sign error in the offset calculation, it was built 27 cm lower instead — a total misalignment of 54 cm.
The error was discovered during a site inspection in December 2003, requiring costly corrections before the bridge could be completed.
Mathematical space safety would have made this class of error visible at compile time:
heights anchored to NHN and Swiss reference are quantity_point values with
different point_origin types, and mixing them without an explicit conversion
is a type error.
Without mathematical space safety, code can perform nonsensical operations:
// Without mathematical space safety - all just "temperature":
auto boiling = 100.0; // °C
auto freezing = 0.0; // °C
auto sum = boiling + freezing; // Meaningless: what does 100°C + 0°C mean?
auto doubled = boiling * 2; // Meaningless: what does 2 × 100°C mean?
auto ratio = boiling / freezing; // Division by zero or nonsense ratio
A dimension-safe library is no better if it provides no affine space support. nholthaus/units — one of the most widely used C++ units libraries — has no point/delta distinction: temperature points and deltas share the same type and every nonsensical operation compiles silently:
// nholthaus/units — no affine space support; points and deltas share the same type:
units::temperature::celsius_t room_temp{20.}; // 20°C — a temperature point
units::temperature::celsius_t outside_temp{5.}; // 5°C — another temperature point
units::temperature::celsius_t temp_delta{10.}; // 10 K — intended as a temperature delta
// All three have the same type — no distinction between temperature points and deltas:
auto sum = room_temp + outside_temp; // Oops! Adding temperature points is meaningless
auto doubled = room_temp * 2.; // Oops! 2 × 20°C is physically meaningless
// No way to enforce point + delta vs. point + point:
auto wrong = room_temp + outside_temp; // Oops! Silently accepted
auto right = room_temp + temp_delta; // ✅ Correct, but indistinguishable from wrong above
Example¶
mp-units V3 addresses all of the above concerns directly:
// Points: Values on interval scale (arbitrary origin)
quantity_point room_temp = point<deg_C>(20.); // quantity<point<deg_C>>
quantity_point outside_temp = point<deg_C>(5.); // quantity<point<deg_C>>
// auto sum = room_temp + outside_temp; // ❌ Compile-time error!
// Error: cannot add two points
quantity temp_diff = room_temp - outside_temp; // ✅ OK: quantity<delta<K>> — 15 K
quantity_point new_temp = room_temp + temp_diff; // ✅ OK: quantity<point<deg_C>>
// Deltas: Differences between values
quantity temp_change = delta<K>(10); // quantity<delta<K>>
quantity displacement = delta<m>(-5); // quantity<delta<m>> — can be negative
quantity total_change = temp_change + temp_change; // ✅ OK: quantity<delta<K>>, 20 K
quantity_point warmed_room = room_temp + temp_change; // ✅ OK: quantity<point<deg_C>>
The Three Abstractions¶
V2 provides two types; V3 adds absolute quantities as a first-class third abstraction:
| Feature | Point | Absolute (V3 planned) | Delta |
|---|---|---|---|
| Physical Model | Interval Scale | Ratio Scale | Difference |
| Example | \(20\ \mathrm{°C}\), \(100\ \mathrm{m\ AMSL}\) | \(293.15\ \mathrm{K}\), \(100\ \mathrm{kg}\) | \(10\ \mathrm{K}\), \(-5\ \mathrm{m}\) |
| Has True Zero? | No (arbitrary/conventional) | Yes (physical) | N/A |
| Allows Negative? | Yes (below arbitrary origin) | No (opt-in) | Yes (direction matters) |
| Addition (A + A) | ❌ Error (\(20\ \mathrm{°C} + 10\ \mathrm{°C}\)) | ✅ Absolute (\(10\ \mathrm{kg} + 5\ \mathrm{kg}\)) | ✅ Delta |
| Subtraction (A − A) | ✅ Delta (\(30\ \mathrm{°C} - 10\ \mathrm{°C}\)) | ✅ Delta (\(50\ \mathrm{kg} - 5\ \mathrm{kg}\)) | ✅ Delta |
| Multiplication (k×A) | ❌ Error (\(2 \times 20\ \mathrm{°C}\)) | ✅ Absolute (\(2 \times 10\ \mathrm{kg}\)) | ✅ Delta (\(-2 \times 5\ \mathrm{m}\)) |
| Division (A / A) | ❌ Error | ✅ Scalar (\(10\ \mathrm{kg} / 5\ \mathrm{kg}\)) | ✅ Scalar (ratio) |
| A + Delta | ✅ Point (shift) | ✅ Delta | ✅ Delta |
| API | quantity<point<...>> |
quantity<...> |
quantity<delta<...>> |
Bounds Checking¶
mp-units provides optional runtime bounds checking for mathematical space conversions:
-
Point bounds checking: When constructing a quantity point optional bounds checking can verify that the resulting point value stays within valid ranges. This is especially useful for physical quantities with natural bounds (e.g., preventing negative absolute temperatures when working with Kelvin, or longitude and latitude). Bounds are automatically scaled and translated to remain consistent when the unit changes (e.g., bounds specified in meters remain correct when converting to kilometers). The library supports multiple boundary semantics:
- Clamping: Values outside the range are clamped to the nearest boundary
- Wrap-around: Values wrap cyclically
- Reflect: Values bounce back at boundaries
-
Absolute quantity bounds checking (V3 planned): When converting deltas to absolute quantities (via
.absolute()), optional runtime precondition checks verify non-negativity. This ensures that negative values can't accidentally become absolute quantities representing physical magnitudes that should always be non-negative (like mass, distance, duration). The precondition check fails at runtime if the value is negative, providing an additional safety layer beyond compile-time type distinctions.
Why This Matters?¶
Mathematical space safety prevents an entire class of conceptual errors that dimension safety alone cannot catch. Consider these examples:
- Temperature control: Room temperature (point on °C scale) vs. temperature change (delta in K)
- GPS navigation: Position (point) vs. displacement (delta/vector)
- Timestamps: Timestamp (point — specific instant relative to an epoch) vs. duration (absolute — always non-negative elapsed time)
- Absolute amounts vs. deltas: In V2,
quantity<kg>is used for both an absolute amount of mass (e.g., total mass of a sample — a ratio-scale value anchored at true zero) and a mass delta (e.g., the change in mass — a signed difference). The type system cannot distinguish them, so argument ordering mistakes and other semantic errors compile silently. V3 fixes this by making absolute quantities a first-class type.
Evolution in mp-units¶
// Both 'water_lost' and 'total_initial' have the same type quantity<kg>.
// Nothing prevents passing them in the wrong order:
quantity<percent> moisture_content_change(quantity<kg> water_lost,
quantity<kg> total) { ... };
quantity<kg> initial[] = { 2.34 * kg, 1.93 * kg, 2.43 * kg };
quantity<kg> dried[] = { 1.89 * kg, 1.52 * kg, 1.92 * kg };
quantity<kg> total_initial = std::reduce(...);
quantity<kg> total_dried = std::reduce(...);
quantity<kg> water_lost = total_initial - total_dried; // delta (difference)
moisture_change(total_initial, water_lost); // 🤔 Swapped arguments — compiles silently!
Two abstractions:
quantity_point<...>for pointsquantity<...>for deltas (also used for absolute amounts — no distinction)
quantity<T> serves double duty as both an absolute amount and a delta — the type
system cannot distinguish them. This makes function signatures ambiguous and lets
argument-ordering mistakes slip through silently.
// Function signature now expresses distinct roles — types are incompatible:
quantity<percent> moisture_content_change(quantity<delta<kg>> water_lost,
quantity<kg> total) { ... };
quantity<kg> initial[] = { 2.34 * kg, 1.93 * kg, 2.43 * kg };
quantity<kg> dried[] = { 1.89 * kg, 1.52 * kg, 1.92 * kg };
quantity<kg> total_initial = std::reduce(...); // absolute (default in V3)
quantity<kg> total_dried = std::reduce(...);
quantity<delta<kg>> water_lost = total_initial - total_dried; // delta (explicit)
// moisture_change(total_initial, water_lost); // ❌ Compile-time error! Types don't match
moisture_change(water_lost, total_initial); // ✅ OK
Three first-class abstractions:
quantity<...>for absolute quantities (new default — ratio scale, true physical zero)quantity<delta<...>>for deltas (signed differences, always explicit)quantity<point<...>>for points (replacesquantity_point<...>)
All three roles are now explicit and distinct — argument-ordering mistakes become compile-time errors.
The arithmetic rules encode physical validity at the type level:
| Operation | Result | Notes |
|---|---|---|
absolute + absolute |
Absolute | Sum of two non-negative amounts |
absolute - absolute |
Delta | Difference — may be negative |
absolute + delta |
Delta | Result sign unknown at compile time |
delta + delta |
Delta | Combined signed change |
norm(vector_delta) |
Absolute | Euclidean norm — always non-negative |
When an absolute result is needed from an operation that conservatively yields a delta,
call .absolute() explicitly — this checks the non-negativity precondition at runtime:
This converts a V2 silent assumption into an explicit, checked V3 contract.
For more details, see Introducing Absolute Quantities.
Error Classes Prevented by Each Level¶
Each safety level prevents a different class of errors:
-
Dimension Safety → Dimensional analysis errors
- Prevents: Adding meters to seconds, multiplying incompatible dimensions
- Impact: Eliminates basic dimension mistakes
-
Unit Safety → Interface boundary errors
- Prevents: Passing m/s when km/h expected, unsafe value extraction
- Impact: Eliminates unit mismatch bugs at integration points
-
Representation Safety → Numerical errors
- Prevents: Silent truncation, overflow, underflow
- Impact: Protects against data loss
-
Quantity Kind Safety → Conceptual errors
- Prevents: Mixing frequency (Hz) with activity (Bq), plane angles (rad) with solid angles (sr)
- Impact: Distinguishes physically different concepts with same dimension
-
Quantity Safety → Hierarchy and equation errors
- Prevents: Using generic length instead of height in potential energy; mixing scalar and vector operations
- Impact: Ensures physics equations use correct specialized quantities
-
Mathematical Space Safety → Point/absolute/delta confusion
- Prevents: Adding absolute temperatures; using deltas where absolute quantities expected; arithmetic on interval-scale values
- Impact: Enforces correct mathematical model (interval vs. ratio scale)
Comprehensive Library Comparison¶
Now that we've explored all six safety levels in detail, let's examine how mp-units compares to other units libraries—both within the C++ ecosystem and across programming languages. This comparison will help contextualize mp-units' capabilities and design choices relative to industry-leading alternatives.
C++ Libraries¶
The C++ ecosystem offers several mature units libraries, each with different design philosophies and trade-offs. We compare mp-units against the most prominent alternatives: Boost.Units (pioneering pre-C++14 solution), nholthaus/units (modern C++14 library), bernedom/SI (C++17 minimalist approach), and Au (production-tested C++14 library from Aurora Innovation).
The following table compares safety features across major C++ units libraries:
| Safety Feature | mp-units (current) |
mp-units (V3 planned) |
Boost.Units | nholthaus | SI | Au |
|---|---|---|---|---|---|---|
| Minimum Requirement | C++20 | C++20 | C++11/14 | C++14 | C++17 | C++14 |
| Dimension Safety | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full |
| Unit Safety | ✅ Full | ✅ Full | 🔶 LimitedCross-unit conversion requires explicit construction: quantity<target_unit>(q); implicit conversion is only allowed when units reduce to identical base units (e.g., SI seconds ↔ CGS seconds). Working with prefixed SI units (e.g., kilometres, milliseconds) or custom units requires verbose boilerplate — defining base units, scaled units, and conversion factors separately |
🟡 PartialImplicit conversions between compatible units; less strict than mp-units | 🔶 LimitedBasic unit type system; limited compile-time enforcement of unit correctness | ✅ Full |
| Representation Safety | ⭐ StrongCompile-time: blocks conversions where the scaling factor definitely overflows the representation type; fixed-point arithmetic prevents intermediate overflow during non-integer unit scaling; does not suppress -Wconversion in internal calculations so compiler warnings remain actionable. Runtime: safe_int<T> drop-in wrapper detects arithmetic overflow for all operations including hidden common-unit arithmetic in operator+/operator==; quantity_bounds<Origin> enforces domain range constraints on quantity points. |
⭐ StrongSame compile-time and runtime guarantees as current mp-units, extended to cover absolute quantities and delta types introduced in V3. | 🔶 LimitedNo systematic overflow detection; representation is a template parameter (quantity<Unit, Y>), so safety depends entirely on the chosen Y — no defaults, no guidance, and no built-in checks |
🟡 PartialUses floating-point by default, which avoids truncation in practice; no systematic overflow detection. Integer representations silently truncate on unit conversion — no compile-time guard | 🔶 LimitedMinimal representation type checking; primarily focused on correct dimensions | ⭐ StrongCompile-time: adaptive "smallest overflowing value" threshold (2'147) — more aggressive than mp-units but produces false positives requiring opt-out via ignore(OVERFLOW_RISK) or ignore(TRUNCATION_RISK); disallows risky integer Quantity / Quantity division to prevent divide-before-convert trap, with escape hatches unblock_int_div() and divide_using_common_unit(). Runtime: opt-in helpers (will_conversion_overflow, will_conversion_truncate, is_conversion_lossy) check explicit conversions only — no automatic detection of overflow in common-unit arithmetic or equivalent to safe_int. |
| Quantity Kind Safety | ✅ Full | ✅ Full | 🔶 LimitedCustom quantity types possible but not systematically enforced | ❌ None | ❌ None | ❌ NoneAu documentation states 'No plans at present to support' quantity kind types; intentional design tradeoff to reduce learning curve and improve compiler error readability |
| Quantity Safety | 🟡 PartialProvides quantity hierarchy with named types; V3 will add proper vector, tensor, and complex number support | ✅ Full | ❌ None | ❌ None | ❌ None | ❌ NoneNo support for quantity character (scalar/vector/complex) or quantity hierarchies. Vector support is planned (GitHub issue #70) |
| Mathematical Space Safety | ⭐ StrongPoints (quantity_point) with sophisticated multi-layered origin system (natural_point_origin, absolute_point_origin, relative_point_origin that can be hierarchically stacked) and deltas (quantity) are fully distinct; promoted for widespread use (timestamps, altitudes, odometer readings, etc.). Supports optional runtime bounds checking for points. Key limitation: no first-class absolute quantity type until V3 — absolute amounts (ratio-scale values) share quantity<T> with deltas |
✅ FullThree first-class abstractions: absolute quantities (ratio-scale, new default), explicit deltas (quantity<delta<...>>), and points (quantity<point<...>>); covers all mathematical space scenarios |
🔶 LimitedGeneric absolute<> wrapper distinguishes points from deltas with correct basic semantics: absolute<T> +/- T → absolute<T> and absolute<T> - absolute<T> → T. Works for temperature and other use cases. Key limitation: no typed origins, so two points in different reference frames (e.g., heights above NHN vs. MüM) are the same type and mixing them still silently compiles |
❌ Nonenholthaus does have offset-aware unit conversions (e.g., celsius_t ↔ kelvin_t applies the +273.15 offset correctly), but this is purely a conversion feature. There is no separate point type: celsius_t and a “temperature delta in °C” are the same type, so adding two temperature points, or scaling one, compiles silently |
❌ None | 🟡 PartialQuantityPoint type with typed origins (origins embedded in unit definitions) prevents mixing points from different reference frames; used selectively for temperature and special cases, not promoted as general-purpose tool; supports bidirectional conversions via CorrespondingQuantity. Key gap: no first-class absolute quantity type — ratio-scale amounts (e.g., mass, duration) share the same type as deltas, just as in mp-units V2 |
Legend:
- ✅ Full: Comprehensive implementation covering all aspects
- ⭐ Strong: Robust implementation with minor limitations
- 🟡 Partial: Some aspects implemented but not comprehensive
- 🔶 Limited: Basic support with significant gaps
- ❌ None: This safety feature is not provided
Key Observations:
- Dimension Safety: All modern C++ units libraries provide this foundational feature
- Unit Safety: mp-units and Au provide complete unit-safe interfaces; other libraries allow varying degrees of implicit conversions
- Representation Safety: mp-units and Au both provide strong compile-time overflow/
truncation protection; Au uses adaptive "smallest overflowing value" threshold (2'147),
mp-units checks scaling factor magnitude; Au provides opt-out mechanisms and runtime
helpers; mp-units additionally provides
safe_int<T>for comprehensive runtime coverage of all arithmetic including common-unit operations - Quantity Kind Safety: Only mp-units provides full quantity kind safety, distinguishing Hz/Bq, rad/sr, Gy/Sv—other libraries either lack this feature or provide partial support
- Quantity Safety: mp-units is unique in providing systematic quantity hierarchies; this level of semantic type safety is absent from other C++ libraries
- Mathematical Space Safety: mp-units provides the most sophisticated point/delta
system with multi-layered typed origins (natural/absolute/relative, hierarchically
stackable) + runtime bounds checking for point conversions, promoted for widespread
use; Au offers QuantityPoint with typed origins embedded in units, used selectively for
temperature/special cases; Boost.Units provides only basic
absolute<>wrapper without typed origins (cannot prevent mixing points from different reference frames). None have first-class absolute quantity types — that is unique to mp-units V3, which adds ratio-scale absolute quantities as a distinct third abstraction with optional runtime bounds checking for non-negativity - C++ Standard Requirement: mp-units requires C++20 — a higher entry bar than Au (C++14)
or nholthaus/units (C++14), which matters for industrial and embedded projects still on
legacy toolchains; Au demonstrates that strong safety guarantees are achievable on C++14,
but C++20 features — NTTPs,
concepts, and class non-type template parameters — allow mp-units to expose them through a significantly more ergonomic, user-friendly API
Cross-Language Libraries¶
According to star-history.com, mp-units directly competes with industry-leading units libraries from other languages. While mp-units operates within the C++ ecosystem, comparing it to cross-language competitors provides important context about its capabilities and design sophistication at the industry level. We compare against: Pint (Python), JSR-385 (Java), UOM (Rust), UnitsNet (.NET), Unitful.jl (Julia), and F# Units of Measure—a unique case where dimensional analysis is a first-class language feature.
The following table compares mp-units to leading units libraries from other programming languages:
| Safety Feature | mp-units (current) |
mp-units (V3 planned) |
Pint | JSR-385 | UOM | UnitsNet | Unitful.jl | F# Units |
|---|---|---|---|---|---|---|---|---|
| Language | C++20 | C++20 | Python | Java | Rust | C# | Julia | F# |
| Dimension Safety | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full |
| Unit Safety | ✅ Full | ✅ Full | ✅ FullAutomatically converts mixed-unit operations (e.g., meters + feet) through UnitRegistry; however, lacks enforcement against mixing quantities from different registry instances |
🟡 PartialStatically typed via Java generics (Quantity<Length>) prevents mixing incompatible dimensions at compile time; mixed-unit operations correctly convert to SI system units first per spec (e.g., 4 cm + 1 inch = 0.0654 m); however value extraction via .getValue() returns raw numeric with no required unit specification, and unit tracking is runtime-only with no compile-time unit in the type |
✅ Full | 🟡 PartialAutomatic conversions between compatible units (e.g., m + ft), but lacks strict enforcement against mixed-unit operations in all runtime contexts |
✅ Full | ✅ Full |
| Representation Safety | ⭐ Strong | ⭐ Strong | 🔶 LimitedNo compile-time representation safety: all checks are runtime-only and manifest as DimensionalityError or silent precision loss. Handles large integers via Python's arbitrary-precision int, but floating-point uses standard IEEE 754 float with potential precision loss; no overflow protection for dimensioned calculations |
🔶 LimitedDefaults to double for most quantities; supports custom representations via Quantity<Q, N> but lacks automatic precision handling for edge cases like overflow; narrowing conversions between numeric types require explicit casts but are not systematically prevented at the API boundary |
⭐ StrongRust's type system prohibits implicit narrowing conversions at the language level (e.g., assigning f64 to f32 without an explicit cast is a compile error); uom inherits this guarantee — any precision-losing conversion must be explicit and is therefore intentional. uom is also generic over any Num-constrained representation type, giving full control over numeric precision |
🟡 PartialUses QuantityValue to specify numeric types (e.g., double, decimal); supports saturation arithmetic but doesn't enforce representation constraints at type level |
🟡 PartialAccepts any Julia numeric type (including custom types with appropriate traits) but doesn't enforce specific types for physical correctness or overflow prevention | 🔶 LimitedLimited to numeric primitives (int, float, decimal); F# does not permit implicit narrowing between different primitive types, but provides no systematic overflow or precision-loss protection within a single type — inherits standard .NET numeric behavior |
| Quantity Kind Safety | ✅ Full | ✅ Full | ❌ NoneUses dimensions to categorize quantities but does not systematically distinguish dimensionally equivalent kinds like Torque vs Energy or Gray vs Sievert | 🟡 PartialProvides separate interfaces for most quantity kinds including dimensionally equivalent pairs: Frequency vs Radioactivity (both s⁻¹) and RadiationDoseAbsorbed vs RadiationDoseEffective (Gy/Sv); however no separate Torque interface — torque and energy both fall under Energy |
🟡 PartialDimensionally equivalent quantities (e.g., Energy and Torque, both kg·m²·s⁻²; Frequency and Radioactivity, both s⁻¹) map to the same Rust generic type Quantity<ISQ<...>, SI, V> at compile time and are freely interchangeable; the optional Kind trait can differentiate them but is not applied to the built-in SI quantities |
🟡 PartialCode-generates a separate, strongly-typed C# struct for each quantity kind (e.g., Energy, Torque, Frequency, Radioactivity, Angle, SolidAngle); mixing incompatible kinds is a compile-time type error; does not formally follow ISO 80000 / ISQ but covers most practically relevant kind distinctions including Hz/Bq and Gy/Sv as separate types |
🟡 PartialDistinguishes quantities by units but not by kind within same dimensions; torque and energy are both Quantity{Float64, 𝐋²𝐌𝐓⁻², ...} without semantic separation |
🔶 LimitedDoes not distinguish dimensionally equivalent quantities at type level (e.g., torque and energy are both represented as float<N·m> with identical type signatures) |
| Quantity Safety | 🟡 PartialProvides quantity hierarchy with named types; V3 will add proper vector, tensor, and complex number support | ✅ Full | ❌ None | ❌ None | ❌ None | ❌ None | ❌ None | ❌ None |
| Mathematical Space Safety | 🟡 PartialPoints (quantity_point) and deltas (quantity) are fully distinct; however, absolute amounts (ratio-scale values) share quantity<T> with deltas — no first-class absolute quantity type until V3 |
✅ FullThree first-class abstractions: absolute quantities (ratio-scale, new default), explicit deltas (quantity<delta<...>>), and points (quantity<point<...>>); covers all mathematical space scenarios |
🔶 LimitedExplicit delta units (delta_degC, delta_degF) and offset-unit semantics for temperature only: subtracting two temperature points yields a delta, adding a point and a delta works correctly; not a general point/delta type system — no typed origins or QuantityPoint abstraction |
🔶 LimitedQuantity.Scale.ABSOLUTE / RELATIVE enum (since v2.0) distinguishes absolute-scale (e.g., Kelvin) from relative-scale (e.g., °C/°F) temperatures; however scale is a runtime property only — no compile-time type-level distinction — and there is no general point/delta abstraction beyond temperature |
🔶 LimitedSeparate ThermodynamicTemperature (point) and TemperatureInterval (delta) types enforce correct semantics for temperature: adding two ThermodynamicTemperature values is a compile error, but temperature + interval works; temperature-specific — no general affine space abstraction for arbitrary quantities |
❌ None | 🔶 LimitedBuilt-in AffineQuantity type for °C/°F (relative-scale temperatures); invalid operations like 32°F + 1°F throw AffineError; the @affineunit macro allows defining custom affine units beyond temperature; however no formal three-way point/delta/absolute distinction and no typed origins |
❌ None |
| Runtime Performance Overhead | ⚡ NoneZero-cost abstraction; units completely erased at compile-time | ⚡ NoneZero-cost abstraction; units completely erased at compile-time | 🔴 Very HighReported 38-1461× slower for basic operations; actual overhead highly dependent on usage patterns and wrapping strategies (can be reduced to ~7× with proper wrapping) | 🔴 Very High650× slower for addition (2.88 μs vs 4.43 ns); 1470× for bulk operations | ⚡ NoneZero-cost verified through assembly inspection; monomorphization produces identical code to raw f64 |
🟡 Low2.4× for arithmetic operations; 10× for unit conversions due to runtime lookup tables | ⚡ NoneZero-cost when type-stable (verified via LLVM IR); 130× slower with type instability | ⚡ NoneZero-cost verified through IL inspection; units completely erased at compile-time |
| Memory Overhead | ⚡ NoneZero overhead; stores only the numeric value (8 bytes for double) |
⚡ NoneZero overhead; stores only the numeric value (8 bytes for double) |
🔴 Very High~700 bytes retained size (includes unit registry references); ~56 bytes direct object size vs 24 bytes for float |
🔴 Very High~500 bytes per Quantity including JVM object header, value reference, and unit metadata graph | ⚡ NoneStores only the numeric value (8 bytes for f64); units exist only in type system |
🟡 Low12-16 bytes (8-byte double + 4-byte unit enum + padding) vs 8 bytes for raw double |
⚡ NoneImmutable structs stored inline; 8 bytes for Float64, no additional runtime unit storage |
⚡ NoneIdentical memory layout to primitive float (8 bytes); units erased during compilation |
| Compile-time Cost | 🔴 HighHeavy template metaprogramming; incremental compilation and precompiled modules help mitigate costs | 🔴 HighHeavy template metaprogramming; incremental compilation and precompiled modules help mitigate costs | ⚡ MinimalDynamic language with minimal compilation overhead; units checked at runtime | 🟢 LowJVM bytecode compilation relatively fast; generic types add modest overhead | 🟡 ModerateRust's trait system and monomorphization add compile-time cost but less than C++ templates | 🟢 LowJIT compilation; struct-based design adds minimal overhead | 🟡 ModerateJulia's type specialization over physical unit types adds non-trivial first-run JIT compilation cost; complex expressions with many unit types exacerbate this (the well-known "time-to-first-X" problem applies to Unitful.jl-heavy code) | ⚡ MinimalUnits completely erased at compile-time; minimal impact on compilation speed |
Key Observations:
- Dimension Safety: All mature units libraries provide this foundational safety level
- Unit Safety: mp-units, UOM, Unitful.jl, and F# Units provide the strongest compile-time guarantees; F# is unique as units are a built-in language feature with zero runtime cost; JSR-385 enforces dimension compatibility via Java generics but tracks units at runtime only
- Enforcement Timing: This is a critical distinction often overlooked: C++, Rust, and F#
libraries enforce all safety levels at compile time — violations are build errors with zero
runtime cost; Python (Pint) and Java (JSR-385) enforce safety at runtime — violations are
exceptions or silent errors that only appear during execution; a
DimensionalityErrorin Pint is a crash, not a compiler diagnostic - Representation Safety: mp-units provides strong compile-time overflow/truncation protection; UOM benefits from Rust's explicit integer handling; runtime libraries (Pint, JSR-385) provide no representation safety
- Quantity Kind Safety: mp-units uniquely provides full quantity kind safety based on ISO 80000; UnitsNet and JSR-385 cover most practical distinctions (Hz/Bq, Gy/Sv, etc.) as separate types but without a formal ISQ basis; UOM's built-in SI quantities make dimensionally equivalent kinds (e.g., Energy/Torque) accidentally interchangeable unless the optional Kind trait is applied
- Quantity Safety: mp-units V3 will be the only zero-overhead, compile-time library with systematic quantity hierarchies based on ISO 80000; JSR-385 (Java) provides some runtime quantity separation but with substantial heap overhead and no ISQ-grounded hierarchy; no other library in this comparison provides scalar/vector/tensor character support
- Mathematical Space Safety: mp-units V3 will provide comprehensive abstraction for points, absolute quantities (with optional runtime bounds checking for non-negativity), and deltas; mp-units V2 already offers the most sophisticated point/delta system with multi-layered typed origins + runtime bounds checking for point conversions; Pint, JSR-385, UOM, and Unitful.jl each offer partial temperature-specific affine support with varying levels of generality and compile-time enforcement; UnitsNet and F# Units provide no mathematical space abstractions
- Language Integration: F# demonstrates what's possible when units are a first-class language feature
- Python's Zero-Overhead Path: While Pint relies on runtime object wrapping,
impunity(TU Delft) demonstrates that AST rewriting at function-definition time can achieve dimension-safety with zero runtime overhead — using Python's annotation syntax to check units statically rather than tracking them at runtime;impunityis limited to dimension-level checks and does not support quantity kinds, affine spaces, or character safety, but it shows that Python's "runtime tax" on unit safety is not inevitable - Performance Architecture: The overhead spectrum divides into three categories:
- Zero-cost compiled (C++, Rust, F#): Units verified at compile-time, completely erased at runtime
- Optimizable JIT (Julia, C#): Type-stable code achieves zero/minimal cost; UnitsNet shows ~2.4× overhead for structs
- Object-wrapped dynamic (Python, Java): Object instantiation dominates; Pint shows 38-1460× overhead, JSR-385 ~650×
- Memory Trade-offs: Heap-allocated objects incur massive overhead—Pint (~700 bytes) and JSR-385 (~500 bytes) vs zero overhead for compiled languages and type-erased implementations; UnitsNet's struct approach adds only 4-8 bytes
- The "Type Stability Tax": Julia and managed runtimes demonstrate that zero-cost is achievable in JIT environments only when types are known at compile-time; dynamic dispatch reintroduces substantial overhead (Julia: 130× when type-unstable)
- Compilation Cost: Compile-time type safety comes with build-time costs—mp-units and UOM pay the highest price for their strong safety guarantees, while dynamic languages have minimal compilation overhead but sacrifice compile-time error detection
- Official Standards: JSR-385 represents Java's official units API, demonstrating that even standardized approaches face fundamental runtime overhead in managed environments
A Note on Performance Metrics
The runtime and memory overhead figures cited in the table reflect the fundamental architectural costs of each library's design and are based on community micro-benchmarks comparing basic unit-wrapped arithmetic against equivalent raw primitive operations:
- Compiled zero-cost languages (C++, Rust, F#): "None" claims are verified via
assembly/IL inspection (e.g., Compiler Explorer), confirming complete compile-time
erasure of unit metadata — the machine code is identical to raw
doubleorf64. - Managed/JIT environments (Java, C#, Julia): Overhead factors (e.g., ~650× for JSR-385) reflect formal micro-benchmarking (JMH, BenchmarkDotNet). These capture the cost of heap allocation, GC pressure, and dynamic dispatch when type structures cannot be fully flattened at compile time. Julia achieves zero cost only when type-stable.
- Dynamic languages (Python): Multipliers for Pint are from standard profiling
(
timeit) and documented community benchmarks, highlighting the object-instantiation and dictionary-lookup cost of tracking dimensions at runtime.
Actual production overhead will vary with application architecture, JIT warmup, and object reuse strategies, but the multipliers accurately reflect each paradigm's fundamental characteristics.
Conclusion¶
Safety in quantities and units libraries exists on a spectrum, from basic dimensional analysis to sophisticated semantic correctness. mp-units is the only such library currently providing all six safety levels (with full quantity character support planned for V3), making it the most complete implementation of metrologically sound, strongly-typed numerics available in the world today.
Choosing a safety level in mp-units
Simple quantities
(quantity<unit, Rep>) are the natural starting point and already cover Levels 1–4 with
no extra effort. From there, two independent opt-in choices extend coverage further:
quantity_pointbrings Level 6 (mathematical space safety) for domains involving affine spaces — time instants, temperatures, positions — at no additional code overhead. If adding two quantities of the same kind makes no physical or domain sense — as with two timestamps or two absolute temperatures — they should be modeled as points.-
Typed quantities (
quantity<isq::quantity[unit], Rep>) add Level 5: full ISQ quantity hierarchy enforcement (e.g.,isq::heightvsisq::widthvsisq::distance). The tradeoffs are:- more verbose code at the call site, though more expressive and self-documenting,
- longer type names in compiler diagnostics and during debugging.
Use them where multiple distinct quantities of the same kind are in play and that distinction matters. mp-units V3 further extends typed quantities with quantity character safety (scalar, vector, tensor).
References¶
- mp-units Documentation
- Safety Features Getting Started
- Bringing Quantity-Safety To The Next Level
- Introducing Absolute Quantities
- ISO 80000 (ISQ) Series