Value Conversions¶
One of the most important features of every unit library is to provide support for compile-time-enabled conversions of a numerical value of a quantity.
A numerical value of a quantity depends on two elements:
- quantity representation type (e.g.,
int,double) that stores the number expressing the amount of quantity, - unit in which the quantity is being measured.
Changing any of the above may require changing the value stored in a quantity.
Value-preserving conversions¶
The second line above converts the current quantity to the one expressed in meters and prints its contents. The third line converts the quantity expressed in kilometers into the one measured in meters.
In case a user would like to perform an opposite transformation:
Both conversions will fail to compile because they try to truncate the quantity value.
There are two ways to make the above work. The first solution is to use a floating-point representation type:
or
quantity q1 = 5 * m;
std::cout << q1.in<double>(km) << '\n';
std::cout << value_cast<double>(q1).in(km) << '\n';
quantity<si::kilo<si::metre>> q2 = q1; // double by default
Important
The mp-units library follows std::chrono::duration
logic and treats floating-point types as implicitly convertible to any unit —
see implicitly_scalable
for details.
Value-truncating conversions¶
The second solution is to explicitly accept the value loss. Every conversion that the framework considers truncating requires the user to state what should happen to the information that does not fit in the result. This is done by passing a rounding policy as the last function argument:
quantity q1 = 1234 * m;
std::cout << q1.in(km, truncated) << '\n'; // 1 km
std::cout << q1.in(km, rounded) << '\n'; // 1 km
std::cout << q1.in(km, rounded_up) << '\n'; // 2 km
std::cout << q1.in(km, rounded_down) << '\n'; // 1 km
quantity<si::kilo<si::metre>, int> q2 = q1.in(km, rounded);
The policy makes it clear at the call site that the conversion loses information and, at the same time, states precisely which representable value gets selected. It is easy to spot in code reviews or while chasing a bug in the source code.
The library provides four rounding policies:
| Policy | Semantics | Consistent with |
|---|---|---|
truncated |
rounds towards zero | static_cast, std::chrono::duration_cast |
rounded |
rounds to the nearest representable value, to even in halfway cases | std::chrono::round |
rounded_down |
rounds towards negative infinity | std::chrono::floor |
rounded_up |
rounds towards positive infinity | std::chrono::ceil |
Note
A rounding policy is not a request to round the quantity. It answers a narrower question: when the exact result of the conversion is not representable in the destination, which of the neighboring representable values should be picked? A value the destination represents exactly is never adjusted.
mp_units::floor/ceil/round<U>(q) are the tools for the other question. They round to
an integral multiple of the provided unit even when the representation could store the
exact result. Both operations coincide for integral representations, and differ as soon
as the destination can hold the exact value:
quantity q = 1. * in;
quantity a = q.in(cm); // 2.54 cm, exact, so no policy is needed
quantity b = ceil<cm>(q); // 3.0 cm, a whole number of centimetres
quantity c = q.in<int>(cm, rounded_up); // 3 cm, `int` cannot hold 2.54
quantity d = q.in(cm, rounded_up); // Compile-time error
The last line is rejected rather than silently yielding 2.54 cm. A double
destination holds the exact result, so there is no neighboring value for the policy to
choose, and answering with the input unchanged would be a poor answer to a question the
user seemed to be asking. Reach for ceil<cm>(q) to round the quantity itself.
Important
truncated and rounded_down differ for negative values. (-1234 * m).in(km, truncated)
yields -1 km while (-1234 * m).in(km, rounded_down) yields -2 km. This is exactly
the trap that made std::chrono retrofit floor(), ceil(), and round() after
shipping the truncating duration_cast. In mp-units, the conversion states its
rounding direction explicitly, so there is no default to be surprised by.
Another place where such a conversion is useful is when a user wants to convert a quantity with a floating-point representation to the one using an integral one. Again, this is a truncating conversion, so a rounding policy is needed:
quantity<si::metre, int> q3 = (3.14 * m).in<int>(truncated);
quantity<si::metre, int> q4 = (3.14 * m).in<int>(rounded);
Info
It is often OK to use an integral as a representation type, but in general, floating-point types provide better precision and are privileged in the library as they are considered to be value-preserving.
The same policies may be passed as the last argument to value_cast, which remains the
escape hatch for generic contexts where the representation type is a dependent name and
the member function would require the template disambiguator:
quantity q5 = value_cast<km>(1567 * m, rounded); // 2 km
quantity q6 = value_cast<int>(3.14 * m); // truncated (static_cast semantics)
When no policy is provided, value_cast keeps its static_cast-like (truncated) semantics
for backward compatibility.
Note
A floating-point destination represents every conversion result with a rounding error
of at most half ULP, which is the semantics of rounded, and the directed modes cannot
be delivered for it. This is why a floating-point destination accepts only the
truncated (understood as the static_cast semantics) and rounded policies.
Rounding happens only on the way to an integral representation, so that is where the policies ask something of the representation type:
- An adjusting policy has to decide which side of a rounding boundary the exact result falls
on, which needs a real scalar. A real scalar wrapper such as
safe_intworks with every policy, while a vector, tensor, or complex representation offers no single value to compare against a boundary and takes onlytruncatedthere. - Rounding a floating-point value to an integral one works on the value's integral part, which the library performs for standard floating-point types only.
In some cases, a unit and a representation type should be changed simultaneously. Moreover,
sometimes, the order of doing those operations matters. In such cases, the library provides
the value_cast<U, Rep>(q) and q.in<Rep>(U, policy) which always return the most precise
result:
inline constexpr struct dim_currency : base_dimension<"$"> {} dim_currency;
inline constexpr struct currency : quantity_spec<dim_currency> {} currency;
inline constexpr struct us_dollar : named_unit<"USD", kind_of<currency>> {} us_dollar;
inline constexpr struct scaled_us_dollar : named_unit<"USD_s", mag_power<10, -8> * us_dollar> {} scaled_us_dollar;
namespace unit_symbols {
inline constexpr auto USD = us_dollar;
inline constexpr auto USD_s = scaled_us_dollar;
} // namespace unit_symbols
using Price = quantity_point<currency[us_dollar]>;
using Scaled = quantity_point<currency[scaled_us_dollar], natural_point_origin<currency>, std::int64_t>;
inline constexpr struct dim_currency : base_dimension<"$"> {} dim_currency;
inline constexpr struct currency : quantity_spec<currency, dim_currency> {} currency;
inline constexpr struct us_dollar : named_unit<"USD", kind_of<currency>> {} us_dollar;
inline constexpr struct scaled_us_dollar : named_unit<"USD_s", mag_power<10, -8> * us_dollar> {} scaled_us_dollar;
namespace unit_symbols {
inline constexpr auto USD = us_dollar;
inline constexpr auto USD_s = scaled_us_dollar;
} // namespace unit_symbols
using Price = quantity_point<currency[us_dollar]>;
using Scaled = quantity_point<currency[scaled_us_dollar], natural_point_origin<currency>, std::int64_t>;
inline constexpr struct dim_currency : base_dimension<"$"> {} dim_currency;
QUANTITY_SPEC(currency, dim_currency);
inline constexpr struct us_dollar : named_unit<"USD", kind_of<currency>> {} us_dollar;
inline constexpr struct scaled_us_dollar : named_unit<"USD_s", mag_power<10, -8> * us_dollar> {} scaled_us_dollar;
namespace unit_symbols {
inline constexpr auto USD = us_dollar;
inline constexpr auto USD_s = scaled_us_dollar;
} // namespace unit_symbols
using Price = quantity_point<currency[us_dollar]>;
using Scaled = quantity_point<currency[scaled_us_dollar], natural_point_origin<currency>, std::int64_t>;
using namespace unit_symbols;
Price price{12.95 * USD};
Scaled spx1 = value_cast<USD_s, std::int64_t>(price);
Scaled spx2 = price.in<std::int64_t>(USD_s, truncated);
As a shortcut, instead of providing a unit and a representation type to value_cast, you
may also provide a Quantity type directly, from which unit and representation type are
taken. However, value_cast<Quantity>, still only allows for changes in unit and
representation type, but not changing the type of the quantity. For that, you will have
to use a quantity_cast
instead.
Overloads are also provided for instances of quantity_point.
All variants of value_cast<...>(q) that apply to instances of quantity have a corresponding
version applicable to quantity_point, where the point_origin remains untouched, and
the cast changes how the "offset" from the origin is represented. Specifically, for any
quantity_point instance qp, all of the following equivalences hold:
static_assert(value_cast<Rep>(qp) == quantity_point{value_cast<Rep>(qp.quantity_from(qp.point_origin)), qp.point_origin});
static_assert(value_cast<U>(qp) == quantity_point{value_cast<U>(qp.quantity_from(qp.point_origin)), qp.point_origin});
static_assert(value_cast<U, Rep>(qp) == quantity_point{value_cast<U, Rep>(qp.quantity_from(qp.point_origin)), qp.point_origin});
static_assert(value_cast<Q>(qp) == quantity_point{value_cast<Q>(qp.quantity_from(qp.point_origin)), qp.point_origin});
Furthermore, there is one additional overload value_cast<ToQP>(qp). This overload permits
to additionally replace the point_origin with another compatible one, while still representing
the same point in the affine space. Thus, it is roughly equivalent to
value_cast<ToQP::unit, ToQP::rep>(qp).point_for(ToQP::point_origin).
In contrast to a separate value_cast followed by point_for (or vice-versa), the combined
value_cast tries to choose the order of the individual conversion steps in a way to avoid
both overflow and unnecessary loss of precision. Overflow is a risk because the change of
origin point may require an addition of a potentially large offset (the difference between
the origin points), which may well be outside the range of one or both quantity types.
Integer scaling: fixed-point arithmetic¶
When both the source and target representation are integral types, unit conversions with
a non-integer conversion factor (e.g. deg → grad, factor 10/9) raise two challenges
that a naive implementation cannot handle correctly:
-
Intermediate overflow — computing
value × num / deninintmax_toverflows for large values even when the final result fits in the representation type, producing silently wrong results:// deg -> grad: factor 10/9 // A naive implementation multiplies first: 1e18 * 10 overflows int64_t (max ≈ 9.22e18): quantity q = (std::int64_t{1'000'000'000'000'000'000} * deg).in(grad, truncated); // Expected: 1'111'111'111'111'111'111ᵍ // Naive result: -938'527'119'301'061'290ᵍ (silent undefined behaviour) // mp-units result: 1'111'111'111'111'111'111ᵍ (correct) -
Floating-point dependency — conversions involving irrational factors (e.g.
deg → rad, factorπ/180) require adoubleintermediate in a naive implementation. This fails silently on FPU-less embedded targets and loses precision for 64-bit integer values (adoublehas only 53 bits of mantissa).
Both challenges are addressed by using fixed-point arithmetic: the conversion factor is
represented at compile time as a widened integer constant (64-bit for types up to 32 bits,
128-bit for int64_t), so the runtime computation is a pure integer multiply followed by
a right-shift with no risk of intermediate overflow and no floating-point operations.
Implementation details
The library distinguishes three sub-cases based on the magnitude \(M\) that relates the two units:
| Case | Condition | Example | Operation | Conversion |
|---|---|---|---|---|
| Integral factor | \(M \in \mathbb{Z}^+\) | m → mm (\(\times 1000\)) |
value * M |
implicit |
| Integral divisor | \(M^{-1} \in \mathbb{Z}^+\) | mm → m (\(\div 1000\)) |
value / M |
explicit |
| Non-integer ratio | otherwise | ft → m (\(\times 0.3048\)) |
fixed-point multiply | explicit |
For the non-integer case the magnitude is converted at compile time to a
fixed-point constant with widened bit-width. The library uses int64_t for
all types up to 32 bits (int8_t, int16_t, int32_t) and 128-bit arithmetic
for int64_t. The actual runtime computation is then a pure integer multiply
followed by a right-shift:
where \(N\) equals 64 for types up to 32 bits, and 128 for int64_t. On platforms
where __int128 is available (most 64-bit targets), the 128-bit arithmetic is
implemented natively; on others, a portable double_width_int emulation is used in
constexpr context.
Because the intermediate is significantly widened (e.g., int64_t for types up to
32 bits, providing much more than double-width headroom for smaller types), it cannot
overflow as long as the input value fits in the representation type — for example,
a value of std::int32_t computed in int64_t has 32 extra bits of safety margin.
For the non-integer ratio path, the result honors the requested rounding policy
(truncated rounds towards zero, and the remaining modes adjust the floor quotient
computed by the shift accordingly). The fixed-point constant is rounded away from
zero at compile time to compensate for one level of double-rounding, keeping the
maximum error within 1 ULP of the true result (i.e. at most ±1 relative to the last
bit of the output). Because the factor itself is an approximation, a rounding policy
may select the neighboring representable value when the exact result lies within that
error of a rounding boundary.
Hint
Chained conversions can accumulate this truncation error additively. Where exact round-trip behavior is required, prefer floating-point representations or perform conversions in a single step rather than via an intermediate unit.
Scaling overflow prevention¶
In the case of small integral types, it is easy to overflow the representation type for
every value besides 0 while performing simple and popular unit conversions. This is why
the library prevents such invalid conversions at compile-time both for explicit and implicit
conversions:
quantity q1 = std::int8_t(1) * km;
quantity q2 = q1.in(m, truncated); // Compile-time error (1)
if(q1 != 1 * m) { /* ... */ } // Compile-time error (2)
- Forced conversion would overflow on scaling.
- Implicit conversion that brings arguments to a common unit before comparison would overflow on scaling.
In the above example, the conversion factor between km and m is 1'000, which is
larger than the maximum value that can be stored in std::int8_t. Even if we want to
convert the smallest possible integral amount (e.g., 1 km), we will overflow the quantity
representation type. We decided not to allow such conversions for safety reasons despite
the value of 0 km would work.
Custom representation types¶
For information on how to integrate a custom representation type with the quantity
conversion machinery — including how to implement the correct operator* and
implicitly_scalable<FromUnit, FromRep, ToUnit, ToRep> — see
Using Custom Representation Types.
Value conversions summary¶
The table below provides all the value conversion functions that may be run on x being the
instance of either quantity or quantity_point:
In the table below, p is one of the rounding policies (truncated, rounded,
rounded_down, rounded_up), and providing it is what enables a truncating conversion:
| Truncating | Representation | Unit | Member function | Non-member function |
|---|---|---|---|---|
| No | Same | u |
x.in(u) |
|
| No | T |
Same | x.in<T>() |
|
| No | T |
u |
x.in<T>(u) |
|
| Yes | Same | u |
x.in(u, p) |
value_cast<u>(x, p) |
| Yes | T |
Same | x.in<T>(p) |
value_cast<T>(x, p) |
| Yes | T |
u |
x.in<T>(u, p) |
value_cast<u, T>(x, p) or value_cast<T, u>(x, p) |
The non-member functions may also be called without a policy, in which case they keep the
static_cast-like (truncated) truncating semantics.