A buggy adventure: Non-type template parameters

By .
Posted at 26 December 2022, 5:54.

Templates are a powerful tool in the toolbox of C++ to define families of types and families of functions in terms of generic implementations in which details can be plugged in via template parameters. Typical examples of the usage of templates include the collection template types in the C++ standard library. Take, for example, the class template definition

template<class ValueT, std::size_t N>
class std::array;

This class template is provided by the <array> header and defines a generic implementation of a fixed-length array of N values of type ValueT. In this array template type, the template parameter ValueT is a template type parameter that can be filled in by a type at compile time, and the template parameter N is a non-type template parameter that can be filled in by a value at compile time. To use this template type, we instantiate it. For example, array<int, 5> defines an array-like type that holds five integers, while array<std::string, 10> defines an array-like type that can hold ten strings.

Historically, non-type template parameters where limited mainly to integral types (such as std::size_t above) and to certain types of pointers and references. The recent C++20 standard lifted many of these limitations: since C++20, one can freely use floating-point types and class types (as long as they are structural). One intended use-case of such class types are to pass string literals to templates.

Running into an issue

Recently, I ran into an issue with code that used non-type template parameters. Further investigation showed several inconsistencies in which the three major C++ compilers handled non-type template parameters. The first issues I ran into reduced to the following minimal example:

The copy_counter example.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */
#include <iostream>
#include <functional>


/*
 * A function object that returns the input.
 */
constexpr static inline std::identity id = {};


/*
 * A structural class type that keeps track of copies: if a value of this type
 * with copy count @{copy_count} is copied, then the resulting copy will have a
 * copy count of @{copy_count + 1}.
 */
struct copy_counter
{
    /* The copy count. */
    unsigned copy_count;


    /*
     * Create a copy counter: start at zero.
     */
    constexpr copy_counter() noexcept : copy_count{0u} {}

    /*
     * The copy constructor: increases the count by one.
     */
    constexpr copy_counter(const copy_counter& other) noexcept :
            copy_count(other.copy_count + 1u) {}
};


/*
 * A template that takes a copy counter @{C} as a non-type template parameter
 * and prints it to standard output.
 */
template<copy_counter C>
void print_nttp()
{
    std::cout << C.copy_count << std::endl;
}


/*
 * A template that takes a copy counter @{C} as a non-type template parameter
 * and passes it to the above print function in a few different ways.
 */
template<copy_counter C>
void test_cases()
{
    print_nttp<C>();
    print_nttp<(C)>();
    print_nttp<(C, C)>();
    print_nttp<id(C)>();
    print_nttp<copy_counter{C}>();
}


/*
 * Entry-point of the program.
 */
int main()
{
    test_cases<copy_counter{}>();
}

The following table shows the output of compiling and running this program using the three major C++ compilers. I provide the results of the compilers I currently use. These are:

  1. Microsoft (R) C/C++ Optimizing Compiler Version 19.34.31937 for x86 (part of Visual Studio 2022, v17.4.3);
  2. Clang version 15.0.1 with target x86_64-pc-windows-msvc (as provided by Visual Studio 2022, v17.4.3); and
  3. GCC g++ version 11.2.0 (as part of Cygwin64).

To rule out issues with my local installation, I have also verified the output of all programs in this article using the most-recent versions of these compilers on the Compiler Explorer, namely the compilers labeled x64 MSVC v19.33, x86-64 Clang 15.0.0, and x86-64 gcc 12.2. In addition, I have tested each program using the necessary flags to compile according the C++20 standard and the latest standard supported (/std:c++latest for Microsoft C++, -std=c++2b for Clang, and -std=c++23 for GCC g++).

Compiler
Microsoft C++ClangGCC g++
Function(19.34.31937)(15.0.1)(11.2.0)
C 0 0 1
(C) 0 0 1
(C, C) 0 1 1
id(C) 0 1 1
copy_counter{C} 1 1 1
The output of compiling and running the above copy_counter example with the three major C++ compilers.

One would expect that the above copy_counter would yield the same result when compiled with any compiler that supports the use of structural classes as the types of non-type template parameters. More worrisome, it seems that the Clang compiler breaks the core idea of a template: print_nttp<e_1> and print_nttp<e_2> should always yield the same results if expressions e_1 and e_2 evaluate to identical values.

Dissecting the issue of copy_counter

Faced with the above result, there are only a few possible explanations (from most-likely to least-likely):
  1. our code is faulty and does not strictly adhere to the rules of the C++ standard, thereby triggering compiler-specific behavior;
  2. some compilers have bugs in how they handle non-type template parameters; or
  3. the C++20 standard does not clearly define how these non-type template parameters should be handled.

The above list is ordered on increasing unlikeliness. When encountering a bug, it is very likely that the cause of this bug can be traced back to your own code: C++ compilers have a huge amount of freedom when compiling buggy code, due to which highly-optimized compiler output can behave rather unpredictably. The presence of bugs in the above code can easily be ruled out, however: copy_counter is a proper structural class type that follows all conditions the C++20 standard places on them. We refer to cppreference.com for details or, if you are interested, to Clause 7 of Section [temp.param] of the standard (the C++ standard draft sources are available via GitHub, the applicable parts of the standard have not seen change between C++20 and the current draft). Besides the usage of copy_counter as a non-type template parameter, the remainder of the program is straightforward.

