Comments
You can use your Mastodon account to reply to this post.
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
(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.
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.
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:
X
.MyClass
:X
with the same signature, but make that constructor a function
template with the same template parameters as MyClass
.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
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.
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
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. ↩︎
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. ↩︎
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. ↩︎
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. ↩︎
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. ↩︎
This
problem goes away with C++20, which gives us explicit(bool)
. ↩︎
You can use your Mastodon account to reply to this post.