Exploring the Limits of Class Template Argument Deduction

I recently needed to trace some error related to C++17 class template argument deduction and came across some corner cases. In this article, I document what I learned, show some “paradox” cases (which have nice, clean solutions as per the standard) and demonstrate a suspected Clang bug.

C++17 gave us Class Template Argument Deduction (CTAD), i.e., a way for the compiler to figure out the arguments to a class template. Therefore, we can now write auto p = std::pair(42, 23); instead of needing to write auto p = std::pair<int, int>(42, 23);.1

CTAD primer

(If you already roughly know how CTAD works, just skip ahead.)

The two main mechanisms for CTAD are deduction guides and what I’ll call implicit deduction guides2. Deduction guides basically tell the compiler which types (or non-types) to use as template arguments when a constructor3 for the class template is called in a certain way. Consider these examples:

1template <class T> class SomeClass {};
2
3// This tells the compiler to use bool for T if it sees SomeClass(42);
4SomeClass(int) -> SomeClass<bool>;
5
6// Deduction guides can be templates themselves! Here, any const-reference
7// to some type X results in X being used as T.
8template<class DeductionT>
9SomeClass(const DeductionT &) -> SomeClass<DeductionT>;

The syntax in pretty straightforward: The part before the -> looks like a function signature, the part after the -> is the template with its parameters replaced by arguments. The left-hand sides of all deduction guides for a given class template form an overload set as normal functions would, and the compiler uses its usual rules of overload resolution to figure out which deduction guide to use.

The second case, implicit deduction guides, are not given by the programmer, but generated automatically from each constructor of the class. The exact rules are a bit involved and we’ll come back to it in the section about the details of CTAD, or see cppreference.com for another nice explanation. Basically, every constructor is treated as a function template, converting the class' template parameters into function template parameters. If function template argument deduction succeeds, the deduced types are used for the class template parameters.

Since these implicit deduction guides are a lot less straightforward than the manually-given ones, I’ll focus on them for the rest of this article.

Paradox Deduction

Before I construct an example of what I’ll call “paradox deduction”, let’s start with something simple:

1template<class T>
2class MyClass {
3public:
4	using type = T;
5
6	MyClass(T t) {}
7};

At the first look, the (sole) constructor of MyClass should make CTAD deduce T to the type of whatever MyClass’s constructor is called with - in other words: whatever function template argument deduction would deduce for T in a call to some hypothetical template<class T> void MyClass(T t) {}. However, function template argument deduction follows some non-obvious rules, as stated in [temp.deduct.call]. For example, in (2.1) it says:

If P is not a reference type: […]

  • If A is an array type, the pointer type produced by the array-to-pointer standard conversion (7.2) is used in place of A for type deduction […]

P here refers to the template parameter, and A to the actual argument. So, since the T in MyClass(T t) {} is not a reference type, this happens:

1/* MyClass defined as above */
2
3char arr[42];
4auto x = MyClass(arr);
5
6if (std::is_same_v<decltype(x)::type, char *>)
7{
8	std::cout << "Pointer";
9}

This emits the string “Pointer”. So, even though we call MyClass’s constructor with an object of type char[42], T is deduced to char * because of the array-to-pointer decay. Note that this would not be the case for MyOtherClass, which accepts a (const) reference in its constructor:

 1template<class T>
 2class MyOtherClass {
 3public:
 4	using type = T;
 5
 6	MyOtherClass(const T& t) {}
 7};
 8
 9int main()
10{
11	char arr[42];
12	auto x = MyOtherClass(arr);
13
14	if (std::is_same_v<decltype(x)::type, char[42]>)
15	{
16		std::cout << "Array";
17	}
18}

This issues the string “Array”. You can play around with both examples here at Godbolt’s Compiler Explorer.

Now we have a situation where different constructors (one taking const T&, the other taking T) produce different deduced types when used for CTAD. With this, we can create a “paradox”. We create a class template that contains both constructors, the one leading to char * being deduced and the one leading to char[42]. We then use SFINAE to always disable the constructor that must have been taken, i.e., if char * was deduced, we disable the constructor leading to char * being deduced:

 1#include <iostream>
 2
 3template<class T>
 4class MyClass {
 5public:
 6	using type = T;
 7
 8	// Using this constructor for CTAD would result in T = char*, so
 9	// we disable it via SFINAE if T = char*
10	template<class InnerT = type,
11	         std::enable_if_t<!std::is_same_v<InnerT, char*>, bool> = true>
12	MyClass(T t) {}
13
14	// Using this constructor for CTAD would result in T = char[42], so
15	// we disable it via SFINAE if T = char[42]
16	template<class InnerT = type,
17	         std::enable_if_t<!std::is_same_v<InnerT, char[42]>, bool> = true>
18	MyClass(const T& t) {}
19};
20
21int main()
22{
23	char arr[42];
24	auto x = MyClass(arr);
25
26	if (std::is_same_v<decltype(x)::type, char *>)
27	{
28		std::cout << "MyClass: Pointer\n";
29	}
30	else if (std::is_same_v<decltype(x)::type, char[42]>)
31	{
32		std::cout << "MyClass: Array\n";
33	}
34}

