Click here to Skip to main content
15,867,594 members
Articles / Programming Languages / C++20

An Introduction to C++ Concepts for Template Specialization

Rate me:
Please Sign up or sign in to vote.
5.00/5 (20 votes)
8 Sep 2022MIT10 min read 19.7K   19   9
How concepts can be used in template programming for partial specialization
C++ template programming is a feature that allows the programmer to write generic, type independent code for which the type will be filled in at compile time. Using concepts, a programmer can define requirements and constraints for template types. As a result, it becomes much easier to specialize or restrict templates based on type information. And if those restrictions are violated, there will be a clean compiler error.

Introduction

This article explains template specialization and partial template specialization in C++, using various language features including the latest C++20 additions to C++ language.

The article starts with an introduction to C++ templates and specialization. Then, it presents some of the approaches that were used before C++20. And finally, it shows how concepts can be used to do it all in a much cleaner and more readable fashion.

Note that for using these language features, you have to update your project settings -> C++ -> language-> to C++20.

Background

C++ template programming is a feature that allows the programmer to write generic, type independent code for which the type will be filled in at compile time. Template programming is unique to C++ and meant to simplify the implementation of similar code for different types, as well simplify how that code is used in the larger codebase.

Introduction to Templates

This section is a quick introduction for people who are new to templates or who haven't programmed their own templates before. Feel free to skip it if this is old news to you.

Template programming can be used for classes and functions. For the sake of simplicity, I demonstrate my article with a simple template function.

C++
template<typename T>
void foo(T t) {
    cout << "Generic foo for " << typeid(t).name() << endl;
}

foo(1.23);    //prints: Generic foo for double
foo(true);    //prints: Generic foo for bool
foo(42);      //prints: Generic foo for int 

This is what we expect for a generic template. At compile time, the compiler creates the code for three different functions, each with a different parameter list.

Explicit Specialization

A template function can be very powerful because you get type-correct code without having to program different functions for each. And this works well if the body of the function can be executed for all types. You can for example execute the '+' operator for ints and floats and std:string, but not for int* or char*.

Given that types can have different behavioral patterns or traits, you may want to have a generic function for most cases, and be able to tell the compiler 'Use the generic function except in this case where I want you to use another specific function'. This is called template specialization.

C++
template<typename T>
void foo(T t) {
    cout << "Generic foo for " << typeid(t).name() << endl;
}

template<>
void foo<int>(int t) {
    cout << "Specific foo<int> for " << typeid(t).name() << endl;
}

foo(1.23);    //prints: Generic foo for double
foo(true);    //prints: Generic foo for bool
foo(42);      //prints: Specific foo<int> for int 

The empty type list and the explicit qualification of foo tell the compiler that when it is compiling foo(42), it should use foo<int> which is more specific than the generic foo.

Template Overloading

Like normal functions, template functions can be overloaded. And this can be useful if you want to implement different behavior while retaining some 'genericness'. Consider this:

C++
template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<typename T>
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

template<typename T>
void foo(vector<T> &t) {
    cout << "foo(vector<T> &t) for " << typeid(t).name() << endl;
}

foo(42);                //prints: Specific foo<int>(int t) for int
foo((void*)NULL);       //prints: Specific foo(T* t) for void * __ptr64
vector<int> v;
foo(v);
foo(v);                 //prints: foo(vector<T> &t) for 
                        //class std::vector<int,class std::allocator<int> > 

There are three overloads for the template function. The compiler has multiple options:

  1. If we present a vector of anything, use foo(vector<T> &t).
  2. If we present any pointer type, use foo(T* t).
  3. If none of the above applies, use foo(T t).

The compiler will try to match the best candidate for foo and use that one.

Mixing Overloads and Specializations

You can mix overloads and specializations, and they appear very similar but there are some differences. Consider this code:

C++
template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<>
void foo<int>(int t) {
    cout << "Specific foo for " << typeid(t).name() << endl;
}

template<typename T>
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

foo(42);                //prints: Specific foo<int>(int t) for int
foo((void*)NULL);       //prints: Specific foo(T* t) for void * __ptr64 

There are two overloads for the template function, and one specialization. You can use them interchangeably in this case because there are no overlaps in which one supports which type. When there are multiple candidates, there can be surprising results.

C++
template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<typename T>       //overload of foo(T t)
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