For now, we assume the C++ standard unambiguously defines the outcome of copy_counter. Under this assumption, at-least two compilers are buggy, as none of the compilers agree on what the program should do. The outcomes of the Microsoft C++ compiler and the GCC g++ compiler can be explained using conflicting semantics for passing non-type template parameter arguments:

  1. The Microsoft C++ compiler seems to use a physical copy semantics in which the physical representation of non-type template parameter arguments are copied to their usage within the template (as-if the binary representation of the argument is copied via std::memcpy).
  2. The GCC g++ compiler seems to use a logical copy semantics in which a logical copy is performed, in this case using a copy constructor, to copy non-type template parameter arguments to their usage within the template.

The Clang compiler seems to use the logical copy semantics for all expressions, unless the expression is a direct usage of a non-type template parameter without further operations (in which case a physical copy is used).

Note that the physical copy and logical copy semantics yield identical results for integral types and the other possible types of non-type template parameters that one could use before C++20.

To determine which of the above semantics is correct (if any), we will have to determine whether the C++ standard unambiguously defines what the outcome of the copy_counter program should be. We will base our analysis of the C++20 standard on the C++ standard draft sources available via GitHub (tag c++20-pre-iso-31-ge52247e8). To refer to specific Sections in the C++20 standard, we shall write Section [x], in which [x] is the section name as used in the standard. In addition, we can also recommend the main papers (P0732R1 and P1907R1) that preceded the changes in C++20 with regards to the non-type template parameters we look at here. The first relevant clause is Clause 8 of Section [temp.param]:

An id-expression naming a non-type template-parameter of class type T denotes a static storage duration object of type const T known as a template parameter object, whose value is that of the corresponding template argument after it has been converted to the type of the template-parameter. ... If an id-expression names a non-type non-reference template-parameter then it is a prvalue if it has non-class type. Otherwise, if it is of class type T it is an lvalue and has type const T.

We have highlighted the crucial parts: whatever is provided as a non-type template parameter argument of type copy_counter is converted to the type copy_counter and the value resulting from this conversion is available within the template as an lvalue object of type const copy_counter.

Next, we need to determine what happens when we convert the result of an expression such as C, (C, C), or id(C) to a copy_counter. Notice that these expressions do not result in copy_counter values: they result in references to the value C. Fortunately, there is an easy way to determine what the conversion to a copy_counter will produce: according to Clause 1 of Section [expr.static.ast], that is exactly what a static_cast produces:

The result of the expression static_cast<T>(v) is the result of converting the expression v to type T. ...

In this case, static_cast will perform a conversion and this conversion is performed via the copy-construction of a copy_counter object. Hence, the C++20 standard points to the logical copy semantics of the GCC g++ compiler as the correct semantics. Using static_cast, we can rewrite the copy_counter program to also generate the expected outcome according to the C++ standard.

The copy_counter example with expected outcomes.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */
#include <iostream>
#include <functional>


/*
 * A function object that returns the input.
 */
constexpr static inline std::identity id = {};


/*
 * A structural class type that keeps track of copies: if a value of this type
 * with copy count @{copy_count} is copied, then the resulting copy will have a
 * copy count of @{copy_count + 1}.
 */
struct copy_counter
{
    /* The copy count. */
    unsigned copy_count;


    /*
     * Create a copy counter: start at zero by default.
     */
    constexpr copy_counter(unsigned init = 0u) noexcept : copy_count{init} {}

    /*
     * The copy constructor: increases the count by one.
     */
    constexpr copy_counter(const copy_counter& other) noexcept :
            copy_count(other.copy_count + 1u) {}
};


/*
 * A template that takes a copy counter @{C} as a non-type template parameter
 * and prints it to standard output. In addition, print the expected value
 * provided by @{expected}.
 */
template<copy_counter C>
void print_nttp(const auto& expected)
{
    std::cout << C.copy_count
              << " (expected: " << expected.copy_count << ")" << std::endl;
}


/*
 * A template that takes a copy counter @{C} as a non-type template parameter
 * and passes it to the above print function in a few different ways.
 */
template<copy_counter C>
void test_cases()
{
    constexpr const copy_counter D = {C.copy_count};
    static_assert(C.copy_count == D.copy_count);

    print_nttp<C>(static_cast<copy_counter>(C));
    print_nttp<D>(static_cast<copy_counter>(D));
    print_nttp<(C)>(static_cast<copy_counter>((C)));
    print_nttp<(C, C)>(static_cast<copy_counter>((C, C)));
    print_nttp<id(C)>(static_cast<copy_counter>(id(C)));
    print_nttp<copy_counter{C}>(static_cast<copy_counter>(copy_counter{C}));
}


/*
 * Entry-point of the program.
 */
int main()
{
    test_cases<copy_counter{}>();
}

The following table provides the outcome of this program. We have not only included example outcomes in the above program, but also included an additional case to test the worrisome behavior of Clang: this case uses an explicit variable D that, for all intents and purposes, should behave identical as C according to the standard we cited earlier (D is defined as a const copy_counter and used as an lvalue).

Compiler
Microsoft C++ClangGCC g++
Function(19.34.31937)(15.0.1)(11.2.0)(expected)
C 0 0 1 1
D 0 1 1 1
(C) 0 0 1 1
(C, C) 0 1 1 1
id(C) 0 1 1 1
copy_counter{C} 1 1 1 1
The output of compiling and running the above copy_counter example with the three major C++ compilers, now including the expected outcomes. All three compilers produce the same expected outcomes (last column), even though they differ on the actual outcomes.