This example is recjected by Clang, GCC, and MSVC, as you can see here at the Compiler Explorer. Though the difference in error messages might already hint at what comes next: While GCC complains about “no matching function call” (which is true because it was disabled via SFINAE), Clang complains about “ambiguous deduction”, which would be true if both constructors were not SFINAE-disabled.

The nitty-gritty details of CTAD

To see why this “paradox” is actually resolved in a well-defined manner by the standard, we need to roughly understand the process behind CTAD via implicit deduction guides. Deducing class template arguments via constructors works by “pushing down” the template parameters of the class into the constructor. Performing CTAD for a class template MyClass works like this:

  • A new, empty, “hypothetical” class (not a class template!) is created by the compiler, let’s call it X.
  • For every constructor in MyClass:
    • Create a constructor in X with the same signature, but make that constructor a function template with the same template parameters as MyClass.
    • If the original constructor in MyClass was a function template itself, the template parameters of MyClass and the template parameters of the constructor we are currently converting are concatenated.

As an example, this class:

1template<class T>
2class Simple {
3public:
4	Simple(T t) {}
5
6	template<class U>
7	Simple(T t, U u) {}
8};

would result in this “hypothetical” class:

1class X {
2public:
3	template<class T>
4	X(T t) {}
5
6	template<class T, class U>
7	X(T t, U u) {}
8};

For actual argument deduction, the compiler then tries to replace MyClass with X in the call that initially triggered CTAD (i.e, something like auto x = MyClass(arr); becomes auto x = X(arr);). Since X is not a class template, the usual pre-C++17 function template argument deduction can work its magic on the constructor overload set of X.

If we apply the transformation above to our previous “paradox” example of MyClass from the previous section, we get something like this:

 1// The 'hypothetical' class for MyClass
 2class MyClassDeductionHelper {
 3public:
 4	template<class T, class InnerT = typename MyClass<T>::type,
 5	         std::enable_if_t<!std::is_same_v<InnerT, char*>, bool> = true>
 6	MyClassDeductionHelper(T t) {}
 7
 8	template<class T, class InnerT = typename MyClass<T>::type,
 9	         std::enable_if_t<!std::is_same_v<InnerT, char[42]>, bool> = true>
10	MyClassDeductionHelper(const T& t) {}
11};

Try it here at Compiler Explorer. Note that MyClassDeductionHelper is not a class template anymore. Also note that MyClass<T>::type suddenly appears in the template declaration. This is the same type as the original constructors’ declarations used, only that the type was just called type then, since we were inside MyClass<T>. The standard is a bit terse on how exactly the constructors are translated, it only states:

The template parameters are the template parameters of the class template followed by the template parameters (including default template arguments) of the constructor, if any.

I assume this means that the default arguments should also be the same types, and therefore should be MyClass<T>::type here.

And indeed, if you now do char arr[42]; auto x = MyClassDeductionHelper{arr};, all three compilers also produce an error - this time clearly stating that they can’t find a matching constructor.4

Where compilers disagree

Now that we know that and why the constructors in this “paradox” case get discarded, let’s try and see if the compilers correctly “fall back” to a third option for a constructor. To do that, we add a third constructor to MyClass which is a strictly worse choice (in terms of overload resolution) than the two constructors we already know. For that, we add a second parameter to the constructors. Consider this complete example:

 1#include <iostream>
 2
 3template<class T>
 4class MySecondClass {
 5public:
 6	using type = T;
 7
 8	// Same as before, just added an int parameter
 9	template<class InnerT = type,
10	         std::enable_if_t<!std::is_same_v<InnerT, char*>, bool> = true>
11	MySecondClass(T t, int i) {}
12
13	// Same as before, just added an int parameter
14	template<class InnerT = type,
15	         std::enable_if_t<!std::is_same_v<InnerT, char[42]>, bool> = true>
16	MySecondClass(const T& t, int i) {}
17
18	// This is not a good overload for the call below - a conversion from
19	// char[42] to const char * must happen. However, it the absence of the
20	// two constructors above, it should be taken.
21	MySecondClass(const char * arr, T i) {};
22};
23
24int main()
25{
26	char arr[42];
27	auto x = MySecondClass(arr, 42);
28
29	if (std::is_same_v<typename decltype(x)::type, int>)
30	{
31		std::cout << "MyClass: int\n";
32	}
33}

