函数模板
普通函数其参数是特定类型
利用函数模板我们可以让编译器根据实参推断参数类型
1 2 3
| template <typename T, typename R> void foo(T, R);
|
比如写一个把传进参数+1的函数,我们可以对任意类型调用(如果可以+1的话)
1 2 3 4
| template <typename T> T add_one(T x) { return x + 1; }
|
- 编译期看到调用点,才根据实参 自动生成 对应函数。
- 如果 T 不支持 +1,编译直接报错,而不是运行期崩溃。
我们试着调用add_one
1 2 3 4 5 6 7
| std::cout << add_one(1) << std::endl; std::cout << add_one(2.0f) << std::endl; std::cout << add_one(2.0) << std::endl;
std::cout << add_one(std::string("hello")) << std::endl;
|
类模板
和函数模板如出一辙,可以泛化类型,让我们写一个能构造任意类型数组的一个类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| #include <iostream>
template<typename T, std::size_t N> class MyArray { public: MyArray() { std::cout << "MyArray<" << typeid(T).name() << "," << N << ">\n"; } T data[N]; };
int main() { MyArray<int, 5> a; MyArray<double, 3> b; }
|
模板参数包
模板参数包是接受 任意参数类型和任意参数个数 的一个特性,使用的时候涉及到打包和解包两个操作
函数模板+参数包
我们写一个log函数,打印接收到的所有参数
1 2 3 4 5 6 7 8 9 10 11 12
| #include <iostream> template<typename... Args> void log(Args... args) { ((std::cout << args << ' '), ...); std::cout << '\n'; }
int main() { log(2025, "price=", 19.9, '$'); }
|
上面的 右折表达式 会在编译期把参数包内容展开成前面操作的形式
1 2 3 4 5 6 7 8 9 10
| ((std::cout << arg1 << ' '), ((std::cout << arg2 << ' '), ((std::cout << arg3 << ' '), ...)));
std::cout << a1 << ' '; std::cout << a2 << ' '; std::cout << a3 << ' '; ...
|
接下来我们使用递归调用来设计功能一样的log
需要注意的事,设计递归,就需要终止条件,而递归函数模板也不例外
1 2 3 4 5 6 7 8 9 10
| void log() { std::cout << "[log end]" << std::endl; }
template<typename Head, typename... Args> void log(const Head& h, Args... args) { std::cout << h << std::endl; log(args...); }
|
终止条件必须写在递归函数模板之前,必须让编译器先看到递归终止条件,在递归调用到0参数时,才会调用终止条件的函数;把终止条件写在后面和不写的效果是一样的,都会编译报错,因为编译器找不到0参数函数实例,就会照着模板生成一个,然后发现模板至少需要一个参数(typename Head限制至少需要一个参数),就会生成实例失败
另外,如果存在多个可推导模板,比如上面两个log,一个用折叠表达式,一个用递归,编译器是看哪个模板更特殊决定使用哪个模板的,更特殊大致的意思是“约束更严格、匹配范围更小”,这里选用的是后面的递归形式,因为它限制了至少要有一个参数
类模板+参数包
我们来写一个简单的tuple类,用类模板递归把参数包拆成“头+尾”,这也是标准库std::tuple的核心思想,把数据一层层存储
需要注意的是,设计递归,就需要终止条件,而递归类模板也不例外(重要的事情说两遍)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| #include <iostream>
template<typename... Ts> class Tuple;
template<> class Tuple<> { public: void print() const {} };
template<typename Head, typename... Tail> class Tuple<Head, Tail...> { Head value; Tuple<Tail...> tail; public: Tuple(Head h, Tail... t) : value(h), tail(t...) {} void print() const { std::cout << value << ' '; tail.print(); } };
int main() { Tuple<int, double, char> t(42, 3.14, 'X'); t.print(); }
|
完美转发
完美转发即把收到的实参原汁原味地转给另一段代码,既不掉引用,也不掉 const/volatile`,更不掉右值性
我们先看看普通的传参
1 2 3 4 5
| template<class T> void wrapper(T arg) { foo(arg); }
|
如果我们foo函数只有一个接收右值的定义,上面的代码就会编译报错了
使用std::forward进行完美转发,保留值性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| #include <iostream> #include <utility>
void foo(int& x) { std::cout << "lvalue int& : " << x << '\n'; } void foo(int&& x) { std::cout << "rvalue int&& : " << x << '\n'; } void foo(const int& x) { std::cout << "const lvalue const int& : " << x << '\n'; }
template<class T> void wrapper(T&& arg) { foo(std::forward<T>(arg)); }
int main() { int a = 1; const int b = 2;
wrapper(a); wrapper(b); wrapper(3); wrapper(std::move(a)); }
|
典型应用:工厂函数
1 2 3 4 5
| template<class T, class... Args> T* create(Args&&... args) { return new T(std::forward<Args>(args)...); }
|
函数模板全特化
全特化和偏特化的概念很简单,前者是将全部模板参数定义为特定类型,后者是将部分定义为特定类型
有人可能会好奇,编译器不是会自动推断吗?干嘛还要特化这种东西
其实特化主要是针对特定类型,我们能做的事情不同,意思就是针对特定类型,函数的定义选为另一套,参数类型推导可不会改函数定义,所以这两个是完全不同的东西,别混淆了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| #include <iostream>
template <typename T> void foo(T) { std::cout << "could calculate" << std::endl; }
template <> void foo(bool) { std::cout << "could judgement" << std::endl; }
template <> void foo(const char*) { std::cout << "could read" << std::endl; }
int main() { int a { 1 }; float b { 2.0f}; double c { 3.0 }; bool e { false }; const char* d { "hello" };
foo(a); foo(b); foo(c); foo(d); foo(e); }
|
先提及两个注意点:
- 全特化不参与重载,只是给已经生成的模板实例打补丁。
- 如果把它写成重载函数而不是全特化,行为会不同(重载优先级更高)。
这也是 “模板全特化 vs 重载” 经典面试题。
在上面的代码中加上一个重载函数,那么最终调用的是这个重载函数,打印出”hello”
1 2 3
| void foo(const char* s) { std::cout << s << std::endl; }
|
如果你喜欢先看看目录再看文章,可能发现了函数模板只提到了全特化,没错,因为函数模板不允许偏特化,这和函数重载决议有关系
说白了c++压根没考虑过两个完全一样的签名在重载决议时出现,我们的通用模板和偏特化模板可能会实例化出完全一样的函数签名,这时候就没法抉择了
而全特化的函数模板,在编译器眼里就是一个普通函数,编译器给他改了函数签名让他不参与重载决议,所以函数模板全特化是允许的,在调用点处,编译器会先看有没有普通函数可以用,再用通用模板匹配,匹配推导类型成功后再看有没有全特化的函数,这样全特化函数即使改了签名还是能被调用到
类模板全特化与偏特化
类模板的全特化写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| #include <iostream>
template<class T, class U> class Foo { public: void print() { std::cout << "Universal\n"; } };
template<> class Foo<int, double> { public: void print() { std::cout << "int+double Specialized\n"; } };
int main() { Foo<int, char> f1; Foo<int, double> f2;
f1.print(); f2.print(); return 0; }
|
类模板的偏特化写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| #include <iostream>
template<class T, class U> class Foo { public: void print() { std::cout << "Universal\n"; } };
template<class U> class Foo<int, U> { public: void print() { std::cout << "int+? Specialized\n"; } };
template<class T> class Foo<T, T*> { public: void print() { std::cout << "?+T* Specialized\n"; } };
int main() { Foo<int, char> f1; Foo<int, char*> f2; Foo<float, float*> f3;
f1.print(); f2.print(); f3.print(); return 0; }
|
上面注释了一个f4,如果取消注释就会发现有编译错误,原因是两个偏特化都能推导定义并且编译器无法判定谁更特殊,就不知道用哪个模板生成了,解决方案也很多,去掉一个偏特化或者加一个全特化之类的,这里只是说明一下问题希望用模板时多多思考,规则有点多