Hence, both the Microsoft C++ compiler and the Clang compiler are wrong, as they do not handle non-type template parameters according to the standard.

Running into other issues

While further dissecting the behavior observed in the previous section, I ran into several other inconsistencies in how the major C++ compilers deal with non-type template parameters. We already observed earlier that the Clang compiler seems to interpret the expression C different from any other expressions that yield the same value. To figure out what was going on, I wrote a program that figures out which types are associated with each variable in use:

The inspect_template_argument example.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */
#include <iostream>
#include <typeinfo>
#include <type_traits>


/*
 * Format the type @{Type} and print it to standard output. The qualifiers
 * @{qs...} are used to include further details on @{Type}.
 */
template<class Type>
void type_printer(const auto&... qs)
{
    if constexpr (std::is_lvalue_reference_v<Type>) {
        return type_printer<std::remove_reference_t<Type>>(qs..., "&");
    }
    if constexpr (std::is_rvalue_reference_v<Type>) {
        return type_printer<std::remove_reference_t<Type>>(qs..., "&&");
    }
    if constexpr (std::is_const_v<Type>) {
        return type_printer<std::remove_const_t<Type>>(qs..., "const");
    }
    if constexpr (std::is_volatile_v<Type>) {
        return type_printer<std::remove_volatile_t<Type>>(qs..., "volatile");
    }
    if constexpr (std::is_array_v<Type>) {
        return type_printer<std::remove_extent_t<Type>>(qs..., "[]");
    }
    if constexpr (std::is_pointer_v<Type>) {
        return type_printer<std::remove_pointer_t<Type>>(qs..., "*");
    }
    ((std::cout << qs << " "), ...);
    std::cout << '`' << typeid(Type).name() << '`' << std::endl;
}


/*
 * Print the details on the type of @{arg} (which reflects the type of any
 * expression passed to this function). The printed data will be labeled with
 * @{case_name}.
 */
void inspect_function_argument(auto&& arg, const auto& case_name)
{
    type_printer<decltype(arg)>(case_name);
}


/*
 * Print the details on the type @{Type}, the type of a non-type template
 * parameter with value @{Value}, and on the type of expressions involving
 * @{Value}. The printed data will be labeled with @{case_name}.
 */
template<class Type, Type Value = Type{}>
void inspect_template_argument(const auto& case_name)
{
    type_printer<Type>(case_name);
    type_printer<decltype(Value)>(case_name);
    type_printer<decltype((Value))>(case_name);
    inspect_function_argument(Value, case_name);

    Type w = Value;
    type_printer<decltype(w)>(case_name);
    type_printer<decltype((w))>(case_name);
    inspect_function_argument(w, case_name);

    const Type x = Value;
    type_printer<decltype(x)>(case_name);
    type_printer<decltype((x))>(case_name);
    inspect_function_argument(x, case_name);
}


/*
 * A dummy struct type.
 */
struct dummy {};


/*
 * A static object with linkage, non-type template parameters can point or
 * reference such objects.
 */
static int static_i = 0;


/*
 * Entry-point of the program.
 */
int main()
{
    /* We look at most combinations of types and values. */
    inspect_template_argument<int>("{int}");
    inspect_template_argument<const int>("{c-int}");
    inspect_template_argument<int&, static_i>("{int ref}");
    inspect_template_argument<const int&, static_i>("{c-int ref}");
    inspect_template_argument<int*, &static_i>("{int ptr}");
    inspect_template_argument<const int*, &static_i>("{c-int ptr}");
    inspect_template_argument<const int* const, &static_i>("{c-int c-ptr}");
    inspect_template_argument<dummy>("{dummy}");
    inspect_template_argument<const dummy>("{c-dummy}");
}

In the following table, we provide the output of the above program. For readability sake, the output of typeid (which is compiler-specific) is simplified to represent the relevant types (`int` or `dummy`). Furthermore, we have highlighted the interesting cases.

