Concepts versus SFINAE-based constraints

In some situations, we need to makes sure function templates can only be invoked with some specific types. SFINAE (that stands for Substitution Failure Is Not An Error) is a set of rules that specify how compilers can discard specializations from the overload resolution without causing errors. A way to achieve this is with the help of std::enable_if.

Let us look at an example. Suppose we want to write a function template called product() that returns the product of its two arguments. We only want to be able to call it with arithmetic types. Using std::enable_if we can define such a function as follows:

template <typename T,
          typename = typename std::enable_if_t<std::is_arithmetic_v<T>>>
T product(T const t1, T const t2)
{
   return t1 * t2;
}

We can use it to multiply integers or doubles for example, even booleans (bools can be converted to integers, with true becoming 1 and false becoming 0), but not other types, such as std::string.

using namespace std::string_literals;

auto v1 = product(3, 4);
auto v2 = product(13.4, 2.55);
auto v3 = product(false, true);
auto v4 = product("one"s, "two"s);  // error

The last line above would produce the following compiler error when compiling with Visual Studio:

error C2672: 'product': no matching overloaded function found
error C2783: 'T product(const T,const T)': could not deduce template argument for '<unnamed-symbol>'

SFINAE-based constraints are not the most intuitive code to read. Eventually, they model concepts with template trickery. But C++20 provides concepts as a first-class experience to make metaprogramming simpler and more expressive both to the compiler and developers. So let us look at how we can do the same and better with concepts.

We can start by providing concepts for numerical types (a type that is either integral, or floating-point). You can see these below. Notice that the standard library provides two concepts called std::integral and std::floating_point in the header <concepts>. The implementation below is identical to the standard one.

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

template <typename T>
concept floating_point = std::is_floating_point_v<T>;

template <typename T>
concept numeric = integral<T> || floating_point<T>;

Having this numeric concept available, we can change the definition of the product() function to the following:

template <numeric T>
T product(T const t1, T const t2)
{
   return t1 * t2;
}

Compiling the lines above would again produce an error for the last invocation of product() using std::string arguments. This time, the errors yield by the Visual Studio compiler are as follows:

error C2672: 'product': no matching overloaded function found
error C7602: 'product': the associated constraints are not satisfied

But what if we want to extend the function template product() so that it works for every type for which the operator* is overloaded? That’s difficult to do with SFINAE but rather straight-forward with concepts. The only thing we need to do is define a concept that expresses that. Below, this concept is called multiplicative.

template<typename T>
concept multiplicative = requires(const T a, const T b)
{
    { a * b }->T;
};

The changes to the definition of product() are minimal: we just replace numeric with multiplicative.

template <multiplicative T>
T product(T const t1, T const t2)
{
   return t1 * t2;
}

So what can we do to make product("one"s, "two"s) compile? We can overload operator* for std::string. The following is an implementation that “zips” two strings together. The product of “abc” and “xywz” is “axbycwz”. The actual implementation is not important; this is provided just for the sake of making the example produce some actual values.

std::string operator*(std::string const& s1, std::string const& s2)
{
   std::string result(s1.length() + s2.length(), '\0');
   size_t i = 0;
   size_t j = 0;
   while(i < s1.length() && i < s2.length())
   {
      result[j++] = s1[i];
      result[j++] = s2[i];
      i++;
   }
   
   for (size_t k = i; k < s1.length(); ++k)
      result[j++] = s1[k];

   for (size_t k = i; k < s2.length(); ++k)
      result[j++] = s2[k];

   return result;
}

With this available the code we’ve seen above compiles without errors.

using namespace std::string_literals;

auto v1 = product(3, 4);
auto v2 = product(13.4, 2.55);
auto v3 = product(false, true);
auto v4 = product("one"s, "two"s);

And that’s how simple concepts can make the code. More about the advantages of concepts can be found here: Why I want Concepts, and why I want them sooner rather than later.

See also on this topic:

1 Reply to “Concepts versus SFINAE-based constraints”

  1. Heads up that the return value “T” in “{ a * b }->T” (in “concept multiplicative”) is no longer legal. You’ll normally (usually) want to change it to either “std::same_as” or “std::convertible_to”. See here for a high-level explanation (search page for “Requirements on return types”)

    https://www.sandordargo.com/blog/2021/03/17/write-your-own-cpp-concepts-part-ii

    Or here for the deeper explanation:

    http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1452r2.html

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.