Skip to content

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.

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.

constexpr auto q = feet(6);
q.in(feet);  // <-- produces `6`

That API is awfully suggestive, though. What happens if we pass some other unit?

Answer: it does just what it looks like it does.

constexpr auto q = feet(6);
q.in(inches); // <-- produces `72`

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:

auto angle = degrees(angle_deg).as(radians);
//      👍 Don't Repeat Yourself---^^^^^^^

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

feet(6).as(yards);

feet(6.0).as(yards);
feet(6).as(yards);  // Compiler error!

feet(6.0).as(yards);  // yards(2.0)

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));

feet(6.0).as(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:

feet(6LL).as(nano(meters));  // nano(meters)(1'828'800'000LL)

Since long long is at least 64 bits, we could handle values into the tens of billions of feet before overflowing! (For more details on the overflow problem, and Au’s strategies for mitigating it, read our overflow discussion.)

As for the floating point value, this is again very safe, so we allow it without complaint.

Example: feet to kelvins

feet(6).as(kelvins);

feet(6.0).as(kelvins);
feet(6).as(kelvins);  // Compiler error!

feet(6.0).as(kelvins);  // Compiler error!

Converting from feet to kelvins is an intrinsically meaningless operation, because they have different dimensions (namely, length and temperature). For both integral and floating point Rep, we forbid this operation.

Forcing lossy conversions: .coerce_as(...) and .coerce_in(...)

Sometimes, you may want to perform a conversion even though you know it’s usually lossy. For example, maybe you know that your particular value will give an exact result (like converting 6 feet into yards). Or perhaps the truncation is desired.

Whatever the reason, you can simply add the word “coerce” before your conversion function to make it “forcing”. Consider this example.

feet(6.0).as(yards);       // yards(2.0)

// Compiler error!
// feet(6).as(yards);

feet(6).coerce_as(yards);  // yards(2)

These “coercing” versions work similarly to static_cast, in that they will truncate if necessary. For example:

feet(5).coerce_as(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 coercing versions 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 a coercing 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.

feet(6).coerce_as(kelvins);
feet(6).coerce_as(kelvins);  // Compiler error!

Converting units with different dimensions isn’t merely “unsafe”; it’s completely meaningless. We can’t “force” the answer because there isn’t even an answer to force.

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 coercing version)
yards(2.0)
length.as(nano(meters)) Forbidden: excessive overflow risk
(can be forced with coercing version)
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

  1. 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, and minutes(3).as(seconds) produces seconds(180).
  2. .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, while round_as(...) produces a quantity.

    • Prefer .as(...) when you have a choice, because it stays within the safety of the library.

  3. 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!).

  4. You can force a conversion that is meaningful, but considered dangerous by preceding your function name with coerce_.

    • For example: seconds(200).as(minutes) won’t compile, but seconds(200).coerce_as(minutes) gives minutes(3), because we forced this lossy conversion by using the word “coerce”.

    • 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.

  5. 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 pass degrees(45) directly to it.

  1. Recall that the “Rep” is shorthand for the underlying storage type of the quantity.