Compiler
Microsoft C++ClangGCC g++
CaseLine(19.34.31937)(15.0.1)(11.2.0)(type)
{int} Type `int`
decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
decltype(w) `int`
decltype((w)) & & & `int`
ifa(w); & & & `int`
decltype(x) const const const `int`
decltype((x)) & const & const & const `int`
ifa(x) & const & const & const `int`
{c-int} Type const const const `int`
decltype(Value) const `int`
decltype((Value)) const `int`
ifa(Value) && const && && const `int`
decltype(w) const const const `int`
decltype((w)) & const & const & const `int`
ifa(w); & const & const & const `int`
decltype(x) const const const `int`
decltype((x)) & const & const & const `int`
ifa(x) & const & const & const `int`
{int ref} Type & & & `int`
decltype(Value) & & `int`
decltype((Value)) & & & `int`
ifa(Value) & & & `int`
decltype(w) & & & `int`
decltype((w)) & & & `int`
ifa(w); & & & `int`
decltype(x) & & & `int`
decltype((x)) & & & `int`
ifa(x) & & & `int`
{c-int ref} Type & const & const & const `int`
decltype(Value) & const & const `int`
decltype((Value)) & & const & const `int`
ifa(Value) & const & const & const `int`
decltype(w) & const & const & const `int`
decltype((w)) & const & const & const `int`
ifa(w); & const & const & const `int`
decltype(x) & const & const & const `int`
decltype((x)) & const & const & const `int`
ifa(x) & const & const & const `int`
{int ptr} Type * * * `int`
decltype(Value) * * * `int`
decltype((Value)) * * * `int`
ifa(Value) && * && * && * `int`
decltype(w) * * * `int`
decltype((w)) & * & * & * `int`
ifa(w); & * & * & * `int`
decltype(x) const * const * const * `int`
decltype((x)) & const * & const * & const * `int`
ifa(x) & const * & const * & const * `int`
{c-int ptr} Type * const * const * const `int`
decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
decltype(w) * const * const * const `int`
decltype((w)) & * const & * const & * const `int`
ifa(w); & * const & * const & * const `int`
decltype(x) const * const const * const const * const `int`
decltype((x)) & const * const & const * const & const * const `int`
ifa(x) & const * const & const * const & const * const `int`
{c-int c-ptr} Type const * const const * const const * const `int`
decltype(Value) * const * const const * const `int`
decltype((Value)) * const * const const * const `int`
ifa(Value) && * const && * const && const * const `int`
decltype(w) const * const const * const const * const `int`
decltype((w)) & const * const & const * const & const * const `int`
ifa(w); & const * const & const * const & const * const `int`
decltype(x) const * const const * const const * const `int`
decltype((x)) & const * const & const * const & const * const `int`
ifa(x) & const * const & const * const & const * const `int`
{dummy} Type `dummy`
decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
decltype(w) `dummy`
decltype((w)) & & & `dummy`
ifa(w); & & & `dummy`
decltype(x) const const const `dummy`
decltype((x)) & const & const & const `dummy`
ifa(x) & const & const & const `dummy`
{c-dummy} Type const const const `dummy`
decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
decltype(w) const const const `dummy`
decltype((w)) & const & const & const `dummy`
ifa(w); & const & const & const `dummy`
decltype(x) const const const `dummy`
decltype((x)) & const & const & const `dummy`
ifa(x) & const & const & const `dummy`
The output of compiling and running the above inspect_template_argument example with the three major C++ compilers. We have highlighted the cases in which the compilers disagree. In this table, ifa(e) refers to a call to inspect_function_argument with arguments e and case_name. The cases for the GCC g++ compiler labeled † provide different output under GCC g++ 12.2 (in which the output has top-level const-qualifiers removed).

As also this program is rather straightforward, we can rule out bugs in this program. First, observe that the GCC g++ compiler disagrees with the other compilers about the working of decltype(Value) when Value is a non-type template argument: the GCC g++ compiler always includes a const-qualifier if the type Type is a class type. Additionally, the GCC g++ compiler always included a const-qualifier in GCC g++ 11.2.0 if the type Type was not a class type, but was const-qualified. To see whether this behavior is correct, we once again look at the standard. The working of decltype specifiers is described in Clause 1 of Section [dcl.type.decltype]:

For an expression E, the type denoted by decltype(E) is defined as follows:

In our code examples, there is no application of type deduction (e.g., there is no relevant use auto with regards to the decltype expressions). Hence, for the inconsistencies on the Lines decltype(V) we only need to determine what the type of a template parameter is. We refer to Clause 6 of Section [temp.param]:

A non-type template-parameter shall have one of the following (possibly cv-qualified) types: ...

The top-level cv-qualifiers on the template-parameter are ignored when determining its type.

Hence, in the Lines decltype(Value) in which the behavior of the GCC g++ compiler deviates from the Clang compiler, the behavior of the GCC g++ compiler is wrong. In addition, we notice that not only the GCC g++ compiler deviates from the Clang compiler: in Cases {int ref} and {c-int ref}, also the Microsoft C++ compiler deviates from the Clang compiler: the Microsoft C++ compiler ignores not only the top-level cv-qualifiers, but also the reference qualifiers. Hence, also the Microsoft C++ compiler is wrong here.

Next, we take a look at the disagreements for the Lines decltype((Value)). In this case, Value is parenthesized and (Value) is interpreted as a normal expression. In the previous section, we already highlighted the part in Clause 8 of Section [temp.param] that determines which object Value represents. We distinguish two cases:

  1. Value is of a class type (Cases {dummy} and {c-dummy} in the above table). In these cases, Value is const Type and its usage results in an lvalue. Hence, the result of decltype((Value)) should be const Type&, which all compilers correctly derive.

  2. Value is not of a class type (the other Cases in the above table). In these cases, Value is const Type and its usage results in a prvalue. Only the last case in Clause 1 of [dcl.type.decltype] applies, and with the information presented up till here, we should conclude that the result of (Value) is a prvalue of type const Type. The standard treats expressions yielding non-class types differently, however. We refer to Clause 2 of Section [expr.type]:

    If a prvalue initially has the type "cv T", where T is a cv-unqualified non-class, non-array type, the type of the expression is adjusted to T prior to any further analysis.

    Taking this into account, the result of (Value) should be T with any top-level const-qualifiers removed. For example, in Case {c-int}, decltype((Value)) must yield int. Hence, in this case the GCC g++ 11.2.0 compiler is wrong (but not the GCC g++ 12.2 compiler). In Case {c-int ref}, there is no top-level const-qualifier, however, and decltype((Value)) must yield const int&. Hence, in this case the Microsoft C++ compiler is wrong (as it removes a non-top-level const-qualifier).

Finally, we take a look at the disagreements for the two Lines ifa(Value) on which the compilers have disagreements. These inconsistencies only happen in cases in which Value is not of a class type. Hence, we can use the same analysis as above to conclude that the expression Value yields a prvalue of type Type with top-level const-qualifiers removed. As prvalues are rvalues, they will bind to rvalue references when passed to functions that accept rvalue references, which is the case when we pass Value to the ifa function.

