Chapter 5: Type Traits and Conditional Compilation

Type traits are an important metaprogramming technique that enables us to inspect properties of types or to perform transformations of types at compile-time. Type traits are themselves templates and you can see them as meta-types. Knowing information such as the nature of a type, its supported operations, and its various properties is key for performing conditional compilation of templated code. It is also very useful when writing a library of templates.

In this chapter, you will learn the following:

  • Understanding and defining type traits
  • Understanding SFINAE and its purpose
  • Enabling SFINAE with the enable_if type trait
  • Using constexpr if
  • Exploring the standard type traits
  • Seeing real-world examples of using type traits

By the end of the chapter, you will have a good understanding of what type traits are, how they are useful, and what type traits are available in the C++ standard library.

We will start the chapter by looking at what type traits are and how they help us.

Understanding and defining type traits

In a nutshell, type traits are small class templates that contain a constant value whose value represents the answer to a question we ask about a type. An example of such a question is: is this type a floating-point type? The technique for building type traits that provide such information about types relies on template specialization: we define a primary template as well as one or more specializations.

Let’s see how we can build a type trait that tells us, at compile-time, whether a type is a floating-point type:

template <typename T>
struct is_floating_point
{
   static const bool value = false;
};
template <>
struct is_floating_point<float>
{
   static const bool value = true;
};
template <>
struct is_floating_point<double>
{
   static const bool value = true;
};
template <>
struct is_floating_point<long double>
{
   static const bool value = true;
};

There are two things to notice here:

  • We have defined a primary template as well as several full specializations, one for each type that is a floating-point type.
  • The primary template has a static const Boolean member initialized with the false value; the full specializations set the value of this member to true.

There is nothing more to building a type trait than this. is_floating_point<T> is a type trait that tells us whether a type is a floating-point type or not. We can use it as follows:

int main()
{
   static_assert(is_floating_point<float>::value);
   static_assert(is_floating_point<double>::value);
   static_assert(is_floating_point<long double>::value);
   static_assert(!is_floating_point<int>::value);
   static_assert(!is_floating_point<bool>::value);
}

This proves that we have built the type trait correctly. But it does not show a real use-case scenario. For this type trait to be really useful, we need to use it at compile-time to do something with the information it provides.

Let’s suppose we want to build a function that does something with a floating-point value. There are multiple floating-point types, such as float, double, and long double. For us to avoid writing multiple implementations, we would build this as a template function. However, that means we could actually pass other types as template arguments, so we need a way to prevent that. A simple solution is to use the static_assert() statement we saw earlier and produce an error should the user supply a value that is not a floating-point value. This can look as follows:

template <typename T>
void process_real_number(T const value)
{
   static_assert(is_floating_point<T>::value);
   std::cout << "processing a real number: " << value 
             << '
';
}
int main()
{
   process_real_number(42.0);
   process_real_number(42); // error: 
                            // static assertion failed
}

This is a really simple example but it demonstrates the use of type traits to do conditional compilation. There are other approaches than using static_assert() and we will explore them throughout this chapter. For the time being, let’s look at a second example.

Suppose we have classes that define operations for writing to an output stream. This is basically a form of serialization. However, some support this with an overloaded operator<<, others with the help of a member function called write. The following listing shows two such classes:

struct widget
{
   int         id;
   std::string name;
   std::ostream& write(std::ostream& os) const
   {
      os << id << ',' << name << '
';
      return os;
   }
};
struct gadget
{
   int         id;
   std::string name;
   friend std::ostream& operator <<(std::ostream& os, 
                                    gadget const& o);
};
std::ostream& operator <<(std::ostream& os, 
                          gadget const& o)
{
   os << o.id << ',' << o.name << '
';
   return os;
}

In this example, the widget class contains a member function, write. However, for the gadget class, the stream operator, <<, is overloaded for the same purpose. We can write the following code using these classes:

widget w{ 1, "one" };
w.write(std::cout);
gadget g{ 2, "two" };
std::cout << g;

However, our goal would be to define a function template that enables us to treat them the same way. In other words, instead of using either write or the << operator, we should be able to write the following:

serialize(std::cout, w);
serialize(std::cout, g);

This brings up some questions. First, how would such a function template look, and second, how can we know whether a type provides a write method or has the << operator overloaded? The answer to the second question is type traits. We can build a type trait to help us answer this latter question at compile-time. This is how such a type trait may look:

template <typename T>
struct uses_write
{
   static constexpr bool value = false;
};
template <>
struct uses_write<widget>
{
   static constexpr bool value = true;
};

This is very similar to the type trait we defined previously. uses_write tells us whether a type defines the write member function. The primary template sets the data member called value to false, but the full specialization for the widget class sets it to true. In order to avoid the verbose syntax uses_write<T>::value, we can also define a variable template, reducing the syntax to the form uses_write_v<T>. This variable template will look as follows:

template <typename T>
inline constexpr bool uses_write_v = uses_write<T>::value;

To make the exercise simple, we’ll assume that the types that don’t provide a write member function overload the output stream operator. In practice, this is would not be the case, but for the sake of simplicity, we will build on this assumption.

The next step in defining the function template serialize that provides a uniform API for serializing all classes is to define more class templates. However, these would follow the same path – a primary template that provides one form of serialization and a full specialization that provides a different form. Here is the code for it:

template <bool>
struct serializer
{
   template <typename T>
   static void serialize(std::ostream& os, T const& value)
   {
      os << value;
   }
};
template<>
struct serializer<true>
{
   template <typename T>
   static void serialize(std::ostream& os, T const& value)
   {
      value.write(os);
   }
};