template<>
void foo<int*>(int* t) {   //specialization of foo(T)
    cout << "Specific foo<int*>(int *t) for " << typeid(t).name() << endl;
}

foo((int*)NULL);           //prints: Specific foo(T* t) for int * __ptr64 

We have one overload and one specialization which are both valid for the int* type. The compiler still picks the overload, not the specialization. The reason is the compiler does overload resolution before specialization. foo(T* t) is a better candidate than foo(T t) so that's the template it is going with. foo(T* t) doesn't have specializations, which means the more generic function is chosen.

Now let's add a specialization for foo(T* t) instead and see what happens.

C++
template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<typename T>       //overload of foo(T t)
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

template<>
void foo<int*>(int* t) {   //specialization of foo(T t)
    cout << "Specific foo<int*>(int *t) for " << typeid(t).name() << endl;
}

template<>
void foo<int>(int* t) {    //specialization of foo(T* y )
    cout << "Specific foo<int>(int *t) for " << typeid(t).name() << endl;
}

foo((int*)NULL);           //prints: Specific foo<int>(int *t) for int * __ptr64 

Now the second specialization is chosen because foo(T* t) is still a better candidate than foo(T t). And foo(T* t) has foo<int>(int* t) as specialization, so it is further specialized.

Limitations

The baseline template specialization and overloading functionality is very powerful, but rather limited. It's not possible to make more complex rules such as 'This overload / specialization should be used for all classes that have a specific base class' or 'This overload / specialization should be used for floating point types'.

Implementing Complex Rules via SFINAE

When the compiler is evaluating the template type substitutions, arguments can be supplied that cannot be used and generate an error. Instead of generating a compiler error and stopping compilation, the compiler simply discards that template, which can in turn guide template selection. This principle is called 'Substitution Failure Is Not An Error' or SFINAE.

The overall idea is that substitution failure can be converted to boolean logic and to choose overloads of specialization. It is not my intention to cover SFINAE in depth. Pre C++11, the standard features were limited in how they could be used for this, and pretty verbose. From C++11 onwards, there were more options. In particular, the enable_if function provided a clean way to express a requirement. Combined with compiler support, you could do some powerful things without needing to subject your brain to torture.

For example: the following code can be used to specialize a template for classes that derive from a given base class.

C++
class Base {};
class Derived : public Base {};

template<
    typename T,
    std::enable_if_t<std::is_base_of_v<Base, T>, bool> Dummy = true>
void Bar(T t) {
    cout << "Bar for T derived from Base" << typeid(t).name() << endl;
};

template<
    typename T,
    std::enable_if_t< not std::is_base_of_v<Base, T>, bool> Dummy = true>
void Bar(T t) {
    cout << "Bar for T not derived from Base " << typeid(t).name() << endl;
};

Derived der;
Bar(der);
Bar(123) 

We have two templates. If we compile for a class that derives from Base, std::enable_if_t<std::is_base_of_v<Base, T>, bool> will substitute to type bool which can be assigned true, which is a valid template.

On the other hand, std::enable_if_t< not std::is_base_of_v<Base, T>, bool> will not substitute to type bool, so it cannot be assigned true, which causes the compiler to disqualify it.

The key is these two definitions are mutually exclusive. When compiling Bar(der) or Bar(123), the compiler will pick the one template that is valid for that statement. You can implement more options than two, as long as they are mutually exclusive.

That's as much as I want to say about SFINAE. It is certainly powerful, but it is verbose, and can be hard to read and understand. C++14 and C++17 improved things, allowing your to cut back on verbosity, but they retained a high degree of needing to implement very visible and artificial hoops for the compiler to jump through, just to have it land in a specific place.

Concepts

Concepts are the answer to programmer demand for a mechanism that allows us to define constraints and requirements for the types that can be used in templates.

Implementing a Single Requirement

Let's start with an easy example. Suppose we are implementing a function that should only be used with variables that are not pointers. We can do that like this:

C++
template<typename T>
concept NonPointer = not is_pointer_v<T>;

template<NonPointer T>
void Baz(T &t) {
    cout << "Baz for NonPointer T: " << typeid(t).name() << endl;
}

int main()
{
    int i = 123;
    int* ip = &i;
    Baz(i);             //Prints: Baz for NonPointer T: int
    Baz(ip);            //C7602 'Baz': the associated constraints are not satisfied  
}