Next, we apply the above to the two cases in which the compilers disagree. In Case {c-int}, the Line ifa(Value) passes a prvalue of type int (top-level const-qualifier removed from type const int), yielding the type int&&. Hence, in this case, both the Microsoft C++ and the GCC g++ 11.2.0 compilers are wrong. In Case {c-int c-ptr}, the Line ifa(Value) passes a prvalue of type const int* (top-level const-qualifier removed from type const int* const), yielding the type const int*&&. Hence, in this case the GCC g++ 11.2.0 compiler is wrong (but not the GCC g++ 12.2 compiler).

What about type deduction?

I did try to find existing bug reports that cover the issues outlined in the previous sections, but I was unable to find any. I did find several related bug reports regarding the types derived for non-type template parameters specified with placeholder types (auto and decltype(auto)), however. To also look at these cases, I adjusted the above inspect_template_argument program to the following program that inspects usages of placeholder types:

The inspect_template_auto and inspect_template_auto_ref example.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */
#include <iostream>
#include <typeinfo>
#include <type_traits>


/*
 * Format the type @{Type} and print it to standard output. The qualifiers
 * @{qs...} are used to include further details on @{Type}.
 */
template<class Type>
void type_printer(const auto&... qs)
{
    if constexpr (std::is_lvalue_reference_v<Type>) {
        return type_printer<std::remove_reference_t<Type>>(qs..., "&");
    }
    if constexpr (std::is_rvalue_reference_v<Type>) {
        return type_printer<std::remove_reference_t<Type>>(qs..., "&&");
    }
    if constexpr (std::is_const_v<Type>) {
        return type_printer<std::remove_const_t<Type>>(qs..., "const");
    }
    if constexpr (std::is_volatile_v<Type>) {
        return type_printer<std::remove_volatile_t<Type>>(qs..., "volatile");
    }
    if constexpr (std::is_array_v<Type>) {
        return type_printer<std::remove_extent_t<Type>>(qs..., "[]");
    }
    if constexpr (std::is_pointer_v<Type>) {
        return type_printer<std::remove_pointer_t<Type>>(qs..., "*");
    }
    ((std::cout << qs << " "), ...);
    std::cout << '`' << typeid(Type).name() << '`' << std::endl;
}


/*
 * Print the details on the type of @{arg} (which reflects the type of any
 * expression passed to this function). The printed data will be labeled with
 * @{case_name...}.
 */
void inspect_function_argument(auto&& arg, const auto&... case_names)
{
    type_printer<decltype(arg)>(case_names...);
}


/*
 * Print the type details on the value @{Value} that is passed via type
 * deduction using @{auto}. The printed data will be labeled with "{auto}"
 * @{case_name}.
 */
template<auto Value>
void auto_nttp(const auto& case_name)
{
    type_printer<decltype(Value)>("{auto}", case_name);
    type_printer<decltype((Value))>("{auto}", case_name);
    inspect_function_argument(Value, "{auto}", case_name);
}

/*
 * Print the type details on the value @{Value} that is passed via type
 * deduction using @{const auto}. The printed data will be labeled with
 * "{c-auto}" @{case_name}.
 */
template<const auto Value>
void const_auto_nttp(const auto& case_name)
{
    type_printer<decltype(Value)>("{c-auto}", case_name);
    type_printer<decltype((Value))>("{c-auto}", case_name);
    inspect_function_argument(Value, "{c-auto}", case_name);
}

/*
 * Print the type details on the value @{Value} that is passed via type
 * deduction using @{decltype(auto)}. The printed data will be labeled with
 * "{decltype}" @{case_name}.
 */
template<decltype(auto) Value>
void decltype_auto_nttp(const auto& case_name)
{
    type_printer<decltype(Value)>("{decltype}", case_name);
    type_printer<decltype((Value))>("{decltype}", case_name);
    inspect_function_argument(Value, "{decltype}", case_name);
}

/*
 * Print the type details on the value @{Value} that is passed via type
 * deduction using @{auto&}. The printed data will be labeled with "{auto-r}"
 * @{case_name}.
 */
template<auto& Value>
void auto_ref_nttp(const auto& case_name)
{
    type_printer<decltype(Value)>("{auto-r}", case_name);
    type_printer<decltype((Value))>("{auto-r}", case_name);
    inspect_function_argument(Value, "{auto-r}", case_name);
}

/*
 * Print the type details on the value @{Value} that is passed via type
 * deduction using @{const auto&}. The printed data will be labeled with
 * "{c-auto-r}" @{case_name}.
 */
template<const auto& Value>
void const_auto_ref_nttp(const auto& case_name)
{
    type_printer<decltype(Value)>("{c-auto-r}", case_name);
    type_printer<decltype((Value))>("{c-auto-r}", case_name);
    inspect_function_argument(Value, "{c-auto-r}", case_name);
}


/*
 * Take a non-reference value @{Value} of type @{Type}, use type deduction to
 * pass @{Value} as a non-type template parameter, and print the type details
 * of the passed value. The printed data will be labeled with @{case_name}.
 */
template<class Type, Type Value>
void inspect_template_auto(const auto& case_name)
{
    auto_nttp<static_cast<Type>(Value)>(case_name);
    const_auto_nttp<static_cast<Type>(Value)>(case_name);
    decltype_auto_nttp<static_cast<Type>(Value)>(case_name);
}

