Au 103: Unit Conversions¶
This tutorial explains how to perform unit conversions, both implicitly and explicitly.
- Time: 30 minutes.
- Prerequisites:
- You will learn:
- How
.in(...)
, the “value retrieving” function, actually performs unit conversions. .as(...)
: the safer cousin of.in(...)
, which gives a quantity instead of a raw number.- How to “force” a physically meaningful conversion which Au thinks is dangerous.
- Which conversions work automatically, which work only when forced, and which are prevented.
- Which implicit conversions are allowed.
- How
New units for .in(...)
¶
Once we store a value in a Quantity
, we know we need to name its unit explicitly to get the value
back out — that’s Au 101.
That API is awfully suggestive, though. What happens if we pass some other unit?
Answer: it does just what it looks like it does.
We introduced .in(...)
as, essentially, “the function that gets the value back out”. That was
true, but incomplete. Now we see its true role: it’s the quantity value conversion function. The
role we saw earlier is just a special case: when we pass the same unit we used to create it, that
“conversion” is simply the identity.
Already, this opens up a new simple, self-contained use case for Au: it’s very easy to conjure up highly readable unit conversions on the fly, even if you both start and end with raw numbers. Consider this example:
// Starting with a raw numeric type:
double angle_deg = 90.0;
{ // ⚠️ Old, manual method for doing conversions:
constexpr auto RAD_PER_DEG = M_PI / 180.0;
double angle_rad = angle_deg * RAD_PER_DEG;
}
{ // ✔️ Easy, readable ad hoc conversion with Au:
double angle_rad = degrees(angle_deg).in(radians);
}
With the old method, we needed to manually craft a carefully named conversion constant. And because
this was an angular conversion, we also needed to worry about how to get a good value for \pi
(here, we chose the M_PI
macro). By contrast, the Au-based alternative gives you a readable,
clearly correct one-liner out of the box — and it doesn’t trouble the author or reader with the
details of correctly obtaining (and using) \pi.
.as(...)
: like .in(...)
, but makes a quantity¶
Using .in(...)
works very well when you want a raw number — typically, when you’re interacting
with some legacy interface. However, sometimes what you want is a quantity that’s expressed in a
specific unit. For example, you might be comparing quantities in a hot loop, and you’d rather avoid
repeated conversions. Or, you might want to print your quantity in some specific unit.
We could satisfy these use cases with .in(...)
, but it’s a little clunky:
// Not recommended! See below.
auto angle = radians(degrees(angle_deg).in(radians));
// ^^^^^^^ Raw number--^^ ^^^^^^^
// | |
// \--Repeated identifier--/
This approach wouldn’t just be repetitive; it would also create a (small!) opportunity for error, because you temporarily leave the safety of the units library before re-entering it.
Fortunately, there’s a better choice. Quantity
has another member function, .as(...)
, for
exactly this use case. You can pass it any unit you would pass to .in(...)
, but .as(...)
returns a quantity, not a raw number. Building on our earlier example:
Use .as(...)
when you want easy, inline, fine-grained control over the units in which your
quantities are expressed.
Conversion categories¶
The examples so far have been pretty straightforward. To convert from feet
to inches
, we simply
multiply the underlying value by 12. That seems pretty safe for just about any Rep1, whether
floating point or integral. However, other conversions can be more subtle.
Let’s look at a bunch of example unit conversions. We’ll show how each conversion works with both
int
and double
Rep, because the rules can differ significantly for integral and floating point
types.
Instructions
For each example: stop and think about what you would expect the library to produce in each case. When you’re ready, click over to the “Results and Discussion” tab to check your intuition.
Example: feet
to yards
Converting from feet
to yards
means dividing the underlying value by 3.
For an integral Rep, this actually yields a compiler error, because we can’t
guarantee that the result will be an integer. True, with feet(6)
, it so happens that it
would — but if we had feet(5)
, this wouldn’t be the case!
Floating point Rep is simpler. When we divide any value by 3
, we won’t exceed typical
floating point error. Because this level of uncertainty simply goes with the territory when
using floating point types, Au allows this operation with no complaint.
Example: feet
to nano(meters)
feet(6).as(nano(meters)); // Compiler error!
feet(6.0).as(nano(meters)); // nano(meters)(1'828'800'000.0)
Converting from feet
to nano(meters)
means multiplying the underlying value by
304,800,000.
Unlike the last example, this is guaranteed to produce an integer result. Yet, the
integral Rep again gives us a compiler error! This time, we’re guarding against
a different risk: overflow. It turns out that any underlying value larger than
feet(7)
would overflow in this conversion. That’s pretty scary, so we forbid this
conversion.
Of course, that’s just because int
is typically only 32 bits. Au adapts to the specific
level of overflow risk, based on both the conversion and the range of the type. For
example, this integral-type conversion would work:
Since long long
is at least 64 bits, we could handle values into the tens of billions of
feet before overflowing!
In more detail: the "Overflow Safety Surface"
Here is how to reason about which integral-Rep conversions the library supports.
For every conversion operation, there is some smallest value which would overflow. This depends on both the size of the conversion factor, and the range of values which the type can hold. If that smallest value is small enough to be “scary”, we forbid the conversion.
How small is “scary”? Here are some considerations.
-
Once our values get over 1,000, we can consider switching to a larger SI-prefixed version of the unit. (For example, lengths over 1000\,\text{m} can be approximated in \text{km}.) This means that if a value as small as 1,000 would overflow — so small that we haven’t even reached the next unit — we should definitely forbid the conversion.
-
On the other hand, we’ve found it useful to initialize, say,
QuantityI32<Hertz>
variables with something likemega(hertz)(500)
. Thus, we’d like this operation to succeed (although it should probably be near the border of what’s allowed).
Putting it all together, we settled on a value threshold of 2‘147. If we
can convert this value without overflow, then we permit the operation; otherwise, we
don’t. We picked this value because it satisfies our above criteria nicely. It will
prevent operations that can’t handle values of 1,000, but it still lets us use
\text{MHz} freely when storing \text{Hz} quantities in int32_t
.
We can picture this relationship in terms of the biggest allowable conversion factor, as a function of the max value of the type. This function separates the allowed conversions from the forbidden ones, permitting bigger conversions for bigger types. We call this abstract boundary the “overflow safety surface”, and it’s the secret ingredient that lets us use a wide variety of integral types with confidence.
As for the floating point value, this is again very safe, so we allow it without complaint.
Example: feet
to kelvins
Explicit-Rep versions: .in<T>(...)
and .as<T>(...)
¶
You can choose the Rep for the result of your conversion by providing it as an explicit template parameter. For example:
This “explicit-Rep” version is morally equivalent to static_cast
, in that it is forcing.
For example:
// Standard C++ intuition:
static_cast<int>(3.14); // 3
// Similar "forcing" semantics:
feet(5).as<int>(yards); // yards(1) --- a truncation of (5/3 = 1.6666...) yards
You can use this to “overrule” Au when we prevent a physically meaningful conversion because we think it’s too risky.
Tip
Prefer not to use the explicit-Rep version unless you have a good reason. If you do, consider adding a comment to explain why your specific use case is OK.
As a code reviewer, if you see an explicit-Rep version that doesn’t seem necessary or justified, ask about it!
At this point, we’ve seen several examples of conversions which Au forbids. We’ve also seen how some of them can be forced anyway. Here’s a chance to test your understanding: what will happen if you try to force that final example — the one where the dimensions differ?
Example: forcing different-dimension conversions?
As before, stop and think about what you would expect the library to produce. When you’re ready, click over to the “Results and Discussion” tab to check your intuition.
Conversion summary¶
This table gives a visual summary of how different kinds of risks impact conversions with different storage types.
Conversion | Result (int Rep):length = feet(6) |
Result (double Rep):length = feet(6.0) |
---|---|---|
length.as(inches) |
inches(72) |
inches(72.0) |
length.as(yards) |
Forbidden: not guaranteed to be integral (can be forced with explicit-Rep) |
yards(2.0) |
length.as(nano(meters)) |
Forbidden: excessive overflow risk (can be forced with explicit-Rep) |
nano(meters)(1'828'800'000.0) |
length.as(kelvins) |
Forbidden: meaningless | Forbidden: meaningless |
Implicit conversions¶
Au emphasizes developer experience. We strive to provide the same ergonomics which developers have
come to expect from the venerable std::chrono
library. This means that any meaningful conversion
which we consider “safe enough” (based on the above criteria), we permit implicitly. This lets
you fluently initialize a quantity parameter with any convertible expression. For example:
// API accepting a quantity parameter.
void rotate(QuantityD<Radians> angle);
// This works!
// We'll automatically convert the integral quantity of degrees to `QuantityD<Radians>`.
rotate(degrees(45));
Our conversion policy is a refinement of the policy for std::chrono::duration
. Here
is their policy (paraphrased and simplified):
- Implicit conversions are permitted if either:
- The destination is floating point;
- Or, the source type is integer, and the conversion multiplies by an integer.
And here is our refinement (the overflow safety surface):
- If an integral-Rep conversion would overflow the destination type for a source value as small
as
2'147
, we forbid the conversion.
Deeper dive: comparing overflow strategies for Au and chrono
The std::chrono
library doesn’t consider overflow in its conversion policy, because they
handle the problem in a different way. Instead of encouraging users to use duration
directly,
they provide pre-defined helper types such as std::chrono::nanoseconds
,
std::chrono::milliseconds
, etc. The Rep for each of these types is chosen to guarantee
covering at least 292 years in either direction.
This is a good and practical solution, which is effective at preventing overflow for users who stick to these helper types. The downside is that it forces users to change their underlying storage types — changing the assembly code produced — in the process of acquiring unit safety.
A key design goal of Au is to avoid forcing users to change their underyling numeric types. We want to empower people to get the same assembly they would have had without Au, just more safely. Because smaller numeric types bring this extra overflow risk (and in a way that’s often non-obvious to developers), we designed this adaptive policy which prevents the biggest risks.
(Lastly, of course we also forbid conversions between units of different dimensions. This
consideration wasn’t part of the std::chrono
library, because that library only has a single
dimension.)
Exercise: practicing conversions¶
Check out the Au 103: Unit Conversions Exercise!
Takeaways¶
-
To convert a quantity to a particular unit, pass that unit’s quantity maker to the appropriate member function,
.in(...)
or.as(...)
.- For example,
minutes(3)
is a quantity, andminutes(3).as(seconds)
producesseconds(180)
.
- For example,
-
.in(...)
gives a raw number, while.as(...)
gives a quantity.-
These names are used consistently in this way throughout the library. For example, we’ll learn in the next tutorial that
round_in(...)
produces a raw number, whileround_as(...)
produces a quantity. -
Prefer
.as(...)
when you have a choice, because it stays within the safety of the library.
-
-
Both conversion functions include safety checks. This means you can generally just use them, and rely on the library to guard against the biggest risks. Here are the details to remember about these safety checks.
-
We forbid conversions with mismatched dimensions (as with any units library).
-
We forbid conversions for integer destinations unless we’re sure we’ll always have an integer (as with the
chrono
library), and we don’t have excessive overflow risk (an Au-original feature!).
-
-
You can force a conversion that is meaningful, but considered dangerous by providing an explicit target Rep as a template parameter.
-
For example:
seconds(200).as(minutes)
won’t compile, butseconds(200).as<int>(minutes)
givesminutes(3)
, because we forced this lossy conversion with the explicit<int>
. -
Use this sparingly, since the safety checks are there for a reason, and consider adding a comment to explain why your usage is safe.
-
You can never force a conversion to a different dimension, because this is not meaningful.
-
-
Any conversion allowed by
.as(...)
will also work as an implicit conversion.- For example, if you have an API which takes
QuantityD<Radians>
, you can passdegrees(45)
directly to it.
- For example, if you have an API which takes
-
Recall that the “Rep” is shorthand for the underlying storage type of the quantity. ↩