The concept itself has a name and is declared as an expression that can be evaluated at compile time. After declaring the concept, we can use it instead of the previous 'typename' specifier. In effect, it serves as a typename specifier with the type requirements baked in as prerequisite. We no longer need to perform SFINAE black magic.

If we use Baz with a data type, it compiles as expected. If we supply a pointer type, it generates a clean compiler error telling you exactly what is wrong. It's so trivial that it's almost like cheating.

Implementing Multiple Requirements

Now let's make the example more interesting: we only want to allow Baz for types which are not pointers, and which are fundamental types such as int, bool, double, ...

C++
template<typename T>
concept NonPointer = not is_pointer_v<T>;

template<typename T>
concept Fundamental = is_fundamental_v<T>;

template<NonPointer T> requires Fundamental<T>
void Baz(T &t) {
    cout << "Baz for Fundamental NonPointer T: " << typeid(t).name() << endl;
}

int main()
{
    int i = 123;
    string s(L"123");
    Baz(i);             //Prints: Baz for Fundamental NonPointer T: int
    Baz(s);             //C7602 'Baz': the associated constraints are not satisfied  
}

As you can see, aside from using a concept to indicate template type requirements, we can use them to define additional constraints while we define the template function itself. Of course, the following is also equivalent and valid:

C++
template<typename T>
concept FundamentalNonPointer = is_fundamental_v<T> && not is_pointer_v<T>;

template<FundamentalNonPointer T>
void Baz(T &t) {
    cout << "Baz for Fundamental NonPointer T: " << typeid(t).name() << endl;
}

As you can see, there can be multiple ways to do the same thing. Which one you choose will depend on what other things you are trying to do.

Check for a Base Class

Let's do another one and add a specific version of Baz which works on types like string and wstring; basically everything that derives from basic_string<T>.

C++
template<typename T>
concept FundamentalNonPointer = is_fundamental_v<T> && not is_pointer_v<T>;

template<FundamentalNonPointer T>
void Baz(T &t) {
    cout << "Baz for Fundamental NonPointer T: " << typeid(t).name() << endl;
}

template<typename T>
concept BasicStringDerived = derived_from<T, basic_string<typename T::value_type>>;

template<BasicStringDerived T>
void Baz(T& t) {
    cout << "Baz for BasicStringDerived T: " << typeid(t).name() << endl;
}

int main()
{
    int i = 123;
    string s(L"123");
    Baz(i);          //Prints: Baz for Fundamental NonPointer T: int
    Baz(s);          //Prints: Baz for BasicStringDerived T: class std::basic_string ...
}

We simply define a concept BasicStringDerived which evaluates whether T derives from basic_string. If you look closely, you see that we use a bit of SFINAE here. There are three possibilities:

  1. T derives from basic_string, it must have a typedef named value_type which is the character type for characters in the derived string. this means the compiler can actually evaluate whether derived_from<T, basic_string<typename T::value_type> is true or not.
  2. T does not derive from basic_string. Which in all likelihood means there is no typedef named value_type. This means the compiler encounters an error already when trying to evaluate derived_from<T, basic_string<typename T::value_type>, which in turn means it will disregard that template possibility altogether. This is SFINAE.
  3. T does not derive from basic_string but for whatever reason there is a value_type typedef. This is not an issue because the compiler will actually be able to evaluate derived_from<T, basic_string<typename T::value_type>, and evaluate it to be false. This will also remove the template for consideration.

Check for Capabilities

So far, we've used concepts to consider only type identity. Suppose we want to ask instead: Is this type capable of doing this or that. Let's illustrate with a practical example. We just implemented a template constraint to check whether a type T derives from basic_string. Now suppose that for whatever reason, we need to consider all the types that have a T::c_str() member. Typically, this would mean it derives from basic_string but perhaps we are also dealing with the existence of additional classes that implement a c_str() method.

C++
template<typename T>
concept HasCStr = requires (T t) { {t.c_str()}; };

template<HasCStr T>
void Baz(T& t) {
    cout << "Baz for HasCStr T: " << typeid(t).name() << endl;
}

int main()
{
    string s(L"123");
    Baz(s);             //Prints: Baz for HasCStr T: class std::basic_string ...
}