/*
 * Take a reference value @{Value} of type @{Type}, use type deduction to pass
 * @{Value} as a non-type template parameter, and print the type details of the
 * passed value. The printed data will be labeled with @{case_name}.
 */
template<class Type, Type& Value>
void inspect_template_auto_ref(const auto& case_name)
{
    /* @{Value} must refer to an object with linkage (e.g., a static variable).
     * These objects can only be passed as a value if they are constexpr. Hence,
     * only pass them by-reference. */
    decltype_auto_nttp<static_cast<Type>(Value)>(case_name);
    auto_ref_nttp<static_cast<Type>(Value)>(case_name);
    const_auto_ref_nttp<static_cast<Type>(Value)>(case_name);
}

/*
 * Take a reference to a constexpr value @{Value} of type @{Type}, use type
 * deduction to pass @{Value} as a non-type template parameter, and print the
 * type details of the passed value. The printed data will be labeled with
 * @{case_name}.
 */
template<class Type, Type& value>
void inspect_template_auto_ref_ce(const auto& case_name)
{
    /* @{Value} must refer to a constexpr object with linkage. */
    auto_nttp<static_cast<Type>(value)>(case_name);
    const_auto_nttp<static_cast<Type>(value)>(case_name);
    decltype_auto_nttp<static_cast<Type>(value)>(case_name);
    auto_ref_nttp<static_cast<Type>(value)>(case_name);
    const_auto_ref_nttp<static_cast<Type>(value)>(case_name);
}


/*
 * A dummy struct type.
 */
struct dummy {};

/*
 * Static objects with linkage. Non-type template parameters can point or
 * reference these objects.
 */
static int static_i = 0;
static dummy static_d = {};

/*
 * A static constexpr object with linkage. Non-type template parameters can not
 * only point or reference these objects, but also use them as values.
 */
static constexpr int static_cei = 0;
static constexpr dummy static_ced = {};

/*
 * Entry-point of the program.
 */
int main()
{
    inspect_template_auto<int, 0>("{int}");
    inspect_template_auto<const int, 0>("{c-int}");
    inspect_template_auto<int*, &static_i>("{int ptr}");
    inspect_template_auto<const int*, &static_i>("{c-int ptr}");
    inspect_template_auto<const int* const, &static_i>("{c-int c-ptr}");
    inspect_template_auto_ref<int&, static_i>("{int ref}");
    inspect_template_auto_ref<const int&, static_i>("{c-int ref}");
    inspect_template_auto_ref_ce<const int&, static_cei>("{ce-int ref}");

    inspect_template_auto<dummy, dummy{}>("{dummy}");
    inspect_template_auto<const dummy, dummy{}>("{c-dummy}");
    inspect_template_auto_ref<dummy&, static_d>("{dummy ref}");
    inspect_template_auto_ref<const dummy&, static_d>("{c-dummy ref}");
    inspect_template_auto_ref_ce<const dummy&, static_ced>("{ce-dummy ref}");
}

In the following table, we provide the output of the above program. We have used the same conventions as in the previous sections. In addition, the Microsoft C++ compiler was unable to compile the line inspect_template_auto_ref_ce("{ce-int ref}"); (error C2672), due to which we have commented-out this line when compiling with the Microsoft C++ compiler. Again, we have highlighted the interesting cases.

