类模板实参推导
-
C++17支持类模板类型推导(class template argument deduction,在下面的文章中,我叫做CTAD)。 -
而我们在很久之前就有了template argument deduction,但是只能用于函数,这多少有点不公平。 -
此篇博客的内容来自cppcon2018_CTAD 。
std::pair<int, string> p1(3, "string");
auto p2 = make_pair(3, "string");
std::pair p3(3, string("hello"));
CTAD是如何工作的?
- CTAD工作的具体细节是很复杂的。而且有些细节是给编译器实现者使用的。
- 这里,我将介绍CTAD工作的最重要的两步。
template <class T, class U>
struct pair{
T first;
U second;
pair(const T& first_, const U& second_)
:first(first_), second(second_)
{}
pair(T&& first_, U&& second_)
:first(std::forward<T>(first_))
,second(std::forward<U>(second_))
{}
};
std::pair p(3, string("hello"));
- 上面是粗略的std::pair的实现,我们使用它来进行讲解。
第一步:
- 当编译器看到你尝试去初始化一个p,编译器又看见了pair是一个模板的名字。但是你没有显式传入模板实参,而且pair没有默认值,所以编译器需要CTAD。
- 编译器会去查看pair的构造函数,它会假装构造函数是普通的函数模板,像下面这样,
template <class T, class U>
pair(const T& first_, const U& second_)
:first(first_), second(second_)
{}
template <class T, class U>
pair(T&& first_, U&& second_)
:first(std::forward<T>(first_))
,second(std::forward<U>(second_))
{}
- 编译器会假装synthesis(合成,函数重载的术语)两个上面的函数,将类的模板参数列表加到构造函数的头部。
- 然后编译器就使用模板实参推导,overload resolutions等一系列方法,去分辨函数重载中最合适的那一个。
- 最终,匹配了第二个右值引用的pair,然后编译器就会推导出p的类型为pair< int, string >。
第二步:
- 注意,第一步中,没有进行任何的实例化,仅仅是推导出模板参数。
- 实例化发生在第二步。
- 编译器现在有了p的类型,pair<int, string >,然后就可以调用第二个构造函数实例化出该对象。
CTAD && STL
- CTAD不仅仅适用于pair,也适用于任何类,因为CTAD是语言特性。
- 有了CTAD之后,你不在需要那些make函数,比如make_pair,make_tuple,CTAD完全可以替代它们,除了某些边缘情况。
tuple t1{3, 3.14, "string"};
auto t2 = make_tuple(1, 1.11, "hello");
zero or all
- 其中一个CTAD和make函数的区别就是,CTAD要么有全部的显式实参,要么一个没有。
std::tuple t1<std::string>{"string", 3, 3.14};
auto t2 = make_tuple<std::string>("string", 3, 3.14);
- 据我的测试,在C++20中,CTAD依然不支持这种语法格式。
vector
std::vector v1{1, 2, 3, 4};
std::vector v2{1, 2 ,3 ,4.0f};
- CTAD和花括号也互动的很好,可以推导出vector< int >。
- 你不能将在花括号中放入不同类型的常量,这样CTAD无法推导出类型。
- 这里有一个陷阱,
std::vector<int> v1{3};
std::vector<int> v2(3);
- 这实际上是C++14之前的一个古老的陷阱。当你用花括号去构造vector,调用初始化列表的构造函数,构造一个size为1,含有一个3的vector。
- 当你使用小括号时,vector回去构造一个size为3的数组,每个位置使用初始值0。
std::vector v1{3};
std::vector v2(3);
- 但是当你使用CTAD时,v1依然可以推断出vector< int >,然后构造出size为1的vector。
- 但是v2的推断失败了。因为编译器只知道v2的size是3,但是它不知道你想用什么类型的vector。它会说,“哦,你想要vector的大小为3,你想用T类型的,但是T是什么,我不知道。”
deduction guides
std::vector range{1, 2, 3, 4};
std::vector v(range.begin(), range.end());
- CTAD依然奏效。根据迭代器类型推导出v 类型为vector< int >。
- 这是如何工作的?
template <class T>
class vector{
template <class Iter>
vector(Iter first, Iter last);
};
- 上面是vector关于此构造函数的简单写法。按照CTAD的第一步,编译器会去合成一些模板函数,
template <class T, class Iter>
vector(Iter first, Iter last);
- 现在你去调用此构造函数,编译器会说,“ok,我可以推导出Iter的类型,因为你给了我两个迭代器。但是我无法推导出T的类型。”
- 仅仅依靠CTAD是不够的,我们需要别的语法,这就是deduction guides。
template <class T>
class vector{
template <class Iter>
vector(Iter first, Iter last);
};
template <class Iter>
vector(Iter, Iter)->vector<typename iterator_traits<Iter>::value_type>;
- 当编译器无法自己完成CTAD时,需要你显式的提供deduction guides。
- 前面类似该构造函数的签名式,然后是一个箭头,然后是你想设置的类型。这告诉编译器,如果你调用该构造函数,那么该类的模板实参为typename iterator_traits< Iter >::value_type。
- 从这里可以看到deduction guides不需要形参的名字。
- deduction guides必须与该类处在相同的作用范围内,或者说相同的命名空间内。
- deduction guides类似一种新的对象,它会在overload resolution的第一步被加入到候选人列表中,如果该构造函数被选中,那么T可以被正确推导了。
std::vector v1(range.begin(), range.end());
std::vector v2(1, 2);
顺序很重要:
- 另外一个陷阱就是,deduction guides必须紧跟在类的后边,如果deduction guides和调用语句互换,那么CTAD不会奏效。
template <class T>
class vector{
template <class Iter>
vector(Iter first, Iter last);
};
std::vector range{1, 2, 3, 4};
std::vector v(range.begin(), range.end());
template <class Iter>
vector(Iter, Iter)->vector<typename iterator_traits<Iter>::value_type>;
- 当某些库缺少deduction guides时,你确实可以打开命名空间,为其编写deduction guides。但是请避免这样做,因为这可能与库维护者的编写产生冲突。
- 例如vector就在std中,因为deduction guides必须与该类处在同一范围中,所以deduction guides必须在std中,但是C++规定禁止向std中添加额外的东西。
陷阱2:花括号有优先级。
vector range{1, 2, 3};
vector v1(range.begin(), range.end());
vector v2{range.begin(), range.end()};
- 当采用花括号时,编译器会优先调用初始化列表那个构造函数,将v2推导为类型为迭代器的vector,而非int。(这点与auto的推导规则一样,初始化列表具有优先级。)
other containers:
vector range{1, 2, 3, 4};
list l(range.begin(), range.end());
forward_list fl(range.begin(), range.end());
deque d(range.begin(), range.end());
set:
- set也可以使用CTAD,但是set有些特殊的构造函数。
set s1{1, 2, 3};
set s2(s1.begin(), s1.end());
set s3({1, 2, 3}, [](int lhs, int rhs)->bool{
return lhs > rhs ;
});
set s4(s1.begin(), s1.end(), [](int lhs, int rhs){
return lhs > rhs;
});
- set有一个构造函数支持初始化列表和一个比较方法,CTAD依然可以。
- set有一个函数支持一对迭代器和一个比较方法,CTAD依然可以。
- it is cool!!
陷阱3, map:
std::map m{{1, 3.14}, {2, 6.66}};
- 你想要上面的CTAD起效果,推导出m的类型为map<int, double >,但是这不行。
- 编译器不认识里面的东西,{1, 3.14}不是任何类型,编译器无法推断出这是什么。
- CTAD不认识内嵌的初始化列表。
std::map m{std::pair{1, 3.14}, std::pair{2, 6.66}};
- 你需要显式告诉编译器,这种类型是一个pair。你可以只为第一个显式说明是pair,编译器会推导剩下的。
CTAD 和拷贝构造函数
vector v{1, 2, 3};
vector v1{v};
list l{1, 2, 3};
list l1{l};
- 拷贝构造函数也可以搭配CTAD。但是这里也有一个陷阱。
copy wins:
vector v{1, 2, 3};
vector v1{v};
vector v2{v, v1};
- 拷贝构造函数比起初始化列表,有更高的优先级。
- v1回去调用拷贝构造函数,从而推断出v1的类型为vector< int >
- v2的类型为vector<< int >>;
其他与CTAD的搭配
locks && mutexes
before C++17
std::shared_timed_mutex mtx;
std::lock_guard<std::shared_timed_mutex> lock(mtx);
std::lock_guard lock(mtx);
std::scoped_lock lock(mtx);
- 现在不需要为lock_guard写一长串的类型,CTAD会推导。
- 在C++17中,我们还有scoped_lock替代lock_guard,这是更好的。
CTAD && 无参构造
std::less<>{};
std::less{};
- less有一个默认的模板参数,你可以不给参数,CTAD就会采用默认的参数。你可以省略尖括号。
- 如果模板没有默认参数,那么CTAD就会出错。
CTAD && more
std::optional o{10};
std::complex c{1.0, 3.14};
- CTAD也适用于这些值包装器,optional,complex。
CTAD要注意的点
std::complex c{1, 2};
- oh,这可能不是你想要的,你想要的是fioat,或者double。
- 但是CTAD推断的类型是完美符合你给它的类型。所以推断出int。但是C++已经指出complex的模板参数如果是float, double, long double之外的,那么结果是未定义的。
- 所以,要注意,CTAD会默默的拓展你的类模板的接口。
智能指针:
class Person{
public:
Person(std::string name, int id);
};
std::shared_ptr sp(new Person("zzh", 1));
auto sp1 = make_shared<Person>("zzh", 1);
std::unique_ptr up(new Person("zzh", 1));
auto up = make_unique<Person>("zzh", 1);
- CTAD竟然不能用于指针指针,你依然得使用make函数。但是这是好的!CTAD不能用于智能指针是好的!
- make函数创建智能指针更高效,make函数直接分配内存,构造一次。
- make函数是异常安全函数,如果创建智能指针抛出异常,make函数保证异常不会扩散。
- 第三个关于智能指针不能使用CTAD的原因才是主要原因,这跟数组有关。
std::unique_ptr up(new int[10]);
- 如果CTAD可以使用,上面会为up推出什么类型?
- 我的天,由于数组到指针的退化,new会返回一个int指针类型,不是数组类型! 然后up的类型就是unique_ptr< int >,而非unique_ptr< int[] >。
- 然后你的若干操作,包括delete都会调用普通版本的,而非数组版本的。everything都是坏的。
- 所以,当你构造智能指针的时候,不能使用CTAD。你需要显式声明你的类型。
std::unique_ptr<int[]> up(new int[10]);
何时 && 如何 禁用CTAD
- 当CTAD会导致错误或者危险的代码
- 当CTAD不会实例化你想要的东西
- 当CTAD会降低效率
- 当CTAD不会提供一些make函数的特性,例如异常安全
type_identity技法:
template <class T>
class my_smart_ptr{
public:
my_smart_ptr(T* ptr)
:ptr_(ptr){}
T* ptr_;
};
my_smart_ptr msp(new int[10]);
- 上面是一个非常简陋的智能指针,如果我们不禁用CTAD,那么就会将数组变成普通指针,bad。
- 我们的手法是利用type_identity,
template <class T>
struct type_identity{
using type = T;
};
template <class T>
using type_identity_t = typename type_identity<T>::type;
- type_identity是C++20才引入的一个模板元函数,将你传入的类型返回给你。very easy。
template <class T>
class my_smart_ptr{
public:
my_smart_ptr(type_identity_t<T>* ptr)
:ptr_(ptr){}
T* ptr_;
};
my_smart_ptr msp(new int[10]);
-
现在该调用不会编译。 -
当编译器尝试去编译时,采取CTAD,但是它发现了type_identity_t也是一个模板,CTAD不会去实例化另外的模板来推断当前的模板。所以,编译器会停下来,告诉你“sorry,type_identity_t是个模板,我无法实例化这个来推断T的类型,所以,error。” -
这就是我们常说的“non-deduction情况”(此术语来自《C++template》一书)。 -
显式的调用则会起效果,因为type_identity_t< T >就是T。 -
type_identity是相当厉害的手法,它还适用于以下情况:
- 普通函数的限制
template <class T>
void fun(type_identity_t<T> t){}
fun(1);
fun<int>(1)
- 它还可以选择参数限制,就是只限制某些参数的推断。
template <class T, class U>
void fun(type_identity_t<T> t, U d){}
fun(1, 3.14);
fun<int>(1, 3.14)
fun<int, double>(1, 3.14);
typedef手法:
template <class T>
class my_smart_ptr{
public:
using pointer = T*;
my_smart_ptr(pointer ptr)
:ptr_(ptr){}
T* ptr_;
};
my_smart_ptr msp(new int[10]);
- 简单的typedef是不管用的,因为using指代的东西仍然是类内部的类型。
template <class T>
class my_smart_ptr{
public:
using pointer = add_pointer_t<T>*;
my_smart_ptr(pointer ptr)
:ptr_(ptr){}
T* ptr_;
};
my_smart_ptr msp(new int[10]);
- 仅仅需要一步简单的变换,就又变成了non-deduction情况。此时,符合我们的要求。
template member手法:
template <class T>
class my_smart_ptr{
public:
template <class U>
my_smart_ptr(U* ptr)
:ptr_(ptr){}
T* ptr_;
};
my_smart_ptr msp(new int[10]);
- 编译器使用CTAD时,它会说,ok,我能推断出U的类型,但是我无法推断出T的类型,因为你有两个模板参数。虽然我们知道U和T是有关系的,但是在CTAD期间,编译器不会去考虑。
模板元编程技法
std::function
void fun();
struct Test{
void operator(){}
};
std::function f1(&fun);
std::function f2(Test());
std::function f3([](){});
template <class Ret, class ... Args>
function(Ret(*)(Args...))->function<Ret(Args)>;
- std的function提供了这个deduction guide,可以看到,deduction guides的模板可以和该类不同,甚至比该类还要多。
- 如果传入的类型是函数指针,那么推断为函数类型。
deduction guides && SFINAE
- 另外一个关于deduction guide非常重要的就是,它支持SFINAE。
template <class T>
class vector{
template <class Iter>
vector(Iter first, Iter last);
vector(size_t n, const T& value = T());
};
template <class Iter>
vector(Iter, Iter)->vector<typename iterator_traits<Iter>::value_type>;
vector v(1, 2);
- 当编译器尝试去初始化v时,它如果采用deduction guide,但是int没有iterator_traits,触发SFINAE,编译器不会报错,而是默默的将该函数丢弃,去寻找下一下。
- 最终构造了一个大小为1,元素为2的vector。
让我们更细致的考虑:
- 实际上vector的构造函数只适用于Input迭代器,如果你传入一个output迭代器,那么不会奏效。
- 我们也可以使用SFINAE来实现。
template <class Iter>
vector(Iter first, Iter last)
->vector<
enable_if_t<
is_base_if_v<
input_iterator_tag,
typename iterator_traits<Iter>::iterator_catagory>,
typename iterator_traits<Iter>::value_type>>;
- 这是常见的enable_if技术,如果Iter时input_iterator,那么就可以使用该构造函数。如果不是的,那么触发SFINAE。
std::array
template <class T, size_t N>
class array{
T data_[N];
};
array arr{1, 2, 3, 4};
- array的简化实现类似上面,有一个类型模板参数,有一个非类型模板参数,表示array的大小。
- 我们依然可以推导出arr的类型为array<int, 4 >,so,CTAD不可能独立完成这一点,因为它无法推导N为多少,我们需要deduction guide。
template <class T, class ... Args>
array(T, Args...)->array<T, 1 + sizeof...(Args)>;
array arr{1, 2, 3, 4};
array arr1{1, 2.0};
- 这样我们就能推导出N的大小.
- 但是,如果我们传入不同的类型,CTAD会推导出T为int,然后将2.0转换成int,但是这不是我们想要的。我们想让它和初始化列表有一样的行为,让这个失败。
- 怎么样让这个失败?
template <class T, class...Args>
array(T, Args...)->array<
enable_if_t<
conjunction_v<is_same<T, Args>...>, T>, 1 + sizeof...(Args)>;
or
template <class T, class...Args>
array(T, Args...)->array<
enable_if_t<(is_same_v<T, Args> && ...), T>, 1 + sizeof...(Args)>;
trap:
template <class T>
struct Foo{};
template <>
struct Foo<int>{
Foo(int){}
};
Foo f(1);
- 在CTAD的第一阶段,类型推导阶段,编译器只会去考虑主模板。so,即使你的特化模板有一个关于int的构造函数,它也不会去考虑。
- 但是,这不一样是该对象实例化的地方。
template <class T>
struct Foo{
Foo(T){}
};
template <>
sruct Foo<int>{
Foo(const double&){}
};
Foo f(1);
more
auto* p = new std::pair{3, 3.14};
std::mutex mtx;
auto lock = lock_guard(mtx);
std::pair p{3, 3.14};
std::pair& pref{p};
auto& pref{p};
- CTAD不能用于raw的智能指针初始化,但是其他的构造函数依然可以使用CTAD。
auto uptr1 = std::make_unique<Person>("zzh", 1);
std::unique_ptr uptr2{std::move(uptr1)};
std::shared_ptr sptr2{std::move(uptr2)};
- deduction guides 不必匹配任何构造函数。
- deduction guides仅仅用于CTAD的类型推导,它可以和构造函数不一样。因为deduction guides永远不会像函数一样被调用。
template <class T, class Deleter>
shared_ptr(std::unique_ptr<T, Deleter>)->shared_ptr<T>;
template <class T, class Deleter>
shared_ptr(std::unique_ptr<T, Deleter>&& uptr);
- deduction guide会获取一个unique_ptr的拷贝,但是unique_ptr不支持拷贝构造。真正的构造函数是右值引用,但是deduction guide仅仅用于类型推断,它永远也不会调用unique_ptr的拷贝构造。
- 实际上这是一种实现手法,当你有一个构造函数是const 引用,一个是右值引用。
- 那么你不必实现两个deduction guides,一个是const 引用,一个是右值引用。
- 可以只实现一个deduction guide,使用值传递,这个deduction guide适用两种情况。然后推导出类型之后再决定调用哪一个。
- 这样写,更方便。
deduction guides可以不是模板:
template <class T>
struct Foo{
T name_;
Foo(T name) : name_(name) {}
};
Foo f("zzh");
- 上面是一个简单的Foo类,如果你给我一种const char*,我不想要这种类型,我想要string_view,因为string_view更好一些,那么就可以这样
template <class T>
struct Foo{
T name_;
Foo(T name) : name_(name) {}
};
Foo(const char*)->Foo<string_view>;
Foo f("zzh");
- 但是不推荐这样写,因为这样的调用不明显。
- 当我们能够从调用语句中明显的看出来将会推导出什么类型时,才会适用deduction guides。
C++20可能对CTAD的修改
1. aggregates需要显式的deduction guide。
template <class T>
struct Name{
T first, last;
};
Name<std::string> name{"zz", "zh"};
Name name{"zz", "zh"};
template <class T>
struct Name{
T first, last;
};
template <class T>
Name(T, T)->Name<T>;
Name name("zz", "zh");
- aggregates是C++一种特殊的对象,没有显式构造函数,所以成员变量都是public的,还有很多限制。aggregates支持花括号初始化,但是这不是构造函数。
- 没有显式的deduction guides,CTAD不会工作。因为在第一阶段,编译器找不到构造函数。用花括号包起来的不是构造函数,只是一种初始化方式!!所以不会被放入到重载集中。
Name name("zz", "zh");
- 即使有显式的deduction guides,也不支持小括号。
- 在第一阶段,编译器推导出name的类型为const char*。
- 在第二阶段,编译器发现Name没有构造函数,你使用的是小括号,不是花括号,不是aggregate初始化,所以error。
经过我的测试,在C++20中,VS和GCC都支持了aggregate的小括号初始化方式,但是clang仍然不支持。这三者都不支持没有显式deduction guides的花括号初始化。
2,CTAD不支持模板别名
template <class T>
struct Foo{
Foo(T t) {
cout << t;
}
};
template <class T>
using Foo_ = Foo<T>;
int main() {
Foo f(1);
Foo_ f1(1);
return 0;
}
- 经过我的测试,在C++20中,gcc竟然支持了CTAD的模板别名。
- clang和VS都不支持,clang的错误信息非常的明显,
3,CTAD看不到继承的构造函数,你需要显式提供deduction guides。
不支持部分的CTAD,C++20仍不支持。
std::tuple<std::string> t{"zzh", 1, 3.14};
|