Here, our concept has a requirements section with a parameter list and a body. The body itself is not executed. The real test is whether it compiles or not. If it compiles, the requirement is met. If it doesn't compile because T doesn't have a c_str() method, the requirement is not met.

We can chain several requirements together. In our example, conceivably we don't just want the c_str() method to exist, but we want to require that c_str() returns a pointer.

C++
template<typename T>
concept HasCStr = requires (T t) { {t.c_str()} -> std::convertible_to<const void*>; };

This is just one way we could implement such a check. Because the requirement check really boils down to 'does this statement compile or not', there are any number of ways we could implement this:

C++
template<typename T>
concept HasCStr = requires (T t) { { *(t.c_str()) }; };  //can we dereference 
                                                         //what comes out of c_Str()?
C++
template<typename T>
concept HasCStr = requires (T t) 
{ { static_cast<const void*>(t.c_str()) }; }; //can we cast the result to const void*

Now suppose that we not only want to check if c_str() returns a pointer but we also want to ensure that T has a value_type typedef so that Baz can use type information. Here too, we can implement this in a couple of ways.

C++
template<typename T>
concept HasCStr = requires (T t) 
{ { t.c_str() } -> same_as<const typename T::value_type*>; };
C++
template<typename T>
concept HasCStr = requires (T t) { { *(t.c_str()) }; typename T::value_type; };

These two are similar but not identical. The first example checks whether t.c_str() can be evaluated and the return type is a pointer to T::value_type. The second example checks whether t.c_str() can be evaluated and its result dereferenced, and whether T has a value_type typedef. A hypothetical implementation that doesn't derive from basic_string might have a hypothetical c_str() that returns a void* and which would also have a value_type typedef.

Such an implementation would fail both these implementations. It would fail the first one because the result of c_str() is not a value_type*. And it would fail the second one because we cannot dereference a void*. However, if we need to allow for such a contrived and hypothetical implementation, we could catch it like this:

C++
template<typename T>
concept HasCStr = requires (T t) { {t.c_str()} -> std::convertible_to<const void*>; 
                  typename T::value_type;};

Points of Interest

This article does not cover every possible use of the template concept language feature. That would take us way too far. For exact details, you can consult the C++20 standard itself, or consult a reference site such as www.cppreference.com.

Instead, with this article, I hope to have presented an introduction to this language feature, and to have demonstrated that concepts are not just syntactic sugar, but a way to cleanly define requirements for type parameters in various ways and circumstances.

History

  • 9th September, 2022: Initial version
  • 9th September, 2022: Fixed typo
  • 10th September, 2022: changed std::convertible_to<void*> to std::convertible_to<const void*>

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Software Developer
Belgium Belgium
I am a former professional software developer (now a system admin) with an interest in everything that is about making hardware work. In the course of my work, I have programmed device drivers and services on Windows and linux.

I have written firmware for embedded devices in C and assembly language, and have designed and implemented real-time applications for testing of satellite payload equipment.

Generally, finding out how to interface hardware with software is my hobby and job.

Comments and Discussions

 
PraiseNice article Pin
adanteny12-Sep-22 3:24
adanteny12-Sep-22 3:24 
QuestionInteresting articles Pin
Xavier Jouvenot11-Sep-22 7:52
Xavier Jouvenot11-Sep-22 7:52 
AnswerRe: Interesting articles Pin
Bruno van Dooren11-Sep-22 21:22
mvaBruno van Dooren11-Sep-22 21:22 
Praiseconst Pin
Member 124079059-Sep-22 15:02
Member 124079059-Sep-22 15:02 
GeneralRe: const Pin
Bruno van Dooren9-Sep-22 22:43
mvaBruno van Dooren9-Sep-22 22:43 
GeneralMy vote of 5 Pin
tugrulGtx9-Sep-22 4:18
tugrulGtx9-Sep-22 4:18 
Liked this.
BugSpelling Errors Pin
Rick York8-Sep-22 13:29
mveRick York8-Sep-22 13:29 
GeneralRe: Spelling Errors Pin
Bruno van Dooren8-Sep-22 19:13
mvaBruno van Dooren8-Sep-22 19:13 
GeneralMy vote of 5 Pin
Greg Utas8-Sep-22 12:34
professionalGreg Utas8-Sep-22 12:34 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.