The serializer class template has a single template parameter, which is a non-type template parameter. It is also an anonymous template parameter because we don’t use it anywhere in the implementation. This class template contains a single member function. It is actually a member function template with a single type template parameter. This parameter defines the type of value we would serialize. The primary template uses the << operator to output the value to the provided stream. On the other hand, the full specialization of the serializer class template uses the member function write to do the same. Notice that we fully specialize the serializer class template and not the serialize member function template.

The only thing left now is to implement the desired free function serialize. Its implementation will be based on the serializer<T>::serialize function. Let’s see how:

template <typename T>
void serialize(std::ostream& os, T const& value)
{
   serializer<uses_write_v<T>>::serialize(os, value);
}

The signature of this function template is the same as the one of the serialize member function from the serializer class template. The selection between the primary template and the full specialization is done with the help of the variable template uses_write_v, which provides a convenient way to access the value data member of the uses_write type trait.

In these examples, we have seen how to implement type traits and use the information they provide at compile-time to either impose restrictions on types or select between one implementation or the other. A similar purpose has another metaprogramming technique called SFINAE, which we will cover next.

Exploring SFINAE and its purpose

When we write templates, we sometimes need to restrict the template arguments. For instance, we have a function template that should work for any numeric type, therefore integral and floating-point, but should not work with anything else. Or we may have a class template that should only accept trivial types for an argument.

There are also cases when we may have overloaded function templates that should each work with some types only. For instance, one overload should work for integral types and the other for floating-point types only. There are different ways to achieve this goal and we will explore them in this chapter and the next.

Type traits, however, are involved in one way or another in all of them. The first one that will be discussed in this chapter is a feature called SFINAE. Another approach, superior to SFINAE, is represented by concepts, which will be discussed in the next chapter.

SFINAE stands for Substitution Failure Is Not An Error. When the compiler encounters the use of a function template, it substitutes the arguments in order to instantiate the template. If an error occurs at this point, it is not regarded as ill-informed code, only as a deduction failure. The function is removed from the overload set instead of causing an error. Only if there is no match in the overload set does an error occur.

It’s difficult to really understand SFINAE without concrete examples. Therefore, we will go through several examples to explain the concept.

Every standard container, such as std::vector, std::array, and std::map, not only has iterators that enable us to access its elements but also modify the container (such as inserting after the element pointed by an iterator). Therefore, these containers have member functions to return iterators to the first and the one-past-last elements of the container. These methods are called begin and end.

There are other methods such as cbegin and cend, rbegin and rend, and crbegin and crend but these are beyond the purpose of this topic. In C++11, there are also free functions, std:begin and std::end, that do the same. However, these work not just with standard containers but also with arrays. One benefit of these is enabling range-based for loops for arrays. The question is how this non-member function could be implemented to work with both containers and arrays? Certainly, we need two overloads of a function template. A possible implementation is the following:

template <typename T>
auto begin(T& c) { return c.begin(); }   // [1]
template <typename T, size_t N>
T* begin(T(&arr)[N]) {return arr; }      // [2]

The first overload calls the member function begin and returns the value. Therefore, this overload is restricted to types that have a member function begin; otherwise, a compiler error would occur. The second overload simply returns a pointer to the first element of the array. This is restricted to array types; anything else would produce a compiler error. We can use these overloads as follows:

std::array<int, 5> arr1{ 1,2,3,4,5 };
std::cout << *begin(arr1) << '
';       // [3] prints 1
int arr2[]{ 5,4,3,2,1 };
std::cout << *begin(arr2) << '
';       // [4] prints 5

If you compile this piece of code, no error, not even a warning, occurs. The reason for that is SFINAE. When resolving the call to begin(arr1), substituting std::array<int, 5> to the first overload (at [1]) succeeds, but the substitution for the second (at [2]) fails. Instead of issuing an error at this point, the compiler just ignores it, so it builds an overload set with a single instantiation and, therefore, it can successfully find a match for the invocation. Similarly, when resolving the call to begin(arr2), the substitution of int[5] for the first overload fails and is ignored, but it succeeds for the second and is added to the overload set, eventually finding a good match for the invocation. Therefore, both calls can be successfully made. Should one of the two overloads not be present, either begin(arr1) or begin(arr2) would fail to match the function template and a compiler error would occur.

SFINAE only applies in the so-called immediate context of a function. The immediate context is basically the template declaration (including the template parameter list, the function return type, and the function parameter list). Therefore, it does not apply to the body of a function. Let’s consider the following example:

template <typename T>
void increment(T& val) { val++; }
int a = 42;
increment(a);  // OK
std::string s{ "42" };
increment(s);  // error

There are no restrictions on the type T in the immediate context of the increment function template. However, in the body of the function, the parameter val is incremented with the post-fix operator++. That means, substituting for T any type for which the post-fix operator++ is not implemented is a failure. However, this failure is an error and will not be ignored by the compiler.

