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
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
Advanced: Au's Magic Number Approach
The Au library goes further with "magic number" overflow detection. Au computes a threshold at compile time and checks whether scaling would overflow, providing stronger guarantees for more scenarios. This however, might be too aggressive for some cases. This is why Au library also provided an opt-out mechanism that allows to explicitly ignore the built-in checkers.
mp-units uses a simpler model that detects overflow when the scaling factor definitely exceeds the representation range. This is nearly always true, as the only value that would survive such a conversion is the value zero. With this, we also don't have to provide any opt-out alternatives. If users require more safety, they should decay to runtime checks embedded in their custom safe representation types (see next admonition).
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
Compile-time programming can only detect when conversions or scaling factors themselves are guaranteed to overflow the representation type (as shown in the integer examples above).
Solution: Custom Representation Types
If runtime overflow protection is required, you can provide custom representation types with runtime checks:
template<typename T>
class SafeInt {
T value;
public:
friend SafeInt operator*(SafeInt lhs, SafeInt rhs)
{
if (/* overflow check */) throw std::overflow_error("...");
return SafeInt{lhs.value * rhs.value};
}
// ... other operations with checks
};
quantity distance = SafeInt{100} * m;
quantity doubled = distance * 2; // Throws if overflow occurs
This is the only way to achieve runtime overflow protection — when values are unknown at compile time, there is no alternative to runtime checking.
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.
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<...>> |
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 | ✅ Strong | ✅ Strong | ⚠️ 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 | ⭐ Strong+Two-tier approach: (1) compile-time "overflow safety surface" — an adaptive heuristic that blocks risky conversions based on type size and conversion factor, covering all conversions including hidden common-unit operations in addition/subtraction/comparison; (2) optional runtime checkers (is_conversion_lossy + .coerce_as()) for exact safety on explicit conversions only — common-unit operations are not covered. The heuristic can be defeated by intermediate conversions. Separate opt-out for truncation checking. Meets and exceeds std::chrono baseline |
| 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 | ⚠️ 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 |
⚠️ PartialGeneric absolute<> point wrapper provides correct basic semantics: absolute<T> +/- T → absolute<T> and absolute<T> - absolute<T> → T. 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 | ⚠️ PartialCustom origins easy to use and compose; elegant QuantityPoint type with typed origins prevents mixing points from different reference frames; maximally efficient common units; 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 lead with strong overflow/truncation protection
- 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 V2 and Au both provide a solid point/delta distinction; however, neither has a first-class absolute quantity type — that is unique to mp-units V3, which adds ratio-scale absolute quantities as a distinct third abstraction (true physical zero); Boost.Units offers limited support for specific cases
- 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 | ⚡ 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 V3 will lead with enhanced overflow/underflow detection; UOM benefits from Rust's explicit integer handling
- 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, and deltas; 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