You can play with this here at the Compiler Explorer. This code compiles fine in GCC and MSVC, but is rejected by Clang. Clang still complains about the ambiguous deduction:

 1<source>:28:14: error: ambiguous deduction for template arguments of 'MySecondClass'
 2    auto x = MySecondClass(arr, 42);
 3             ^
 4<source>:11:5: note: candidate function [with T = char *, InnerT = type-parameter-0-0, $2 = true]
 5    MySecondClass(T t, int i) {}
 6    ^
 7<source>:16:5: note: candidate function [with T = char[42], InnerT = type-parameter-0-0, $2 = true]
 8    MySecondClass(const T& t, int i) {}
 9    ^
101 error generated.

This strongly looks like Clang does not actually do the hypothetical-class-transformation and subsequent constructor overload resolution described above, but takes a “shortcut” and somehow just checks which constructors are available. In this shortcut, it does not realize that the constructors are both SFINAed away - but this is really just speculation at this point. I have opened a Clang issue for this suspected bug here.

One can change the error emitted by Clang to the classical “you did SFINAE wrong”-error by replacing InnerT with type inside the two std::enable_if, like this:

 1#include <iostream>
 2
 3template<class T>
 4class MySecondClass {
 5public:
 6	using type = T;
 7
 8	// Same as before, just added an int parameter
 9	template<class InnerT = type,
10	         std::enable_if_t<!std::is_same_v<type, char*>, bool> = true>
11	MySecondClass(T t, int i) {}
12
13	// Same as before, just added an int parameter
14	template<class InnerT = type,
15	         std::enable_if_t<!std::is_same_v<type, char[42]>, bool> = true>
16	MySecondClass(const T& t, int i) {}
17
18	// This is not a good overload for the call below - a conversion from
19	// char[42] to const char * must happen. However, it the absence of the
20	// two constructors above, it should be taken.
21	MySecondClass(const char * arr, T i) {};
22};
23
24int main()
25{
26	char arr[42];
27	auto x = MySecondClass(arr, 42);
28
29	if (std::is_same_v<typename decltype(x)::type, int>)
30	{
31		std::cout << "MyClass: int\n";
32	}
33}

(Try at the Compiler Explorer.) In this case I’m not so sure Clang is at fault, even though both GCC and MSVC still compile this without complaints. The Clang error is the error that you usually see if the substitution failure happens outside of the immediate context5 of the template we are currently deducing arguments for. Since at this point, there is an function argument deduction running “inside” a class template argument deduction, my notion of this immediate context gets even more murky than usual. I’d be happy to hear some opinions on whether this compilation error is actually Okay.

Conclusion

In conclusion, I would be careful when using CTAD with classes that contain “fancy” constructors, i.e., constructors that somehow use SFINAE. Compilers seem not to fully agree on how to resolve that SFINAE-during-CTAD, and I don’t feel confident enough to judge which compiler is correct here.

Unfortunately, many of the standard library constructors are “conditionally explicit”, which in C++17 requires you to write two constructors and select the correct one via SFINAE.6


  1. Of course we could also write std::make_pair(42, 23). In fact, std::make_pair was added to C++11 mainly because C++11 did not have CTAD. ↩︎

  2. These don’t have their own name in the C++17 standard. Section [over.match.class.deduct] only defines the algorithm used for CTAD. These implicit deduction guides are what results from the constructors of the class template. ↩︎

  3. In fact, CTAD is not only done when encountering a call to a constructor, but also in other cases. cppreference.com has a nice overview↩︎

  4. Note that now Clang also agrees with the other compilers and does not complain about “ambiguous deduction”. Thus, Clang seems to internally do something slightly different than creating this hypothetical class. ↩︎

  5. My best handwaving summary of this is: All the hypothetical classes and functions that must be instantiated during template argument deduction must actually be valid, i.e., not ill-formed. The failure may only happen after all this was done and the compiler substitutes template arguments into the template that we currently are deducing arguments for. ↩︎

  6. This problem goes away with C++20, which gives us explicit(bool)↩︎

Comments

You can use your Mastodon account to reply to this post.

Reply to tinloaf's post

With an account on the Fediverse or Mastodon, you can respond to this post. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one.

Copy and paste this URL into the search field of your favourite Fediverse app or the web interface of your Mastodon server.