The C++ standard (license usage link: http://creativecommons.org/licenses/by-sa/3.0/) defines the list of errors that are considered SFINAE errors (in paragraph §13.10.2, Template argument deduction, the C++20 standard version). These SFINAE errors are the following attempts:

  • Creating an array of void, an array of reference, an array of function, an array of negative size, an array of size zero, and an array of non-integral size
  • Using a type that is not a class or enum on the left side of the scope resolution operator :: (such as in T::value_type with T being a numeric type for instance)
  • Creating a pointer to reference
  • Creating a reference to void
  • Creating a pointer to member of T, where T is not a class type
  • Using a member of a type when the type does not contain that member
  • Using a member of a type where a type is required but the member is not a type
  • Using a member of a type where a template is required but the member is not a template
  • Using a member of a type where a non-type is required but the member is not a non-type
  • Creating a function type with a parameter of type void
  • Creating a function type that returns an array type or another function type
  • Performing an invalid conversion in a template argument expression or an expression used in a function declaration
  • Supplying an invalid type to a non-type template parameter
  • Instantiating a pack expansion containing multiple packs of different lengths

The last error in this list was introduced in C++11 together with variadic templates. The others were defined before C++11. We will not go on to exemplify all of these errors, but we can take a look at a couple more examples. The first concerns attempting to create an array of size zero. Let’s say we want to have two function template overloads, one that handles arrays of even sizes and one that handles arrays of odd sizes. A solution to this is the following:

template <typename T, size_t N>
void handle(T(&arr)[N], char(*)[N % 2 == 0] = 0)
{
   std::cout << "handle even array
";
}
template <typename T, size_t N>
void handle(T(&arr)[N], char(*)[N % 2 == 1] = 0)
{
   std::cout << "handle odd array
";
}
int arr1[]{ 1,2,3,4,5 };
handle(arr1);
int arr2[]{ 1,2,3,4 };
handle(arr2);

The template arguments and the first function parameter are similar to what we saw with the begin overload for arrays. However, these overloads for handle have a second anonymous parameter with the default value 0. The type of this parameter is a pointer to an array of type char and a size specified with the expressions N%2==0 and N%2==1. For every possible array, one of these two is true and the other is false. Therefore, the second parameter is either char(*)[1] or char(*)[0], the latter being an SFINAE error (an attempt to create an array of size zero). Therefore, we are able to call either one of the other overloads without generating compiler errors, thanks to SFINAE.

The last example that we will look at in this section will show SFINAE with an attempt to use a member of a class that does not exist. Let’s start with the following snippet:

template <typename T>
struct foo
{
   using foo_type = T;
};
template <typename T>
struct bar
{
   using bar_type = T;
};
struct int_foo : foo<int> {};
struct int_bar : bar<int> {};

Here we have two classes, foo, which has a member type called foo_type, and bar, which has a member type called bar_type. There are also classes that derive from these two. The goal is to write two function templates, one that handles the foo hierarchy of classes, and one that handles the bar hierarchy of classes. A possible implementation is the following:

template <typename T>
decltype(typename T::foo_type(), void()) handle(T const& v)
{
   std::cout << "handle a foo
";
}
template <typename T>
decltype(typename T::bar_type(), void()) handle(T const& v)
{
   std::cout << "handle a bar
";
}

Both overloads have a single template parameter and a single function parameter of type T const&. They also return the same type, and that type is void. The expression decltype(typename T::foo_type(), void()) may need a little consideration to understand better. We discussed decltype in Chapter 4, Advanced Template Concepts. Remember that this is a type specifier that deduces the type of an expression. We use the comma operator, so the first argument is evaluated but then discarded, so decltype will only deduce the type from void(), and the deduced type is void. However, the arguments typename T::foo_type() and typename T::bar_type() do use an inner type, and this only exists either for foo or bar. This is where SFINAE manifests itself, as shown in the following snippet:

int_foo fi;
int_bar bi;
int x = 0;
handle(fi); // OK
handle(bi); // OK
handle(x);  // error

Calling handle with an int_foo value will match the first overload, while the second is discarded because of a substitution failure. Similarly, calling handle with an int_bar value will match the second overload, while the first is discarded because of a substitution failure. However, calling handle with an int will cause substitution failure for both overloads so the final overload set for substituting int will be empty, which means there is no match for the call. Therefore, a compiler error occurs.

SFINAE is not the best way to achieve conditional compilation. However, in modern C++ it’s probably best used together with a type trait called enable_if. This is what we will discuss next.

Enabling SFINAE with the enable_if type trait

The C++ standard library is a family of sub-libraries. One of these is the type support library. This library defines types such as std::size_t, std::nullptr_t, and std::byte, run-time type identification support with classes such as std::type_info, as well as a collection of type traits. There are two categories of type traits:

  • Type traits that enable us to query properties of types at compile-time.
  • Type traits that enable us to perform type transformations at compile-time (such as adding or removing the const qualifier, or adding or removing pointer or reference from a type). These type traits are also called metafunctions.

One type trait from the second category is std::enable_if. This is used to enable SFINAE and remove candidates from a function’s overload set. A possible implementation is the following:

template<bool B, typename T = void>
struct enable_if {};
template<typename T>
struct enable_if<true, T> { using type = T; };

There is a primary template, with two template parameters, a Boolean non-type template, and a type parameter with void as the default argument. This primary template is an empty class. There is also a partial specialization for the true value of the non-type template parameter. This, however, defines a member type simply called type, which is an alias template for the template parameter T.

The enable_if metafunction is intended to be used with a Boolean expression. When this Boolean expression is evaluated as true, it defines a member type called type. If the Boolean expression is false, this member type is not defined. Let’s see how it works.

Remember the example from the Understanding and defining type traits section at the beginning of the chapter, where we had classes that provided a write method to write their content to an output stream, and classes for which the operator<< was overloaded for the same purpose? In that section, we defined a type trait called uses_write and wrote a serialize function template that allowed us to serialize, in a uniform manner, both types of objects (widget and gadget). However, the implementation was rather complex. With enable_if, we can implement that function in a simple manner. A possible implementation is shown in the next snippet:

template <typename T, 
          typename std::enable_if<
             uses_write_v<T>>::type* = nullptr>
void serialize(std::ostream& os, T const& value)
{
   value.write(os);
}
template <typename T,
          typename std::enable_if<
             !uses_write_v<T>>::type*=nullptr>
void serialize(std::ostream& os, T const& value)
{
   os << value;
}

There are two overloaded function templates in this implementation. They both have two template parameters. The first parameter is a type template parameter, called T. The second is an anonymous non-type template parameter of a pointer type that also has the default value nullptr. We use enable_if to define the member called type only if the uses_write_v variable evaluates to true. Therefore, for classes that have the member function write, the substitution succeeds for the first overload but fails for the second overload, because typename * = nullptr is not a valid parameter. For classes for which the operator<< is overloaded, we have the opposite situation.

The enable_if metafunction can be used in several scenarios:

  • To define a template parameter that has a default argument, which we saw earlier
  • To define a function parameter that has a default argument
  • To specify the return type of a function

For this reason, I mentioned earlier that the provided implementation of the serialize overloads is just one of the possibilities. A similar one that uses enable_if to define a function parameter with a default argument is shown next:

template <typename T>
void serialize(
   std::ostream& os, T const& value, 
   typename std::enable_if<
               uses_write_v<T>>::type* = nullptr)
{
   value.write(os);
}
template <typename T>
void serialize(
   std::ostream& os, T const& value,
   typename std::enable_if<
               !uses_write_v<T>>::type* = nullptr)
{
   os << value;
}

You will notice here that we basically moved the parameter from the template parameter list to the function parameter list. There is no other change, and the usage is the same, such as follows:

widget w{ 1, "one" };
gadget g{ 2, "two" };
serialize(std::cout, w);
serialize(std::cout, g);

The third alternative is to use enable_if to wrap the return type of the function. This implementation is only slightly different (the default argument does not make sense for a return type). Here is how it looks:

template <typename T>
typename std::enable_if<uses_write_v<T>>::type serialize(
   std::ostream& os, T const& value)
{
   value.write(os);
}
template <typename T>
typename std::enable_if<!uses_write_v<T>>::type serialize(
   std::ostream& os, T const& value)
{
   os << value;
}

The return type, in this implementation, is defined if uses_write_v<T> is true. Otherwise, a substitution failure occurs and SFINAE takes place.

Although in all these examples, the enable_if type trait was used to enable SFINAE during the overload resolution for function templates, this type trait can also be used to restrict instantiations of class templates. In the following example, we have a class called integral_wrapper that is supposed to be instantiated only with integral types, and a class called floating_wrapper that is supposed to be instantiated only with floating-point types:

template <
   typename T,
   typename=typenamestd::enable_if_t<
                        std::is_integral_v<T>>>
struct integral_wrapper
{
   T value;
};
template <
   typename T,
   typename=typename std::enable_if_t<
                        std::is_floating_point_v<T>>>
struct floating_wrapper
{
   T value;
};

Both these class templates have two type template parameters. The first one is called T, but the second one is anonymous and has a default argument. The value of this argument is defined or not with the help of the enable_if type trait, based on the value of a Boolean expression.

In this implementation, we can see:

  • An alias template called std::enable_if_t, which is a convenient way to access the std::enable_if<B, T>::type member type. This is defined as follows:

    template <bool B, typename T = void>

    using enable_if_t = typename enable_if<B,T>::type;

  • Two variable templates, std::is_integral_v and std::is_floating_point_v, which are convenient ways to access the data members, std::is_integral<T>::value and std::is_floating_point<T>::value. The std::is_integral and std::is_floating_point classes are standard type traits that check whether a type is an integral type or a floating-point type respectively.

The two wrapper class templates shown previously can be used as follows:

integral_wrapper w1{ 42 };   // OK
integral_wrapper w2{ 42.0 }; // error
integral_wrapper w3{ "42" }; // error
floating_wrapper w4{ 42 };   // error
floating_wrapper w5{ 42.0 }; // OK
floating_wrapper w6{ "42" }; // error

Only two of these instantiations work, w1, because integral_wrapper is instantiated with the int type, and w5, because floating_wrapper is instantiated with the double type. All the others generate compiler errors.

It should be pointed out that this code samples only work with the provided definitions of integral_wrapper and floating_wrapper in C++20. For previous versions of the standard, even the definitions of w1 and w5 would generate compiler errors because the compiler wasn’t able to deduce the template arguments. In order to make them work, we’d have to change the class templates to include a constructor, as follows:

template <
   typename T,
   typename=typenamestd::enable_if_t<
                        std::is_integral_v<T>>>
struct integral_wrapper
{
   T value;
   integral_wrapper(T v) : value(v) {}
};
template <
   typename T,
   typename=typename std::enable_if_t<
                        std::is_floating_point_v<T>>>
struct floating_wrapper
{
   T value;
   floating_wrapper(T v) : value(v) {}
};

Although enable_if helps achieve SFINAE with simpler and more readable code, it’s still rather complicated. Fortunately, in C++17 there is a better alternative with constexpr if statements. Let’s explore this alternative next.

Using constexpr if

A C++17 feature makes SFINAE much easier. It’s called constexpr if and it’s a compile-time version of the if statement. It helps replace complex template code with simpler versions. Let’s start by looking at a C++17 implementation of the serialize function that can uniformly serialize both widgets and gadgets:

template <typename T>
void serialize(std::ostream& os, T const& value)
{
   if constexpr (uses_write_v<T>)
      value.write(os);
   else
      os << value;
}

The syntax for constexpr if is if constexpr(condition). The condition must be a compile-time expression. There is no short-circuit logic performed when evaluating the expression. This means that if the expression has the form a && b or a || b, then both a and b must be well-formed.

constexpr if enables us to discard a branch, at compile-time, based on the value of the expression. In our example, when the uses_write_v variable is true, the else branch is discarded, and the body of the first branch is retained. Otherwise, the opposite occurs. Therefore, we end up with the following specializations for the widget and gadget classes:

template<>
void serialize<widget>(std::ostream & os,
                      widget const & value)
{
   if constexpr(true)
   {
      value.write(os);
   }
}
template<>
void serialize<gadget>(std::ostream & os,
                       gadget const & value)
{
   if constexpr(false) 
   {
   } 
   else
   {
      os << value;
   } 
}

Of course, this code is likely to be further simplified by the compiler. Therefore, eventually, these specializations would simply look like the following:

template<>
void serialize<widget>(std::ostream & os,
                       widget const & value)
{
   value.write(os);
}
template<>
void serialize<gadget>(std::ostream & os,
                       gadget const & value)
{
   os << value;
}

The end result is the same as the one we achieved with SFINAE and enable_if, but the actual code we wrote here was simpler and easier to understand.

constexpr if is a great tool for simplifying code and we actually saw it earlier in Chapter 3, Variadic Templates, in the Parameter packs paragraph, when we implemented a function called sum. This is shown again here:

template <typename T, typename... Args>
T sum(T a, Args... args)
{
   if constexpr (sizeof...(args) == 0)
      return a;
   else
      return a + sum(args...);
}

In this example, constexpr if helps us to avoid having two overloads, one for the general case and one for ending the recursion. Another example presented already in this book where constexpr if can simplify the implementation is the factorial function template from Chapter 4, Advanced Template Concepts, in the Exploring template recursion section. That function looked as follows:

template <unsigned int n>
constexpr unsigned int factorial()
{
   return n * factorial<n - 1>();
}
template<> 
constexpr unsigned int factorial<1>() { return 1; }
template<> 
constexpr unsigned int factorial<0>() { return 1; }

With constexpr if, we can replace all this with a single template and let the compiler take care of providing the right specializations. The C++17 version of this function may look as follows:

template <unsigned int n>
constexpr unsigned int factorial()
{
   if constexpr (n > 1)
      return n * factorial<n - 1>();
   else
      return 1;
}

The constexpr if statements can be useful in many situations. The last example presented in this section is a function template called are_equal, which determines whether the two supplied arguments are equal or not. Typically, you’d think that using operator== should be enough to determine whether two values are equal or not. That is true in most cases, except for floating-point values. Because only some of the floating-point numbers can be stored without a precision loss (numbers like 1, 1.25, 1.5, and anything else where the fractional part is an exact sum of inverse powers of 2) we need to take special care when comparing floating-point numbers. Usually, this is solved by ensuring that the difference between two floating-point values is less than some threshold. Therefore, a possible implementation for such a function could be as follows:

template <typename T>
bool are_equal(T const& a, T const& b)
{
   if constexpr (std::is_floating_point_v<T>)
      return std::abs(a - b) < 0.001;
   else
      return a == b;
}

When the T type is a floating-point type, we compare the absolute value of the difference of the two numbers with the selected threshold. Otherwise, we fall back to using operator==. This enables us to use this function not just with arithmetic types, but also any other type for which the equality operator is overloaded.

are_equal(1, 1);                                   // OK
are_equal(1.999998, 1.999997);                     // OK
are_equal(std::string{ "1" }, std::string{ "1" }); // OK
are_equal(widget{ 1, "one" }, widget{ 1, "two" }); // error

We are able to call the are_equal function template with arguments of type int, double, and std::string. However, attempting to do the same with values of the widget type will trigger a compiler error, because the == operator is not overloaded for this type.

So far in this chapter, we have seen what type traits are as well as different ways to perform conditional compilation. We have also seen some of the type traits available in the standard library. In the second part of this chapter, we will explore what the standard has to offer with regard to type traits.

Exploring the standard type traits

The standard library features a series of type traits for querying properties of types as well as performing transformations on types. These type traits are available in the <type_traits> header as part of the type support library. There are several categories of type traits including the following:

  • Querying the type category (primary or composite)
  • Querying type properties
  • Querying supported operations
  • Querying type relationships
  • Modifying cv-specifiers, references, pointers, or a sign
  • Miscellaneous transformations

Although looking at every single type trait is beyond the scope of this book, we will explore all these categories to see what they contain. In the following subsections, we will list the type traits (or most of them) that make up each of these categories. These lists as well as detailed information about each type trait can be found in the C++ standard (see the Further reading section at the end of the chapter for a link to a freely available draft version) or on the cppreference.com website at https://en.cppreference.com/w/cpp/header/type_traits (license usage link: http://creativecommons.org/licenses/by-sa/3.0/).

We will start with the type traits for querying the type category.

Querying the type category

Throughout this book so far, we have used several type traits, such as std::is_integral, std::is_floating_point, and std::is_arithmetic. These are just some of the standard type traits used for querying primary and composite type categories. The following table lists the entire set of such type traits:

Table 5.1
Table 5.1
Table 5.1

Table 5.1

All these type traits are available in C++11. For each of them, starting with C++17, a variable template is available to simplify the access to the Boolean member called value. For a type trait with the name is_abc, a variable template with the name is_abc_v exists. This is true for all the type traits that have a Boolean member called value. The definition of these variables is very simple. The next snippet shows the definition for the is_arithmentic_v variable template:

template< class T >
inline constexpr bool is_arithmetic_v =
   is_arithmetic<T>::value;

Here is an example of using some of these type traits:

template <typename T>
std::string as_string(T value)
{
   if constexpr (std::is_null_pointer_v<T>)
      return "null";
   else if constexpr (std::is_arithmetic_v<T>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}
std::cout << as_string(nullptr) << '
'; // prints null
std::cout << as_string(true) << '
';    // prints 1
std::cout << as_string('a') << '
';     // prints a
std::cout << as_string(42) << '
';      // prints 42
std::cout << as_string(42.0) << '
';   // prints 42.000000
std::cout << as_string("42") << '
';    // error

The function template as_string returns a string containing the value pass as an argument. It works with arithmetic types only and with the nullptr_t for which it returns the value "null".

You must have noticed the statement, static_assert(always_false<T>), and wondering what this always_false<T> expression actually is. It is a variable template of the bool type that evaluates to false. Its definition is as simple as the following:

template<class T> 
constexpr bool always_false = std::false_type::value;

This is needed because the statement, static_assert(false), would make the program ill-formed. The reason for this is that its condition would not depend on a template argument but evaluate to false. When no valid specialization can be generated for a sub-statement of a constexpr if statement within a template, the program is ill-formed (and no diagnostic is required). To avoid this, the condition of the static_assert statement must depend on a template argument. With static_assert(always_false<T>), the compiler does not know whether this would evaluate to true or false until the template is instantiated.

The next category of type traits we explore allows us to query properties of types.

Querying type properties

The type traits that enable us to query properties of types are the following:

Table 5.2
Table 5.2
Table 5.2

Table 5.2

Although most of these are probably straightforward to understand, there are two that seem the same at a first glance. These are is_trivial and is_trivially_copyable. These both are true for scalar types or arrays of scalar types. They also are true for classes that are trivially copyable or arrays of such classes but is_trivial is true only for copyable classes that have a trivial default constructor.

According to the paragraph §11.4.4.1 in the C++ 20 standard, a default constructor is trivial if it is not user-provided, and the class has no virtual member functions, no virtual base classes, no non-static members with default initializers, every direct base of it has a trivial default constructor, and every non-static member of a class type also has a trivial default constructor. To understand this better, let’s look at the following example:

struct foo
{
   int a;
};
struct bar
{
   int a = 0;
};
struct tar
{
   int a = 0;
   tar() : a(0) {}
};
std::cout << std::is_trivial_v<foo> << '
'; // true
std::cout << std::is_trivial_v<bar> << '
'; // false
std::cout << std::is_trivial_v<tar> << '
'; // false
std::cout << std::is_trivially_copyable_v<foo> 
          << '
';                                 // true
std::cout << std::is_trivially_copyable_v<bar> 
          << '
';                                 // true
std::cout << std::is_trivially_copyable_v<tar> 
          << '
';                                 // true

In this example, there are three similar classes. All three of them, foo, bar, and tar, are trivially copyable. However, only the foo class is a trivial class, because it has a trivial default constructor. The bar class has a non-static member with a default initializer, and the tar class has a user-defined constructor, and this makes them non-trivial.

Apart from trivial copy-ability, there are other operations that we can query for with the help of other type traits. We will see these in the following section.

Querying supported operations

The following set of type traits helps us to query supported operations:

Table 5.3
Table 5.3

Table 5.3

Except for the last sub-set, which was introduced in C++17, the others are available in C++11. Each kind of these type traits has multiple variants, including ones for checking operations that are trivial or declared as non-throwing with the noexcept specifier.

Now let’s look at type traits that allow us to query for relationships between types.

Querying type relationships

In this category, we can find several type traits that help to query relationships between types. These type traits are as follows:

Table 5.4

Table 5.4

Of these type traits, perhaps the most used one is std::is_same. This type trait is very useful in determining whether two types are the same. Keep in mind that this type trait takes into account the const and volatile qualifiers; therefore, int and int const, for instance, are not the same type.

We can use this type trait to extend the implementation of the as_string function shown earlier. Remember that if you called it with the arguments true or false it prints 1 or 0, and not true/false. We can add an explicit check for the bool type and return a string containing one of these two values, shown as follows:

template <typename T>
std::string as_string(T value)
{
   if constexpr (std::is_null_pointer_v<T>)
      return "null";
   else if constexpr (std::is_same_v<T, bool>)
      return value ? "true" : "false";
   else if constexpr (std::is_arithmetic_v<T>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}
std::cout << as_string(true) << '
';    // prints true
std::cout << as_string(false) << '
';   // prints false

All the type traits seen so far are used to query some kind of information about types. In the next sections, we will see type traits that perform modifications on types.

Modifying cv-specifiers, references, pointers, or a sign

The type traits that are performing transformations on types are also called metafunctions. These type traits provided a member type (typedef) called type that represents the transformed type. This category of type traits includes the following:

Table 5.5
Table 5.5

Table 5.5

With the exception of remove_cvref, which was added in C++20, all the other type traits listed in this table are available in C++11. These are not all the metafunctions from the standard library. More are listed in the next section.

Miscellaneous transformations

Apart from the metafunctions previously listed, there are other type traits performing type transformations. The most important of these are listed in the following table:

Table 5.6
Table 5.6

Table 5.6

From this list, we have already discussed enable_if. There are some other type traits here that are worth exemplifying. Let’s first look at std::decay and for this purpose, let’s consider the following slightly changed implementation of the as_string function:

template <typename T>
std::string as_string(T&& value)
{
   if constexpr (std::is_null_pointer_v<T>)
      return "null";
   else if constexpr (std::is_same_v<T, bool>)
      return value ? "true" : "false";
   else if constexpr (std::is_arithmetic_v<T>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}

The only change is the way we pass arguments to the function. Instead of passing by value, we pass by rvalue reference. If you remember from Chapter 4, Advanced Template Concepts, this is a forwarding reference. We can still make calls passing rvalues (such as literals) but passing lvalues will trigger compiler errors:

std::cout << as_string(true) << '
';  // OK
std::cout << as_string(42) << '
';    // OK
bool f = true;
std::cout << as_string(f) << '
';     // error
int n = 42;
std::cout << as_string(n) << '
';     // error

The last two calls are triggering the static_assert statement to fail. The actual type template arguments are bool& and int&. Therefore std::is_same<bool, bool&> will initialize the value member with false. Similarly, std::is_arithmetic<int&> will do the same. In order to evaluate these types, we need to ignore references and the const and volatile qualifiers. The type trait that helps us do so is std::decay, which performs several transformations, as described in the previous table. Its conceptual implementation is the following:

template <typename T>
struct decay
{
private:
    using U = typename std::remove_reference_t<T>;
public:
    using type = typename std::conditional_t< 
        std::is_array_v<U>,
        typename std::remove_extent_t<U>*,
        typename std::conditional_t< 
            std::is_function<U>::value,
            typename std::add_pointer_t<U>,
            typename std::remove_cv_t<U>
        >
    >;
};

From this snippet, we can see that std::decay is implemented with the help of other metafunctions, including std::conditional, which is key for selecting between one type or another based on a compile-time expression. Actually, this type trait is used multiple times, which is something you can do if you need to make a selection based on multiple conditions.

With the help of std::decay, we can modify the implementation of the as_string function, stripping reference, and cv-qualifiers:

template <typename T>
std::string as_string(T&& value)
{
   using value_type = std::decay_t<T>;
   if constexpr (std::is_null_pointer_v<value_type>)
      return "null";
   else if constexpr (std::is_same_v<value_type, bool>)
      return value ? "true" : "false";
   else if constexpr (std::is_arithmetic_v<value_type>)
      return std::to_string(value);
   else
      static_assert(always_false<T>);
}

By changing the implementation as shown here, we made the previous calls to as_string that failed to compile without any more errors.

In the implementation of std::decay we saw the repetitive use of std::conditional. This is a metafunction that is fairly easy to use and can help to simplify many implementations. In Chapter 2, Template Fundamentals, in the section Defining alias templates, we saw an example where we built a list type called list_t. This had a member alias template called type that was aliasing either the template type T, if the size of the list was 1, or std::vector<T>, if it was higher. Let’s look at the snippet again:

template <typename T, size_t S>
struct list
{
   using type = std::vector<T>;
};
template <typename T>
struct list<T, 1>
{
   using type = T;
};
template <typename T, size_t S>
using list_t = typename list<T, S>::type;

This implementation can be greatly simplified with the help of std::conditional as follows:

template <typename T, size_t S>
using list_t = 
   typename std::conditional<S == 
                 1, T, std::vector<T>>::type;

There is no need to rely on class template specialization to define such a list type. The entire solution can be reduced to defining an alias template. We can verify this works as expected with some static_assert statements, as follows:

static_assert(std::is_same_v<list_t<int, 1>, int>);
static_assert(std::is_same_v<list_t<int, 2>,
                             std::vector<int>>);

Exemplifying the use of each of the standard type traits is beyond the scope of this book. However, the next section of this chapter provides more complex examples that require the use of several standard type traits.

Seeing real-world examples of using type traits

In the previous section of the chapter, we have explored the various type traits that the standard library provides. It is difficult and unnecessary to find examples for each and every type trait. However, it is worth showcasing some examples where multiple type traits can be used for solving a problem. We will do this next.

Implementing a copy algorithm

The first example problem we will take a look at is a possible implementation for the std::copy standard algorithm (from the <algorithm> header). Keep in mind that what we will see next is not the actual implementation but a possible one that helps us learn more about the use of type traits. The signature of this algorithm is as follows:

template <typename InputIt, typename OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last,
                        OutputIt d_first);

As a note, this function is constexpr only in C++20, but we can discuss it in this context. What it does is copy all the elements in the range [first, last) to another range that begins with d_first. There is also an overload that takes an execution policy, and a version, std::copy_if, that copies all the elements that match a predicate, but these are not important for our example. A straightforward implementation of this function is the following:

template <typename InputIt, typename OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last,
                        OutputIt d_first)
{
   while (first != last)
   {
      *d_first++ = *first++;
   }
   return d_first;
}

However, there are cases when this implementation can be optimized by simply copying memory. However, there are some conditions that must be met for this purpose:

  • Both iterator types, InputIt and OutputIt, must be pointers.
  • Both template parameters, InputIt and OutputIt, must point to the same type (ignoring cv-qualifiers).
  • The type pointed by InputIt must have a trivial copy assignment operator.

We can check these conditions with the following standard type traits:

  • std::is_same (and the std::is_same_v variable) to check that two types are the same.
  • std::is_pointer (and the std::is_pointer_v variable) to check that a type is a pointer type.
  • std::is_trivially_copy_assignable (and the std::is_trivially_copy_assignable_v variable) to check whether a type has a trivial copy assignment operator.
  • std::remove_cv (and the std::remove_cv_t alias template) to remove cv-qualifiers from a type.

Let’s see how we can implement this. First, we need to have a primary template with the generic implementation, and then a specialization for pointer types with the optimized implementation. We can do this using class templates with member function templates as shown next:

namespace detail
{
   template <bool b>
   struct copy_fn
   {
      template<typename InputIt, typename OutputIt>
      constexpr static OutputIt copy(InputIt first, 
                                     InputIt last, 
                                     OutputIt d_first)
      {
         while (first != last)
         {
            *d_first++ = *first++;
         }
         return d_first;
      }
   };
   template <>
   struct copy_fn<true>
   {
      template<typename InputIt, typename OutputIt>
      constexpr static OutputIt* copy(
         InputIt* first, InputIt* last,
         OutputIt* d_first)
      {
         std::memmove(d_first, first, 
                      (last - first) * sizeof(InputIt));
         return d_first + (last - first);
      }
   };
}

To copy memory between a source and a destination we use std::memmove here, which copies data even if objects overlap. These implementations are provided in a namespace called detail, because they are implementation details that are used in turn by the copy function and not directly by the user. The implementation of this generic copy algorithm could be as follows:

template<typename InputIt, typename OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last, 
                        OutputIt d_first)
{
   using input_type = std::remove_cv_t<
      typename std::iterator_traits<InputIt>::value_type>;
   using output_type = std::remove_cv_t<
      typename std::iterator_traits<OutputIt>::value_type>;
   constexpr bool opt =
      std::is_same_v<input_type, output_type> &&
      std::is_pointer_v<InputIt> &&
      std::is_pointer_v<OutputIt> &&
      std::is_trivially_copy_assignable_v<input_type>;
   return detail::copy_fn<opt>::copy(first, last, d_first);
}

You can see here that the decision to select one specialization or the other is based on a constexpr Boolean value that is determined using the aforementioned type traits. Examples of using this copy function are shown in the next snippet:

std::vector<int> v1{ 1, 2, 3, 4, 5 };
std::vector<int> v2(5);
// calls the generic implementation
copy(std::begin(v1), std::end(v1), std::begin(v2));
int a1[5] = { 1,2,3,4,5 };
int a2[5];
// calls the optimized implementation
copy(a1, a1 + 5, a2);

Keep in mind that this is not the real definition of the generic algorithm copy you will find in standard library implementations, which are further optimized. However, this was a good example to demonstrate how to use type traits for a real-world problem.

For simplicity, I have defined the copy function in what appears to be the global namespace. This is a bad practice. In general, code, especially in libraries, is grouped in namespaces. In the source code on GitHub that accompanies the book, you will find this function defined in a namespace called n520 (this is just a unique name, nothing relevant to the topic). When calling the copy function that we have defined, we would actually need to use the fully qualified name (that includes the namespace name) such as the following:

n520::copy(std::begin(v1), std::end(v1), std::begin(v2));

Without this qualification, a process called Argument-Dependent Lookup (ADL) would kick in. This would result in resolving the call to copy to the std::copy function because the arguments we pass are found in the std namespace. You can read more about ADL at https://en.cppreference.com/w/cpp/language/adl.

Now, let’s look at another example.

Building a homogenous variadic function template

For the second example, we want to build a variadic function template that can only take arguments of the same type or types that can be implicitly converted to a common one. Let’s start with the following skeleton definition:

template<typename... Ts>
void process(Ts&&... ts) {}

The problem with this is that all of the following function calls work (keep in mind that the body of this function is empty so there will be no errors due to performing operations unavailable on some types):

process(1, 2, 3);
process(1, 2.0, '3');
process(1, 2.0, "3");

In the first example, we pass three int values. In the second example, we pass an int, a double, and a char; both int and char are implicitly convertible to double, so this should be all right. However, in the third example, we pass an int, a double, and a char const*, and this last type is not implicitly convertible to either int or double. Therefore, this last call is supposed to trigger a compiler error but does not.

In order to do so, we need to ensure that when a common type for the function arguments is not available, the compiler will generate an error. To do so, we can use a static_assert statement or std::enable_if and SFINAE. However, we do need to figure out whether a common type exists or not. This is possible with the help of the std::common_type type trait.

The std::common_type is a metafunction that defines the common type among all of its type arguments that all the types can be implicitly converted to. Therefore std::common_type<int, double, char>::type will alias the double type. Using this type trait, we can build another type trait that tells us whether a common type exists. A possible implementation is as follows:

template <typename, typename... Ts>
struct has_common_type : std::false_type {};
template <typename... Ts>
struct has_common_type<
          std::void_t<std::common_type_t<Ts...>>, 
          Ts...>
   : std::true_type {};
template <typename... Ts>
constexpr bool has_common_type_v =
   sizeof...(Ts) < 2 ||
   has_common_type<void, Ts...>::value;

You can see in this snippet that we base the implementation on several other type traits. First, there is the std::false_type and std::true_type pair. These are type aliases for std::bool_constant<false> and std::bool_constant<true> respectively. The std::bool_constant class is available in C++17 and is, in turn, an alias template for a specialization of the std::integral_constant class for the bool type. This last class template wraps a static constant of the specified type. Its conceptual implementation looks as follows (although some operations are also provided):

template<class T, T v>
struct integral_constant
{
   static constexpr T value = v;
   using value_type = T;
};

This helps us simplify the definition of type traits that need to define a Boolean compile-time value, as we saw in several cases in this chapter.

A third type trait used in the implementation of the has_common_type class is std::void_t. This type trait defines a mapping between a variable number of types and the void type. We use this to build a mapping between the common type, if one exists, and the void type. This enables us to leverage SFINAE for the specialization of the has_common_type class template.

Finally, a variable template called has_common_type_v is defined to ease the use of the has_common_type trait.

All these can be used to modify the definition of the process function template to ensure it only allows arguments of a common type. A possible implementation is shown next:

template<typename... Ts,
         typename = std::enable_if_t<
                       has_common_type_v<Ts...>>>
void process(Ts&&... ts) 
{ }

As a result of this, calls such as process(1, 2.0, "3") will produce a compiler error because there is no overloaded process function for this set of arguments.

As previously mentioned, there are different ways to use the has_common_type trait to achieve the defined goal. One of these, using std::enable_if, was shown here, but we can also use static_assert. However, a much better approach can be taken with the use of concepts, which we will see in the next chapter.

Summary

This chapter explored the concept of type traits, which are small classes that define meta-information about types or transformation operations for types. We started by looking at how type traits can be implemented and how they help us. Next, we learned about SFINAE, which stands for Substitution Failure Is Not An Error. This is a technique that enables us to provide constraints for template parameters.

We then saw how this purpose can be achieved better with enable_if and constexpr if, in C++17. In the second part of the chapter, we looked at the type traits available in the standard library and demonstrated how to use some of them. We ended the chapter with a couple of real-world examples where we used multiple type traits to solve a particular problem.

In the next chapter, we continue the topic of constraining the template parameters by learning about the C++20 concepts and constraints.

Questions

  1. What are type traits?
  2. What is SFINAE?
  3. What is constexpr if?
  4. What does std::is_same do?
  5. What does std::conditional do?

Further reading

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
52.15.160.43