Safer type casting with C++17
I like to write less code and letting the compiler fill in the open parts. After all the compiler knows most and best about these things. In C++ we have a strong type system. Valid conversions between types are either done implicitly or with cast-operators. To honor this system we express some of these conversions with casts like static_cast
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void Before() { Foo foo{1.0f}; auto floatFoo = static_cast<float>(foo); printf("%f\n", floatFoo); Bar bar{2}; auto intBar = static_cast<int>(bar); printf("%d\n", intBar); } |
Here is a potential class design for the types Foo
and Bar
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class Foo { public: Foo(float x) : mX{x} {} operator float() const { return mX; } operator int() const { return static_cast<int>(mX); } private: float mX; }; class Bar { public: Bar(int x) : mX{x} {} operator int() const { return mX; } private: int mX; }; |
Imaging that you have dozens of such casts all over your code. They are fine, but a constant source for errors. Especially Foo
is problematic. It can convert to a float
as well as to an int
.
What I like to achieve, is that I can call one function, let's name it default_cast
, which does the cast for me. All the casts which are in 90% of the code the same.
Depending on the input type it converts it to the desired default output type. The resulting code size and speed should match the code I could write by hand. Further it all of it must happen at compile time, as I like to know whether or not a cast is valid.
The mapping table from Foo
to float
and Bar
to int
should be in one place and expressive. So here is how default_cast
could look like:
1 2 3 4 5 6 7 8 | template<typename T> decltype(auto) default_cast(T& t) { return MapType<T, V<Foo, float>, V<Bar, int> >(t); } |
As you can see, it contains the mapping table. Line 5 and 6 are two table entries declaring that the default for Foo
should be float
, whereas for Bar
the default is int
. Looks promising. The type V
is a very simple struct
just capturing the in and out type:
1 2 3 4 5 6 | template<typename InTypeT, typename OutTypeT> struct V { using InType = InTypeT; using OutType = OutTypeT; }; |
So far so good. How does the function MapeType
look like? Of course, it is a template function. Its job is to take the type T
and try to find a match for in the list of V
s. Sounds a lot like a variadic template job. Here is a possible implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | template<typename T, typename C, typename... R> decltype(auto) MapType(T& t) { if constexpr(is_same_v<T, typename C::InType>) { return static_cast<typename C::OutType>(t); } else if constexpr(is_same_v< T, const typename C::InType>) { return static_cast<const typename C::OutType>(t); } else if constexpr(0 == sizeof...(R)) { return t; } else { return MapType<T, R...>(t); } } |
It is based on a C++17 feature: constexpr if
. With that the mapping is done at compile-time. With the help of variadic templates MapType
expands at compile-time looking for a matching input type in the variadic argument list. In case a match is found, the output type is returned with a static_cast
to the desired default output type. In case no matching type is found MapType
pops one V
-argument and calls itself again. The nice thing with C++17 and constexpr if
is, that I can check for the last case where no more arguments are available. Plus it allows me to have mixed return types in one function, as all the discard branches are ignored.
How to handle the case where no mapping exists is up to the specific environment. Here I just pass the original type back. However, this hides some missing table entries. At this point a static_assert
could be the better thing.
This construct generates the same code as I could write it by hand. Just way more deterministic. And here is how default_cast
is applied:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void After() { Foo foo{1.0f}; auto floatFoo = default_cast(foo); printf("%f\n", floatFoo); Bar bar{2}; auto intBar = default_cast(bar); printf("%d\n", intBar); } |
Especially with C++11's auto
the static_cast
's in code I've seen and written increased. auto
captures the original type and does care for conversions. default_cast
is a convenient way to stay safe and consistent with less typing. Still transporting the message, that a cast happens intentionally at this point.
Have fun with C++17 and all the new ways it gives us.
Andreas