Template Specialization & Concepts in C++


Written by Quarterstar
12 minutes to read

Loading tip...

If you come from Rust, you might miss the value of traits. Thankfully, C++ has something very similar to them. Suppose you are creating your own vector type (for the thousandth time). You probably do something like:

template<typename T>
struct Vector3 {
  T x, y, z;
};

template<typename T>
struct Vector3 {
  T x, y, z, w;
};

C++ has a feature called template specialization that allows you to specify behavior for particular instances of the template:

template <typename T>
struct Foo {
  static void display() {
    std::println("General template");
  }
};

template <>
struct Foo<int> {
  static void display() {
    std::println("Specialized for int");
  }
};

The C++ compiler knows that you are specifying a specialization when you use empty <>. If you want to be really fancy, you might use it for your vector class like so:

template <int_or_float T, typename Derived> struct IVector {
  T x{};
  T y{};

  auto operator+(this const IVector &self, const Derived &other) noexcept
      -> Derived {
    return Derived{self.x + other.x, self.y + other.y};
  }
};

template <typename T> struct Vector2 : IVector<T, Vector2<T>> {
  using Base = IVector<T, Vector2<T>>;
  using Base::Base;
}; // AGGREGATE

template <typename T> struct Vector3 : IVector<T, Vector3<T>> {
  T z{};

  auto operator+(this const Vector3 &self, const Vector3 &other) noexcept
      -> Vector3 {
    auto result = IVector<T, Vector3>::operator+(other);
    result.z = self.z + other.z;
    return result;
  }
}; // AGGREGATE

template <typename T> struct Vector4 : IVector<T, Vector4<T>> {
  T z{};
  T w{};

  auto operator+(this const Vector4 &self, const Vector4 &other) noexcept
      -> Vector4 {
    auto result = IVector<T, Vector4>::operator+(other);
    result.w = self.w + other.w;
    return result;
  }
}; // AGGREGATE

Since inheritance (which is a runtime mechanism) and templates (which are a compile-time mechanism) don’t go well together, we have applied CRTP. But a problem still remains: how do we ensure that the user of our user supplies correct types? In our case, the ones that make sense are integers and floats.

This is where concepts come in. For the basic types like int, unsigned int, and so on, the C++ standard groups them under one general name called integral types.

The exact implementation of the things that we will discuss ultimately depend on the C++ implementation by the compiler, but we will use some basic patterns that are common across all of them. Conceptually, std::is_integral, which is a utility for determining whether a type is one of the integrals, is defined like so:

template <typename T>
struct is_integral : std::false_type { };

template <>
struct is_integral<bool> : std::true_type { };
template <>
struct is_integral<char> : std::true_type { };
template <>
struct is_integral<signed char> : std::true_type { };

Here, std::false_type is what is known as a type trait. Type traits are compile-time tools that allow you to ask questions about a particular type. Most type traits are implemented with a static boolean member value, which is precisely what std::false_type does:

struct false_type {
    static constexpr bool value = false;
    using type = false_type;
};

You can probably guess the implementation of std::true_type based on this as well. The idea is that if we make specializations inherit std::true_type instead, the base value becomes true, which allows the compiler to do some type checking we will see just in a second.

Any type that inherits from a type trait is also a type trait. Consequently, std::is_integral is a type trait.

The value of a type trait is often abbreviated with _v prefixes in the standard library:

template <typename T>
inline constexpr bool is_integral_v = is_integral<T>::value;

Now, where do concepts come in play? Concepts allow constraining template parameters with semantic rules defined by type traits. That means if we define:

template<typename T>
concept integral = std::is_integral_v<T>;

And use the concept in place of our typename:

template <int_or_float T, typename Derived> struct IVector {
  T x{};
  T y{};

  auto operator+(this const IVector &self, const Derived &other) noexcept
      -> Derived {
    return Derived{self.x + other.x, self.y + other.y};
  }
};

The compiler, fundamanentally speaking, will evaluate the boolean condition at compile-time. If it is false, you will get a compilation error. We can make the rest of our vector implementation use this concept:

template <int_or_float T, typename Derived> struct IVector {
  T x{};
  T y{};

  auto operator+(this const IVector &self, const Derived &other) noexcept
      -> Derived {
    return Derived{self.x + other.x, self.y + other.y};
  }
};

template <int_or_float T> struct Vector2 : IVector<T, Vector2<T>> {
  using Base = IVector<T, Vector2<T>>;
  using Base::Base;
}; // AGGREGATE

template <int_or_float T> struct Vector3 : IVector<T, Vector3<T>> {
  T z{};

  auto operator+(this const Vector3 &self, const Vector3 &other) noexcept
      -> Vector3 {
    auto result = IVector<T, Vector3>::operator+(other);
    result.z = self.z + other.z;
    return result;
  }
}; // AGGREGATE

template <int_or_float T> struct Vector4 : IVector<T, Vector4<T>> {
  T z{};
  T w{};

  auto operator+(this const Vector4 &self, const Vector4 &other) noexcept
      -> Vector4 {
    auto result = IVector<T, Vector4>::operator+(other);
    result.w = self.w + other.w;
    return result;
  }
}; // AGGREGATE

You will now notice that, in the following code:

#include <stdfloat>

#include "vector.hpp"

int main() {
  // this is OK
  auto a{Vector4<std::float32_t>{0.5f32, 0.5f32, 1.0f32, 1.0f32}};

  // this fails to compile because the constraints are not satisfied
  auto b{Vector4<std::string>{"A", "B", "B", "C"}};
  
  return 0;
}

So concepts let us have these compile-time guarantees that were impossible before C++20.

Now that you understand how they work and how to use them, enjoy applying them to your codebase (if you don’t have a 32 year old monolith stuck in C++11). Below is a small list of them.

Enjoyed reading? Subscribe to my newsletter.

Quarterstar

Quarterstar

Machine learning researcher with special interests in 3D graphics, privacy enhancement, low-level programming, and mathematics.