Compiler
Microsoft C++ClangGCC g++
CaseLine(19.34.31937)(15.0.1)(11.2.0)(type)
{auto} {int} decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
{c-auto} {int} decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
{decltype} {int} decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
{auto} {c-int} decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
{c-auto} {c-int} decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
{decltype} {c-int} decltype(Value) `int`
decltype((Value)) `int`
ifa(Value) && && && `int`
{auto} {int ptr} decltype(Value) * * * `int`
decltype((Value)) * * * `int`
ifa(Value) && * && * && * `int`
{c-auto} {int ptr} decltype(Value) * * * `int`
decltype((Value)) * * * `int`
ifa(Value) && * && * && * `int`
{decltype} {int ptr} decltype(Value) * * * `int`
decltype((Value)) * * * `int`
ifa(Value) && * && * && * `int`
{auto} {c-int ptr} decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
{c-auto} {c-int ptr} decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
{decltype} {c-int ptr} decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
{auto} {c-int c-ptr} decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
{c-auto} {c-int c-ptr} decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
{decltype} {c-int c-ptr} decltype(Value) * const * const * const `int`
decltype((Value)) * const * const * const `int`
ifa(Value) && * const && * const && * const `int`
{decltype} {int ref} decltype(Value) & & `int`
decltype((Value)) & & & `int`
ifa(Value) & & & `int`
{auto-r} {int ref} decltype(Value) & & `int`
decltype((Value)) & & & `int`
ifa(Value) & & & `int`
{c-auto-r} {int ref} decltype(Value) & const & const `int`
decltype((Value)) & & const & const `int`
ifa(Value) & const & const & const `int`
{decltype} {c-int ref} decltype(Value) & const & const `int`
decltype((Value)) & & const & const `int`
ifa(Value) & const & const & const `int`
{auto-r} {c-int ref} decltype(Value) & const & const `int`
decltype((Value)) & & const & const `int`
ifa(Value) & const & const & const `int`
{c-auto-r} {c-int ref} decltype(Value) & const & const `int`
decltype((Value)) & & const & const `int`
ifa(Value) & const & const & const `int`
{auto} {ce-int ref} decltype(Value) C2672 `int`
decltype((Value)) C2672 `int`
ifa(Value) C2672 && && `int`
{c-auto} {ce-int ref} decltype(Value) C2672 `int`
decltype((Value)) C2672 `int`
ifa(Value) C2672 && && `int`
{decltype} {ce-int ref} decltype(Value) C2672 & const & const `int`
decltype((Value)) C2672 & const & const `int`
ifa(Value) C2672 & const & const `int`
{auto-r} {ce-int ref} decltype(Value) C2672 & const & const `int`
decltype((Value)) C2672 & const & const `int`
ifa(Value) C2672 & const & const `int`
{c-auto-r} {ce-int ref} decltype(Value) C2672 & const & const `int`
decltype((Value)) C2672 & const & const `int`
ifa(Value) C2672 & const & const `int`
{auto} {dummy} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{c-auto} {dummy} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{decltype} {dummy} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{auto} {c-dummy} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{c-auto} {c-dummy} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{decltype} {c-dummy} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{decltype} {dummy ref} decltype(Value) & & `dummy`
decltype((Value)) & & & `dummy`
ifa(Value) & & & `dummy`
{auto-r} {dummy ref} decltype(Value) & & `dummy`
decltype((Value)) & & & `dummy`
ifa(Value) & & & `dummy`
{c-auto-r} {dummy ref} decltype(Value) & const & const `dummy`
decltype((Value)) & & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{decltype} {c-dummy ref} decltype(Value) & const & const `dummy`
decltype((Value)) & & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{auto-r} {c-dummy ref} decltype(Value) & const & const `dummy`
decltype((Value)) & & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{c-auto-r} {c-dummy ref} decltype(Value) & const & const `dummy`
decltype((Value)) & & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{auto} {ce-dummy ref} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{c-auto} {ce-dummy ref} decltype(Value) const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{decltype} {ce-dummy ref} decltype(Value) const & const & const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{auto-r} {ce-dummy ref} decltype(Value) const & const & const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
{c-auto-r} {ce-dummy ref} decltype(Value) const & const & const `dummy`
decltype((Value)) & const & const & const `dummy`
ifa(Value) & const & const & const `dummy`
The output of compiling and running the above inspect_template_auto and inspect_template_auto_ref example with the three major C++ compilers. We have highlighted the cases in which the compilers disagree. In this table, ifa(e) refers to a call to inspect_function_argument with arguments e and case_names.

Before we interpret the above discrepancies, we first look at how non-type template parameters with placeholder types are to be interpreted. We refer to Clause 1 of Section [temp.arg.nontype]:

If the type T of a template-parameter contains a placeholder or a placeholder for a deduced class type, the type of the parameter is the type deduced for the variable x in the invented declaration
T x = template-argument;

When applying the above excerpt from the standard to the program, one should read the type T as either auto (function template auto_nttp), const auto (function template const_auto_nttp), auto& (function template auto_ref_nttp), const auto& (function template const_auto_ref_nttp), or decltype(auto) (function template decltype_auto_nttp). We refer to Clause 4 and Clause 5 of Section [dcl.type.auto.deduct] for details on the types deduced for placeholder types. Here, we will only summarize the deduced types for the placeholder types we use in our program. Let expr be the expression provided as a template argument. First, for the cases decltype(auto), Clause 5 of Section [dcl.type.auto.deduct] specifies that the type derived for variable x in the declaration decltype(auto) x = expr; is the type decltype(expr). For all other cases, Clause 4 of Section [dcl.type.auto.deduct] specifies that the type derived for variable x in constoptional auto &optional x = expr; is the same as the type derived for variable u in the call f(expr) in the following invented function template:

template<class U>
void (constoptional U &optional u);

When following the above rules for deducing the type of non-type template parameters with a placeholder type, we can easily end up with const-qualified types (e.g., in the Cases with {c-auto}, which use a non-type template parameter of the form const auto Value; see the function template const_auto_nttp). Due to Clause 6 of Section [temp.param], top-level cv-qualifiers on template parameters are ignored, however (see also the section above). Hence, any deduced const-qualifiers are consequently removed.

Besides the fifteen cases in which the Microsoft C++ compiler fails to compile the program, we have already seen all the inconsistencies in the above table in the previous section:

  1. We have eight Lines decltype(Value) with a class-type Value in which the GCC g++ compiler fails to remove a top-level const-qualifier.
  2. We have seven Lines decltype(Value) in which the Microsoft C++ compiler wrongfully removes a top-level reference-qualifier.
  3. We have eight Lines decltype(Value) in which the Microsoft C++ compiler wrongfully removes a top-level reference-qualifier, due to which it also removes a non-top-level const-qualifier.
  4. We have eight Lines decltype((Value)) in which the Microsoft C++ compiler wrongfully removes a non-top-level const-qualifier from a non-class type (observe that dummy& and const dummy& are reference types and not class types).

Bonus material

During testing, we also found a few additional examples that did not compile in the Microsoft C++ compiler:

Passing references indirectly: Does not compile in Microsoft C++.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */
#include <functional>


/*
 * A function object that returns the input.
 */
static constexpr std::identity id = {};


/*
 * A no-op function that accepts a non-type template parameter @{P}.
 */
template<const int* P>
void nttp_ptr()
{
}


/*
 * A function that accepts a non-type template parameter @{P} and passes this
 * pointer to itself if @{Self} is @{true} or passes it to @{nttp_ptr}.
 */
