Logo

Blog


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

Comments