Strong Units Conversions
Strong types are a way to add a meaning to objects by giving them a meaningful name, by using types. This lets the compiler, human beings, and developers understand better the intent of a piece of code.
We’ve been exploring strong types on Fluent C++. I focus here on how to define strong types conversions.
If you want to catch up on strong types, you can read the main article: Strong types for strong interfaces.
The whole series of posts about strong types is:
- Strongly typed constructors
- Strong types for strong interfaces
- Passing strong types by reference
- Strong lambdas: strong typing over generic types
- Good news: strong types are (mostly) free in C++
- Inheriting functionalities from the underlying type
- Making strong types hashable
- Converting strong units to one another
- Metaclasses, the Ultimate Answer to Strong Typing in C++?
- Making strong types implicitly convertible
Motivation
At first I didn’t think it could be useful, or even reasonable, to allow conversions among strong types. But some of the things that the std::chrono
library made me change my mind.
For example, you can pass a value in hours where seconds are expected, and the fact that all duration types are convertible to one another allows the code to do what you would expect from it.
#include <chrono> #include <iostream> using namespace std::chrono; void doSomethingFor(seconds s) { std::cout << s.count() << '\n'; } int main() { doSomethingFor(4h); }
Even if the value passed to the doSomethingFor
function 4 is in hours, the implicit conversion to seconds makes this code output 14400, because this is how many seconds there are in 4 hours.
This shows that implementing conversions between certain strong types can be useful. Of course we don’t want every type to be convertible to any other type, so we would like to able to declare which strong type can be convertible to which, and how to apply this conversion.
We will use the NamedType
class described in the main post about strong types. With it, a strong type can be declared the following way:
using Meter = NamedType<double, struct MeterTag>;
We would like to be able to easily add to this declaration that this type can be convertible to others.
I will start by dealing with integral multiples, like from converting from meter to kilometers. Then we will see how to declare conversions in the general case, like with non integral conversion (from kilometers to miles), or even non-linear conversions (from decibels to watts).
The solution that I propose takes inspiration from the elegant interfaces of std::chrono
, in order to be able to apply the same ideas to any other strong type. For the record the Boost.Unit library also aims at manipulating units, but with a different design (it is very interesting to read though, as always with boost libraries).
Multiples of a unit
The standard library has a class representing an rational factor: std::ratio
. It takes two integral numbers, a numerator and a denominator, as template parameters. For example:
std::ratio<3,2>
represents a ratio of 3/2.
This is what std::chrono
uses to represent conversion factors between durations. For example between a minute and a second there is a ratio of std::ratio<60>
(the second template parameter defaults to 1).
We can add a ratio in the template parameters of NamedType
:
template <typename T, typename Tag, typename Ratio> class NamedType { ...
And choose a unit of reference for a certain quantity, that has the ratio std::ratio<1>
. Say for example that Meter
defined above is a reference unit for distances.
This way, strong types representing the same quantity but with different ratios are effectively different types. And we want to write an implicit conversion operator to other strong types with different ratios. To be able to convert to types representing the same quantity only, we will use the same Parameter
type (which is a tag used above in MeterTag
) to defined types convertible with each other.
For example we would declare:
using Meter = NamedType<double, DistanceTag, std::ratio<1>>; using Millimeter = NamedType<double, DistanceTag, std::milli>;
(note that std::milli
is a typedef for std::ratio<1, 1000>
).
The conversion operator is fairly straightforward to write once we get the order of the ratios right:
// in NamedType class definition template <typename Ratio2> operator NamedType<T, Tag, Ratio2>() const { return NamedType<T, Tag, Ratio2>(get() * Ratio::num / Ratio::den * Ratio2::den / Ratio2::num); }
The declaration above is arguably cumbersome though, because it forces the user to get the tags right. We can simplify this by passing std::ratio<1>
by default and using a specific typedef for multiples. For this let’s rename our NamedType
by NamedTypeImpl
, to keep NamedType
for the reference type that uses a ratio of std::ratio<1>
:
template <typename T, typename Tag> using NamedType = NamedTypeImpl<T, Tag, std::ratio<1>>;
And we can define a specific typedef for multiples: MultipleOf
.
(While the implementation of MultipleOf
is really not difficult, I consider this too much of an implementation detail to get into here. Let’s focus on the interface to see where this is going. If you’re really interested in the implementation feel free to have a look at the GitHub repo, feedback welcome).
We can then write our declarations the following way:
using Meter = NamedType<double, MeterTag>; using Millimeter = MultipleOf<Meter, std::milli>;
And with the template implicit conversion operator, we can pass meters where millimeters are expected, or the other way round, and the multiplication by the ratio will do the necessary conversion.
The general case of conversion
Some conversions are more complex that just multiplicating or dividing (or both) by a ratio. For example the unit used to measure sound volumes (dB or decibels) corresponds to a certain power (in watts), and the conversion formula is not linear. It is:
and the other way round:
This cannot be achieved with our previous construction with ratios. And we don’t even need to go this far to be limited with ratios: C++ does not accept floating point numbers as template parameters. So for non-integral linear conversions (like between miles and kilometers with a ratio of 1.609) we can’t just pass the conversion factor into a ratio.
What to do then?
Maybe you want to take a moment to ponder this, before reading on.
Done?
One solution is to take a step back and realize that the ratios we used defined conversion functions. With ratios, these conversions functions only consist in multiplying or dividing by the numerators and denominators of the ratios. But why not use other functions?
So instead of declaring a multiple by giving a ratio, we could declare a type that is related to another type by providing two functions, one to convert from it and one to convert to it.
So to make our NamedTypeImpl
class more general we replace Ratio
by Converter
:
template <typename T, typename Tag, typename Converter> class NamedTypeImpl { ...
and agree that the (static) interface a converter has to expose consists of two functions: a convertFrom
function and a convertTo
function.
Then the generalized implicit conversion operator of the named type class becomes:
template <typename Converter2> operator NamedTypeImpl<T, Tag, Converter2>() const { return NamedTypeImpl<T, Tag, Converter2>(Converter2::convertFrom(Converter::convertTo(get()))); }
This follows the same idea as the ratios, but with the general case of converting from and to the unit of reference.
To instantiate a type convertible to another one we can use the convenience typedef ConvertibleTo
. (Once again, let’s focus on the interface rather than the implementation details here. You can have a look at the implementation of ConvertibleTo
here on GitHub if you’re interested).
It can be used the following way:
using Watt = NamedType<double, struct WattTag>; struct ConvertDBFromAndToWatt { static double convertFrom(double watt) { return 10 * log(watt) / log(10); } static double convertTo(double db) { return pow(10, db / 10); } }; using dB = ConvertibleTo<Watt, ConvertDBFromAndToWatt>;
And you can then pass dB where watts were expected, or the other way round, and the code will do Just The Right Thing.
Yay!
Keeping ratios
Even though some relations between units are more complex that multiplying or diving by an integral ratio, this case remain fairly common. We would therefore like to keep the MultipleOf
that accepted a ratio. To do this, we can write an adapter that accepts a ratio, and makes it fit the expected interface of converters:
template<typename T, typename Ratio> struct ConvertWithRatio { static T convertFrom(T t) { return t * Ratio::den / Ratio::num; } static T convertTo(T t) { return t * Ratio::num / Ratio::den; } };
and MultipleOf
is redefined by using it (see here for the implementation).
And this is it really.
I’ve purposefully skipped over some technical aspects (like the implementation of the convenience typedefs, or ensuring that multiples of multiples work correctly), and hid some of the other functionalities presented in the other articles of this series (like adding, printing, or comparing strong types together). All this was done for the purpose of clarity. But you can see all the code on the dedicated GitHub repository.
All the features of NamedType
are designed to be usable together. For instance, we can write the following code:
// defining Meter using Meter = NamedType<double, struct DistanceTag, Addable, Printable>; Meter operator"" _meter(unsigned long long value) { return Meter(value); } //defining Kilometer using Kilometer = MultipleOf<Meter, std::kilo>; Kilometer operator"" _kilometer(unsigned long long value) { return Kilometer(value); } void printDistance(Meter distance) { std::cout << distance << "m\n"; } printDistance(1_kilometer + 200_meter);
And the above code prints out:
1200m
What should we do with strong types next? Your feedback really matters to me. If you have an opinion on all that was shown here, or on what strong types have to do to be useful to you, by all means, post a comment and let me know.
Related articles:
- Strongly typed constructors
- Strong types for strong interfaces
- Passing strong types by reference
- Strong lambdas: strong typing over generic types
- Good news: strong types are (mostly) free in C++
- Inheriting functionalities from the underlying type
- Making strong types hashable
- Converting strong units to one another
- Metaclasses, the Ultimate Answer to Strong Typing in C++?
Share this post!