template<const int* P, bool Self = true>
void nttp_iptr()
{
    if constexpr (Self) {
        nttp_iptr<id(P), false>();
    }
    else {
        nttp_ptr<id(P)>(); // <-- error C2672.
    }
}


/*
 * A static object with linkage, non-type template parameters can point or
 * reference such objects.
 */
static int static_i = 0;


/*
 * Entry-point of the program.
 */
int main()
{
    nttp_ptr<&static_i>();   // <-- no problem.
    nttp_iptr<&static_i>();  // <-- does not compile.
}
Passing references indirectly: Internal compiler error in Microsoft C++.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */
#include <functional>


/*
 * A dummy type that we shall pass as a non-type template parameter.
 */
struct dummy
{
    /* ICE only happens if dummy has a custom constructor, even if that custom
     * constructor is the default constructor. */
    constexpr dummy() = default;
};


/*
 * A function object that returns the input.
 */
static constexpr std::identity id = {};


/*
 * A function that accepts a non-type template parameter @{R} and passes this
 * reference to itself if @{Repeat} is @{true} or does nothing.
 */
template<const dummy& V, bool Repeat = true>
void nttp_ref()
{
    if constexpr (Repeat) {
        // Direct references: never an issue.
        nttp_ref<V, !Repeat>();
    }
}


/*
 * A function that accepts a non-type template parameter @{R} and passes a copy
 * of this reference to itself if @{Repeat} is @{true} or does nothing.
 */
template<const dummy& V, bool Repeat = true>
void nttp_iref()
{
    if constexpr (Repeat) {
        // Indirect references: ICE if a local static variable is used.
        nttp_iref<id(V), !Repeat>();
    }
}


/*
 * A static object with linkage, non-type template parameters can reference this
 * object.
 */
static constexpr dummy static_d = {};


/*
 * Entry-point of the program.
 */
int main()
{
    nttp_ref<static_d>();   // <-- no problem.
    nttp_iref<static_d>();  // <-- no problem.
    
    /* A local static object with linkage, non-type template parameters can also
     * reference this object. ICE only happens if this variable is constexpr,
     * otherwise, the code simply does not compile (error C2672). */
    static constexpr dummy local_d = {};

    nttp_ref<local_d>();   // <-- no problem.
    nttp_iref<local_d>();  // <-- ICE.
}

Finally, I also found a minor issue with the GCC g++ compiler when dealing with non-copyable types:

Passing movable objects that cannot be copied: Does not compile in GCC g++.
/*
 * @author{Jelle Hellings}.
 * @copyright{The 2-Clause BSD License; see the end of this article}.
 */


/*
 * A type that can only be default-constructed and moved.
 */
struct no_copy
{
    /*
     * We can default-construct a no_copy.
     */
    constexpr no_copy() {};

    /*
     * We cannot copy a no_copy.
     */
    no_copy(const no_copy&) = delete;

    /*
     * But we certainly can move a no_copy.
     */
    constexpr no_copy(no_copy&&) {}
};



/*
 * A template function that accepts a no_copy non-type template parameter, but
 * does not do anything with it.
 */
template<no_copy NC> 
void test_f()
{
    /* We cannot pass NC to another template, as we do not have a copy
     * constructor. We can use this template by moving in a no_copy, however. */
};


/*
 * A template struct that accepts a no_copy non-type template parameter, but
 * does not do anything with it.
 */
template<no_copy NC> 
struct test_t
{
    /* We cannot pass NC to another template, as we do not have a copy
     * constructor. We can use this template by moving in a no_copy, however. */
};


/*
 * Entry-point of the program.
 */
int main ()
{
    test_f<no_copy{}>();     // Works fine, as it should.
    test_t<no_copy{}> value; // <- error: use of deleted function.
}

Concluding Remarks

In this article, I outlined several disagreements in the implementation of non-type template parameters in the current major C++ compilers. Based on these disagreements and my best-effort interpretation of the C++ standard, I have identified compiler bugs in each of the major C++ compilers. If you disagree with my analysis, then feel free to provide an improved analysis in the comments below. In my experience, you will not run into many issues due to these bugs, however: most of the bugs outlined in this article will not interfere with typical use-cases of non-faulty template parameters. In cases where these bugs do cause issues, workarounds are easily constructed.

In general, I am still very happy with the changes made to non-type template parameters in C++20: the added support for structural classes opens the door for powerful new approaches to writing high-level abstractions that, with the usage of templates and constexpr functions, still compile to highly-optimized binaries. I hope I can finish my endeavors into developing such high-level abstractions and present the results soon. (In practice, the projects I am currently working on do not suffer from any of the bugs outlined in this article; except for the limitations enforced by the Microsoft C++ compiler with respect to reference types).

That being said, the number of bugs found and the simplicity of the minimal examples I use here to illustrate the bugs does raise some concern about the correctness of modern C++ compilers, this especially with regards to more cutting-edge features. To partially address these concerns, we, the C++ community as a whole, should work on building comprehensive compliance tests for both new and old C++ features and, where possible, include such tests for new proposals and additions to the standard.

I invite anyone to use the examples in this article as an inspiration for building such compliance tests for non-type template parameters. As such, I have released all code on this page under the 2-Clause BSD License:

2-Clause BSD License
/*
 * Copyright (c) 2022 Jelle Hellings
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */

I have reported each of the bugs I found (where possible, by augmenting existing relevant reports). I will update this article if I have further information on these bug reports.

Join the discussion