12185 字
61 分钟
C++ 模板笔记(一):函数模板、类模板与非类型模板参数
2022-12-14

C++ template#

此笔记应和杂记3的模板相关部分搭配查看。

第一章 函数模板#

https://youtu.be/XN319NYEOcE

0.0 一些深度理解,必须提前知道#

  • 只有函数模板(function template), 没有模板函数(templated function)。类模板同理

  • 函数(主)模板并不是函数。函数模板没有类似于函数的行为。类模板同理

    • 函数主模板本身并不能调用。
    • 我们表面上是调用了函数主模板,但是实际上是编译器推导出(或我们自己指定出)类型后,编译器将所有的T替换为对应类型。然后合成出来的一个新的实例。这就是实例化。
    • 所以,函数主模板本身从不是重载决议候选人(candidate)。候选人是编译器帮助我们替换了T之后的实例化的函数。

QQ截图20221225200646

https://youtu.be/NIDEjY5ywqU

  • 编译器会通过函数的(主)模板,合成(synthesize)一个特化的函数声明(declaration)[其实是签名signature]。然后实例化(instantiate)这个函数的定义(definition)。
    • 它会首先检验函数的调用,通过查看传入的参数或显式指定的参数获取类型。
    • 然后替换所有的占位符T
    • 最后我们得到了一个用于重载决议的候选对象,也就是个函数。

0.1 部分名词解析#

  • 我们知道编译器处理模板代码的时候会进行替换,也就是把每一个T替换成传入的模板参数。这个过程被称之为模板的实例化( instantiation)
  • 一旦我们有了实例化的动作,我们就拥有了一个函数。这个函数被称之为特化(specialization)。在这个时候,T已经被替换为对应类型。特化是特定于类型的。他们关心类型,现在不再是类型不可知得了。类模板也一样
  • 特化过的类模板或函数模板,或说编译器已经通过类模板和函数模板合成出来的附带了特定类型的类或函数的行为和普通的类和函数是一致的。
  • 但是我们依旧可以使用<>来显式指定一个被关联到模板参数的、被进行了特殊替换的声明。比如这样:
    • 为了区分编译器帮助我们进行的特化,针对这种情况,我们称之为显式(全)特化(explicit specialization)
    • 一般来讲,显式特化被认为是通用模板的一种特殊形式。
template<typename T1, typename T2> // 主模板
class MyClass {
//一些内容
};
template<> // 显式全特化。
class MyClass<std::string,float> {
//一些内容
};
  • 如果在显式全特化后,仍需要添加一些模板参数,我们就称之为[显式]部分特化(偏特化)
template<typename T> // 偏特化
class MyClass<T,T> {
//一些内容
};
template<typename T> // 偏特化
class MyClass<bool,T> {
//一些内容
};

0.2 显式实例化(14.5),隐式实例化和特化。#

template<typename T>
void func(T a){
cout << a << endl;
}
template
void func<float>(float a); //2 显式实例化
template
void func<>(int a); //也可以
int main(){
func(10); //1 隐式实例化
func<int>(10); //也叫隐式实例化。没错,显式指明模板参数会导致隐式实例化。
return 0;
}
  1. 这个过程叫做隐式实例化。隐式实例化指的是函数或类模板被使用或调用的时候,由编译器帮助我们处理的过程。因为编译器帮助我们合成了这个函数的声明,实例化了函数的定义。这个func(10)本身是一个特化的函数。
  2. 这个过程叫做显式实例化,也叫做外部实例化。显示实例化的意义在于在不发生函数调用的时候就已经将函数模板实例化,或者在不使用类模板的时候就已经将类模板实例化称之为模板显示实例化。这个void func<float>(float a)函数本身也是一个特化的函数。
    • 显式实例化只需要写声明,不需要写定义。也就是我们通知编译器,直接根据这个声明,实例化这个函数定义。
  • 无论是编译器帮助我们合成后实例化的,还是我们自己显式特化的,只要这个函数有了明确的类型要求,这就是一个特化的函数。(和函数模板相比)
  • 显式特化在上一节。
  • 显示实例化和显式特化的语法区别在于显式实例化的template关键字后不加<>,显式特化的template关键词后要加<>
  • 当一个函数模板被定义在一个头文件中且被多个源文件包含时,每个包含该头文件的源文件都会产生模板的实例化。通常情况下,因为模板是内联的,这不会导致链接错误。然而,如果你显式地实例化了模板函数或者类模板,则可能会遇到链接问题。显式实例化告诉编译器在给定的翻译单元(通常是一个.cpp文件)中创建模板的具体实现。如果在多个源文件中进行了相同模板的显式实例化,那么链接器将报告重复定义错误。当然了, 如果使用了显式实例化, 则在其他需要用到该模板的地方需要使用外部模板声明.
extern template void func<int>(int a);

extern 模板声明用于防止模板在当前翻译单元中被实例化. 这种情况下, 该函数的调用不会导致模板在当前翻译单元中实例化, 因为已经在外部单元实例化过了.

显示实例化的各种方式, 详细例子, 和优缺点分析

什么时候用extern#

通常来说, 我们知道模板 的声明和定义需要写在一起. 如果这样做, 我们最好在外部使用extern template减少文件大小. 原因是所有include了该头文件的翻译单元都会合成一份独立的函数模板, 最后在链接期间丢弃多余的符号. 比如

a.h
#pragma once
#include <iostream>
struct f {
template <typename T>
void ReallyBigFunction();
};
template<typename T>
void f::ReallyBigFunction(){
std::cout << "ReallyBigFunction" << std::endl;
}
// b.cpp
#include "a.h"
#include "iostream"
extern template void f::ReallyBigFunction<int>(); // 注意这里
void something1()
{
f o;
o.ReallyBigFunction<int>();
}
// c.cpp
#include "a.h"
#include "iostream"
void something2()
{
f o;
o.ReallyBigFunction<int>();
}
int main(){
}

微信图片_20240429224802

我们看到图片里, 如果我们在b.o不加extern, 那么b.o和 c.o都生成了同一个函数的符号.

微信图片_20240429224809

如果有extern, 则b.o里的符号变成了U 而c.o里保持不变.

然而如果我们用另一种写法, 也就是.h文件只包含模板声明, 模板定义在.cpp文件中, 同时在.cpp文件中显式实例化所需模板. 这样做的话, 其他文件就不需要使用extern.

a.h
#pragma once
#include <iostream>
struct f {
template <typename T>
void ReallyBigFunction();
};
// a.cpp
#include "a.h"
#include <iostream>
template<typename T>
void f::ReallyBigFunction(){
std::cout << "ReallyBigFunction" << std::endl;
}
template void f::ReallyBigFunction<int>();

显示实例化和隐式实例化在类模板中的差异#

  • 隐式实例化的时候,编译器会合成尽可能少的成员,但是显式实例化会让类模板合成全部的成员。

  • 然而,成员函数可以被单独地显式实例化。

https://youtu.be/XN319NYEOcE?t=3152

QQ截图20230227022726

这里的实例化既代表了过程也代表了最后的实体

最后的总结就是:

  • 我们可以通过显式实例化或隐式实例化的方式达成隐式特化的目标。(这一阶段就是编译器默认合成出来的函数或类)
  • 同时我们也可以通过显式特化的方式来特化一个模板。(这个指的是通过显式指定类型来让编译器和成出函数或类)

0.3 声明和定义#

我们在杂记当中已经解释了什么是声明和定义,这里针对上下文再次强化一下认知:

  • “声明”将一个名称引入或者再次引入到一个 C++作用域内。引入的过程中可能会包含这个名称的一部分类别,但是一个有效的声明并不需要相关名称的太多细节。比如:
class C; // 类C的声明
void f(int p); // 函数f的声明
extern int v; // 变量v的声明。注意这里使用了extern
  • 对于声明 ,如果其细节已知, 或者是需要申请相关变量的存储空间,那么声明就变成了定义。
    • 对于 类类型的定义和函数定义,意味着需要提供一个包含在{}中的主体,或者是对函数使用了=defaut/=delete。
    • 对于变量,如果进行了初始化或者没有使用 extern,那么声明也会变成定义。下面是一些“定义”的例子:
class C {}; // 声明并定义 类C
void f(int p) { //声明并定义函数f
std::cout << p << ’\n’;
}
extern int v = 1; // 因为初始化了,所以这里也是声明并定义了变量v
int w; // 全局变量,就算没有初始化,由于没有使用extern,所以也是声明并定义变量w
  • 如果一个类模板或者函数模板有包含在{}中的主体的话,那么声明也会变成定义。所以
template<typename T>
void func (T); //这个是声明。
template<typename T>
class S {}; //这个是定义。

0.4 模板形参和模板实参#

template<typename T1> // T1 是模板形参,形式上的参数。 parameter
class test;
int main(){
test<int> obj; //int 是模板实参,实际传入的参数。 argument
}

简单来讲可以说:模板参数是被模板实参初始化的。或者更准确的说:

  • 模板参数是那些在模板定义或者声明中,出现在 template 关键字后面的尖括号中的名称。
  • 模板实参是那些用来替换模板参数的内容。不同于模板参数,模板实参可以不只是名称。

0.5 模板名(template-name) 和 模板标识(template-id)#

  • 模板名,顾名思义就是模板的名字。如下面例子的Demo就是模板名。
  • 模板标识,是模板名和其参数列表。也就是整个模板名<形参列表>是一个模板标识。下面的Demo<int>就是模板标识。
template <typename T>
struct Demo{
// ...
};
int main()
{
Demo <int> d; // Demo 是 template name, Demo<int> 是 template-id
// ...
}

https://zh.cppreference.com/w/cpp/language/templates

https://stackoverflow.com/questions/3796558/difference-between-template-name-and-template-id

  • 当指出模板的模板标识 的时候,用模板实参替换模板参数的过程就是显式的,但是在很多情况这一替换则是隐式的(比如模板参数被其默认值替换的情况。一个基本原则是:任何模板实参都必须是在编译期可知的。就如接下来会澄清的,这一要求对降低模板运行期间的成本很有帮助。由于模板参数最终都会被编译期的值进行替换,它们也可以被用于编译期表达式。

0.6 不推导语境#

因为比较分散,所以请在笔记内搜索关键词: 不推导语境

1.1.2 基本信息#

  • 在声明模板变量类型的时候,尽量使用typename而非class。尽管没有区别。

  • 所有的模板类型参数都应该可以被推导或有默认值。不应该无法推导的模板参数,会导致错误。(个人推理)

  • 函数模板有两种类型的参数:

    • 模板参数:位于函数模板名称的前面,在一对尖括号内部进行声明:
    template <typename T>       //T是模板参数
    • 调用参数:位于函数模板名称之后,在一对圆括号内部进行声明:
T max (T const& a, T const& b)   //a和b都是调用参数
  • 一定要让函数模板的所有重载版本的声明都位于它们被调用的位置之前(一般都放在同一个头文件中)

1.1.3 二阶段检查。搭配深度探索对象模型里面的模板一起看。#

  • 模板的工作原理,并不是把模板编译成一个可以处理任何类型的单一实体;而是对于实例化模板参数的每种类型,都从模板产生(合成)出一个不同的实体。

在实例化模板的时候,如果模板参数类型不支持所有模板中用到的操作符,将会遇到编译期错误。

但是在定义的地方并没有遇到错误提示。这是因为模板是被分两步编译的。所以我们可以说:在实例化时,模板被编译了两次,分别发生在模板定义阶段(实例化之前),和模板实例化阶段(实例化期间)

  • 在模板定义阶段,模板的检查并不包含类型参数的检查。只包含下面几个方面:

    • 语法检查。比如少了分号。
    • 使用了未定义的不依赖于模板参数的名称(类型名,函数名,…)。
    • 未使用模板参数的 static assertions。
    • 简而言之,就是先检查模板代码本身,查看语法是否正确;在这里会发现错误语法,如遗漏分号等。
  • 在模板实例化阶段,为确保所有代码都是有效的,模板会再次被检查,尤其是那些依赖于类型参数的部分。

    • 检查模板代码,查看是否所有的调用都有效。在这里会发现无效的调用,如该实例化类型不支持某些函数调用等。

举例:

template<typename T>
void foo(T t)
{
undeclared(); // 如果 undeclared()未定义,第一阶段就会报错,因为与模板参数无关
undeclared(t); //如果 undeclared(t)未定义,第二阶段会报错,因为与模板参数有关
static_assert(sizeof(int) > 10,"int too small"); // 与模板参数无关,总是报错
static_assert(sizeof(T) > 10, "T too small"); //与模板参数有关,只会在第二阶段报错
}

需要注意的是,有些编译器并不会执行第一阶段中的所有检查。因此如果模板没有被至少实例化一次的话,你可能一直都不会发现代码中的常规错误。

1.1.4 编译和链接#

  • 由于 C++中使用的是静态模板的机制,所以当使用函数模板,并且引发模板实例化的时候,编译器(在某时刻)需要查看模板的定义。这就不同于普通函数中编译和链接之间的区别,因为对于普通函数而言,只要有该函数的声明(不需要定义),就可以顺利通过编译。因为在这里需要的是定义,所以可以考虑在头文件内部实现每个模板以使编译器能够顺利的找到模板的定义。

  • 假设我们有一个模板类的.h和一个.cpp文件,还有一个使用了模板类的.cpp文件,这个文件include了.h文件而不是.cpp文件。首先头文件不编译。声明被拷贝至使用的文件。这时候我们使用了这个模板。由于C++是分离编译。所以在当前文件下没有问题,会留下函数符号让链接器去寻找。然后我们开始编译模板的.cpp文件。模板是二段式编译。也是由于是分离编译,但是在模板的.cpp中,我们只有函数定义但是没有使用函数。所以只会进行第一段也就是检查是否有错误。因为没有实例化所以根本不会进行第二段的编译。所以这时候我们使用的文件中就会有无法解析的外部符号了。

  • 所以这个时候我们要么把.h和.cpp文件放到一起写然后include .hpp文件,

  • 要么就在在使用文件中不include .h 而是include .cpp

1.2 函数模板参数类型推断#

没啥特别要说的,注意两点:

  • 一个是模板形参是万能引用T&&的时候触发的引用折叠。
  • 另一个是利用函数参数自动推导出的模板参数右值引用,只可能推导出左值引用或者基本类型。这点看下面的推导表格就能发现,T永远不会被推导成&&。都是推导成非引用或左值引用版本,然后和T后附加的&或&&形成引用折叠
    • 注意是永远不会推导出而不是永远不会成为。这也说明了为什么std::forward在一般情况下触发的都是左值版本,除非显式指明为右值引用如forward<int&&>(5)。参考杂记1

1.2.1 函数模板参数类型推导中的类型转换#

下文的A是实参类型,P是形参类型。

  • 在类型推断的时候自动(隐式)的类型转换是受限制的。因为类型转换是重载决议做的事情,类型推断不负责。而且类型推导发生在重载决议前面。(所以如果它通过入参推导出来的类型和函数形参不匹配,就不行):
    • 首先实参的引用属性被忽略(A 的引用属性被忽略.)
    • 如果 形参是有 cv 限定的类型,那么推导时会忽略顶层 cv 限定符
    • 如果调用参数是按引用传递的,任何类型转换都不被允许。通过模板类型参数 T 定义的两个参数,它们实参的类型必须完全一样。每个 T 都必须正确匹配。
    • 如果形参是引用类型,那么用形参所引用的类型推导。
    • 如果调用参数是按值传递的(形参是非引用的时候),那么只有退化(decay)这一类简单转换是被允许的:(和杂记里面的函数模板参数推导一样)
      • const 和 volatile 限制符会被忽略 (实参 的 顶层 cv 限定符被忽略.)
      • raw array(原始数组) 和函数被转换为相应的指针类型。(如果 A 是数组或函数, P 是值时, 数组和函数退化为指针. )
      • (隐藏,此处不相干,但还是放在这里。和杂记中的函数模板参数推导一样)如果 P 是无 cv 限定符的转发引用 (即 T&&), 且 A 是左值时, T 被推导为左值引用.
      • 通过模板类型参数 T 定义的两个参数,它们实参的类型在退化(decay) 后也必须一样
      • 总结就是如果P是引用类型则不退化。P不是引用类型则发生上面的退化。

通常而言,你必须指定最后一个不能被隐式推导的模板实参之前的所有实参类型。也就是我们必须显式指定所有模板参数的类型,直到某一个模板参数的类型可以被推断出来为止。

然而,模板的实参推导并不适合返回类型(可以把推导看成是重载解析的一部分—重载解析是一个不依赖于返回类型选择的过程,唯一的例外就是转型操作符成员的返回类型)。

这里搭配7.2看。

举个例子:

template<typename T>
struct typewrapper{
typewrapper(T args){
}
};
void foo(typewrapper<tmp> f){
std::puts(__PRETTY_FUNCTION__);
cout <<"called" << endl;
}
template<typename... T>
void foo0(typewrapper<T...> f){
std::puts(__PRETTY_FUNCTION__);
cout <<"called" << endl;
}
void foo1(function<void(int, int, int)>){
cout <<"called" << endl;
}
template<typename... Args>
void foo2(function<void(Args...)>){
cout <<"called" << endl;
}
template<typename... Args>
void foo3(Args... F ){
cout <<"called" << endl;
}
int main(){
foo(typewrapper<tmp>(tmp{}));
foo(tmp{});
foo0(typewrapper<tmp>(tmp{}));
foo0(tmp{}); //不行
foo1(function<void(int, int, int)>(callablefunc));
foo1(callablefunc);
foo2(function<void(int, int, int)>(callablefunc));
foo2(callablefunc); //不行
foo3(function<void(int, int, int)>(callablefunc));
foo3(callablefunc); //可以
}
  • 为啥foo0和foo2的第二个都不行呢?因为参数类型推导中有冲突。但是foo和foo1都是非模板函数,或者说,参数推导没有冲突。比如foo3。这样的都可以。

    • 因为,foo0的参数类型涉及到推导的部分应该是typewrapper模板的模板参数类型。所以T会被推导为tmp类型。但是这个foo0函数的参数所要求的类型应该是typewrapper<T>类型,两者类型不匹配。

      • 也就是:T类型被推导为tmp,所以函数要求形参为typewrapper<tmp>类型,但是传入的参数类型为tmp类型。
    • foo2同理。foo2的参数类型涉及到推导的部分应该是std::function模板的模板参数类型。所以Args...会被推导为int, int, int类型。但是这个foo2函数的参数要求的类型应该是function<void(Args...)>类型,两者类型不匹配。

      • 也就是:Args...类型被推导为int, int, int,所以函数要求形参为function<void(int, int, int)>类型,但是传入的参数类型为void(*)(int, int, int)类型。
    • 至于foo3为什么可以,因为foo3的Args...可以被推导为任何可能的类型,不涉及到需要类型转换的部分。比如如果传入函数指针,则会被推导为void (*)(int, int, int)。如果传入std::function对象,则会被推导为std::function<void(int, int, int)>

QQ截图20230512231804

注意区别显式指定模板实参类型(不推导)和显式全特化#

显式全特化是替换定义。

显式指定模板的实参类型是这样:

template <typename T>
void func(T a, T b){
cout <<"success" << endl;
}
int main() {
long lng = 2000;
func<int>(lng, 2); //显式指定模板实参类型
}
  • 我们显式指定模板实参类型就等于避免了参数类型推断。所以可以隐式转换了!

比如上面的函数

func(lng, 2);//不行。一个是long一个是int。但是只有一个T类型
func<int>(lng, 2); //可以。显式指定T为int,lng被隐式转换了。

1.2.2 对默认调用参数的类型推断#

需要注意的是,类型推断并不适用于默认调用参数。例如:

template<typename T>
void func(T a = 12){
cout << "called" << endl;
}
int main(){
func(1); //OK 没问题 推导为int
func(); //编译器发出抱怨,甚至想骂人。
return 0;
}

为应对这一情况,你需要给模板类型参数也声明一个默认参数

template<typename T = int> //注意这里
void func(T a = 12){ //这里
cout << "called" << endl;
}
int main(){
func(); //OK
return 0;
}

注意,给定的模板默认参数应该和函数默认参数的类型一致。

1.3 多个模板参数#

好像是废话,但是水还是蛮深的。我们一起看一下。

template<typename T1, typename T2>
T1 func(T1 a, T2 b){
return b < a ? a : b;
}
int main(){
auto ret = func(1,2.345);
return 0;
}
  • 看上去就和我们想的一样,它可以接受两个不同类型的调用参数。但是如示例代码所示,这也导致了一个问题:
    • 如果你使用其中一个类型参数的类型作为返回类型,不管是不是和调用者预期地一样,当应该返回另一个类型的值的时候,返回值会被做类型转换。这将导致返回值的具体类型和参数的传递顺序有关。
    • 例子:如果传递 2.345 和 1 给这个函数模板,返回值是 double 类型 的 2.345,但是如果传递 1 和 2.345,返回值却是 int 类型的 2。因为我们固定返回较大的数字。但是返回值类型会被进行隐式转换。

所以我们有三个方法解决这个问题:

  1. 引入第三个模板参数作为返回类型。
  2. 让编译器找出返回类型。
  3. 将返回类型定义为两个参数类型的“公共类型。

1.3.1 做为返回类型的模板参数#

我们还记得模板有两种参数:模板参数和调用参数。

  • 当模板参数和调用参数之间没有必然的联系,且模板参数不能确定的时候,就要显式的指明模板参数。比如你可以引入第三个模板来指定函数模板的返回类型
template<typename RT, typename T1, typename T2>
RT func(T1 a, T2 b){
cout << "called" << endl;
}
  • **但是模板类型推断不会考虑返回类型,而 RT 又没有被用作调用参数的类型。因此 RT 不会被推导。**这样就必须显式的指明模板参数的类型。比如:
template<typename RT, typename T1, typename T2>
RT func(T1 a, T2 b){
cout << "called" << endl;
}
int main(){
func<int>(1,2.345); //注意。后面两个可以省略。因为可以被推导。
return 0;
}

通常而言,我们必须显式指定所有模板参数的类型,直到某一个模板参数的类型可以被推断出来为止。

1.3.2 利用auto,decltype 和(可选的)尾置返回类型进行返回类型推导#

C++14开始可以使用auto和decltype 搭配(可选的)尾置返回类型对返回值类型进行推导。

template<typename T1, typename T2> //不使用尾置返回类型
auto func(T1& a, T2& b){
return b < a? a: b;
}
template<typename T1, typename T2> //使用尾置返回类型
auto func(T1& a, T2& b) -> decltype(b<a? a:b){
return b < a? a: b;
}
  • 在不使用尾置返回类型(trailing return type)的情况下将 auto 用于返回类型,要求返回类型必须能够通过函数体中的返回语句推断出来。当然,这首先要求返回类型能够从函数体中推断出来。因此,必须要有这样可以用来推断返回类型的返回语句,而且多个返回语句之间的推断结果必须一致。
  • 注意decltype推导过程是在编译期完成的,并且不会真正计算表达式的值。
  • 此处要注意可能返回值类型可能会被推导为引用类型。请查看下面的文章。

decltype过于复杂,可以看这里decltype详解

尾置返回类型的 auto#

尾置返回类型的 auto是占位符,所以:

auto (*fp)() -> auto = f;

这个情况下,第一个auto仅仅是占位符,语法层面的表达。只有第二个auto才会进行类型推导。来自这里

1.3.3 利用std::common_type将返回类型声明为公共类型(common type)#

就是获得两个类型的公共类型。

1.4 模板默认参数#

  • 可以在模板默认参数中使用common_type或者是decay来获取默认值。比如
template<typename T1, typename T2, typename RT = std::decay_t<decltype(true ? T1() : T2())>>
//也可以这样typename RT = std::common_type_t<T1,T2>>
RT max (T1 a, T2 b){
return b < a ? a : b;
}
  • 和函数默认值不同,在函数模板参数中,即使后面的模板参数没有默认值,我们依然可以让第一个模板参数有默认值。并且此时调用时可以不显式指定<>
template<typename RT = int, typename T1, typename T2>
RT func(T1 a, T2 b){
return a;
}
int main(){
auto c = func(1,2.234); //并且此时调用时可以不显式指定<>
return 0;
}

1.5 函数模板重载和显式全特化#

像普通函数一样,函数模板也是可以重载的。也就是说,你可以定义多个有相同函数名的函数,当实际调用的时候,由 C++编译器负责决定具体该调用哪一个函数。即使在不考虑模板的时候,这一决策过程也可能异常复杂。

  • 和普通函数一样,函数模板可以被重载。不止可以被重载,还可以进行特化。但是函数模板只能全特化。

    • 决议顺序:普通函数 > 主模板(未特化的模板) > 全特化版本。因为越特化的可能越是需要的
      • 当两个都为模板版本,则调用更特化的模板函数
      • 因为越特化的可能是越实际要求的。
  • 如果函数名指名了某个函数模板,那么首先进行模板实参推导
  • 如果它成功,那么将会生成一个单独的模板特化并添加到所要考虑的重载集合中。
    • 这一步骤结合0中提到的,通过函数模板合成一个函数,然后添加进重载决议候选人集合中
  • 如果集合中有多于一个函数与目标匹配,且至少一个函数是非模板,那么从考虑集合中去除模板特化。
    • 这一步是普通函数优先于模板合成出的函数。
  • 如果所有剩余候选者都是模板特化,那么当存在更特殊的模板特化时,移除较不特殊者。如果在各项移除之后还有多于一个候选者,那么程序非良构。
    • 这一步是如果剩余的函数都是模板函数,那么最特化的优先。如果在这之后还是多个函数,则会有隐含问题。

关于重载决议和匹配,写在文末。

  • 如果函数模板和普通函数都可以实现(同名的函数模板可以被实例化为与非模板函数具有相同类型的调用参数),在所有其它因素都相同的情况下,优先调用普通函数而不是从模板实例化出来的函数。

  • 如果模板可以实例化出一个更匹配的函数,那么就会优先调用模板函数。(!!!!!!比如如果普通函数涉及到任何级别的类型转换的时候,如果模板能直接合成出一个完美匹配的函数模板,则此时会调用模板合成的函数!!!!!!)

  • 可以使用空模板参数列表<>来强制调用函数模板

  • 在模板参数推断时不允许自动类型转换,而常规函数是允许的。

  • 当有多个特化的函数模板可以被匹配的时候,会有二义性导致报错。

  • 函数模板显式全特化不能包含默认实参值。然而,对于被特化的模板所指定的任何默认实参,显式特化版本都可以应用这些默认实参值(其参数默认值会被保留)。

template<typename T>
T f(T a, T b = 42)
{
cout <<"general" << endl;
cout << b << endl;
return b;
}
template<>
float f <float>(float a, float b = 42.2) // 错误
{
cout <<"special" << endl;
cout << b << endl;
return b;
}
template<>
double f <double>(double a, double b) // 可以
{
cout <<"special" << endl;
cout << b << endl;
return b;
}
f(4); //输出general 42
f(4.444); //输出special 42
  • !!!注意,只有非模板和主模板重载参与重载决议。显式全特化并不是重载也从不参与重载决议,因此此时不受考虑。只有在重载决议选择最佳匹配的主函数模板后,才会考虑它的全特化版本以查看最佳匹配者。
    • 因为显式全特化的函数模板没有独立名称。也就是不引入名称。因为它只是一个替换的定义,而不是一个替换的声明。
    • 显式全特化不是重载。
template<class T>
void f(T); // #1:所有类型的重载
template<>
void f(int*); // #2:#1 的特化,针对指向 int 的指针
template<class T>
void f(T*); // #3:所有指针类型的重载
f(new int(1)); // 调用 #3,虽然 #1 的特化是完美匹配
void test(int a, int b){
cout << "普通函数" << endl;
}
template <typename T>
void test(T a, T b){
cout << "模板函数" << endl;
}
template <typename T>
void test(T a, T b, T c){
cout << "重载的模板函数" << endl;
}
template<>
void test<int>(int a, int b, int c){
//void test(int a, int b, int c){ //可以去掉 <int>
cout <<"全特化" << endl;
}
int main(){
test(1,2); //输出普通函数
test<>(1,2); //使用空模板参数列表 输出模板函数
test(1.1,2.2,3.3); //输出重载的模板函数
test(1,2,3); //输出全特化
test<int>(1,2,3); //输出全特化
return 0;
}
  • 为什么后两个都输出全特化?因为他们都是int。多个候选者都为模板的时候,更特化的优先。

    • 目前只有两个模板可以接受三个参数。而此时int可以匹配到下面的全特化版本,所以全特化版本优先。
  • 倒数第三个因为是float。这样由于模板匹配的优先级大于类型转换。所以会被匹配到重载的模板函数。

  • 通常而言,在重载模板的时候,要尽可能少地做改动。你应该只是改变模板参数的个数或者显式的指定某些模板参数。比如不要一会儿值传递一会儿引用传递。

  • 函数的全特化是非常不好的设计。全特化提供的是一个替换的定义,而不是一个替换的声明。在调用函数模板的时点,该调用已经完全基于函数模板而完成解析了 [16.3.2]。所以:

    • 无法在没有主模板的时候对函数模板进行全特化。

      • 再次重申全特化的函数没有新的mangled的名字。也就是全特化函数没有独立名字。
    • 类成员函数的全特化必须写在类外。

  • 如果是分文件编译,为了预防重定义导致的链接错误,必须把全特化的函数模板定义为inline防止重定义。

    • 因为在链接器的眼里,函数模板在全特化之后和常规函数是一样的。

函数模板重载的例子#

下面的重载都是合法的

//允许参数顺序(因为类型顺序不同)不同
template<typename T1,typename T2>
void func(T1, T2){
}
template<typename T1,typename T2>
void func(T2, T1){
}
//允许返回值类型不同
template<typename T1>
int func1(T1){
}
template<typename T1>
char func1(T1){
}
//允许模板形参个数不同,允许函数形参个数不同
template<typename T1,typename T2>
void func2(T1, T2){
cout <<"2 typename" << endl;
}
template<typename T1>
void func2(T1, T1){
cout <<"1 typename " << endl;
}
template<typename T1>
void func2(T1){
cout <<"single param" << endl;
}

https://blog.csdn.net/qq_41453285/article/details/104447573

关于不要全特化函数和函数全特化的问题写在了最后面#

所谓的类型转换和模板参数推导的关系#

我们之前说过, 在模板参数推断时不允许自动类型转换,而常规函数是允许的。

template<typename T>
void func(T A, T B){
std::puts(__PRETTY_FUNCTION__);
}
int main(){
func(123.1, 2); //不可以
func<int>(123.1, 2); //可以
return 0;
}

但是为什么上面的第二种就可以呢?

关键在于 在模板参数推导时不允许隐式类型转换。也就是当推导T的过程中,如果发现类型不匹配,不允许在这个时候发生隐式类型转换(把参数换成其他类型)。原因之前提到过,也就是类型转换是重载决议负责的,而不是参数推导负责的。所以第一种不可以,因为推断的时候通过第一个参数推断出T是double,但是第二个参数推断出T是int,发生了冲突,而此时不可以把第二个参数类型隐式转为double。

为什么第二行可以呢?是因为我们指明了模板参数。这个时候相当于跳过了推导这一阶段,因为我们通过显式指定模板参数的方式隐式实例化了对应函数。然后进行重载决议的时候就可以进行类型转换了。所以:

template<typename T>
struct typewrapper{
typewrapper(T args){
cout <<"const" << endl;
}
operator T(){
cout <<"converted" << endl;
return T();
}
};
class tmp{
public:
string _text;
tmp() = default;
tmp(string c): _text(c) {} //单参构造,可以看做转换构造
};
template<typename T>
void foo3(T A, T B){
std::puts(__PRETTY_FUNCTION__);
cout <<"called" << endl;
}
int main(){
foo3<typewrapper<tmp>>(typewrapper<tmp>{tmp{}}, tmp{}); //可以
foo3<tmp>(typewrapper<tmp>{tmp{}}, tmp{}); //可以
foo3(typewrapper<tmp>{tmp{}}, tmp{});//不行
}

所以我们如果没有指明模板参数,则根据推导规则,类型有冲突且不能通过隐式类型转换解决问题。但是前面两个我们指明了模板参数,跳过了推导这一过程,则直接进入重载决议这一阶段就可以进行类型转换。

QQ截图20230512231811

第二章 类模板#

  • 类模板的声明:
类模板的声明:
template<typename T>
class Stack{
Stack(Stack<T> const &);
~Stack();
Stack<T> operator=(Stack<T> const &);
};
  • 区分两种写法:类的类名 Stack、类的类型 Stack<T>
    • 当在声明中需要使用类的类型时,你必须使用 Stack<T>(大部分情况)
    • 然而当使用类名而不是类的类型时,就应该只用 Stack(比如指定类的名称、构造和析构函数名)
  • 类模板可以偏特化也可以全特化。
    • 注意,特化的部分我们塞进去的是实参argument。这部分非常关键
template<未被特化的部分>
class obj<特化的部分, 未被特化的部分>{
...
};
template<typename T1, typename T2>
class test{
T1 data1;
T2 data2;
};
//全特化
template<>
class test<int, float>{
int data1;
};
//偏特化
template<typename T2>
class test<int, T2>{
int data1;
};
  • 注意,特化的类模板不能改变整体的模板形参数量。也就是在特化的时候,我们不可以改变特化的模板实参的数量
template<typename T1>
class test;
template<typename T1, typename T2> //这样做错误 ERROR。主模板只有一个形参。但是特化的时候我们塞进去了俩。不行。
class test<T1, T2>{//改变了特化的模板实参数量。错误
};
int main(){
test<int, double> obj
}
  • 类模板调用顺序: 对主版本模板类、全特化类、偏特化类的调用优先级从高到低进行排序是:全特化类>偏特化类>主版本模板类。这样的优先级顺序对性能也是最好的。

  • 只有那些被调用的成员函数,才会产生这些函数的实例化代码。对于类模板,成员函数只有在被使用的时候才会实例化。

    • 因为在编译阶段,编译器无法确认模板的参数类型,所以无法创建模板类成员函数
    • 显然,这样可以节省空间和时间;另一个好处是对于那些 未能提供所有 “ 成员函数中所有操作的类型“,你也可以使用该类型来实例化类模板,只要对那些 未能提供 “ 某些操作的” 成员函数,模板内部不使用就可以。而且现在的 C++标准要求编译器要尽可能的延迟实例化的时机。
  • 静态成员的无条件实例化:如果类模板中含有静态成员,那么用来实例化的每种类型,都会实例化这些静态成员。

  • 类模板不能被重载

2.6 多模板参数的偏特化#

偏特化有多种形式。下面的几种都可以。但是注意约束:

  • 类模板偏特化的形参参数个数是可以和主模板不一样的,它既可以多于主模板,也可以少于主模板。但是偏特化的实参必须与主模板对应的参数相匹配。------出自16.4章
    • 非常重要!!在特化类模板的时候,可以改变特化版本的形参列表的参数数量。但是最终落实(传入/应用)到实参上面的时候,参数数量不可变。
    • 此外,显式书写的模板实参数量与主模板的模板参数数量甚至也可能不同。尤其会在拥有默认模板实参或拥有可变模板时发生
  • 偏特化的参数列表不能具有默认实参;作为替代,主类模板的默认实参会被使用。
  • 偏特化的非类型实参要么是一个非依赖型值,要么是一个普通的非类型模板参数。它们不能是更加复杂的表达式,诸如2*N(N是一个模板参数)。
  • 偏特化的模板实参列表不应该与主模板的参数列表完全相同(忽略重命名)。
  • 如果模板实参的某一个是包展开,那么它必须位于模板实参列表的最后。
template<typename T1, typename T2>
class MyClass {
//主模板
};
template<typename T>
class MyClass<T,T> {
//两个参数同一个类型。
//注意这里不一定非得写T1 T1。类型具体叫什么没所谓
//特化模板的形参列表的参数数量可以变。但是实参列表的参数数量不可变。主模板是两个形参,我们就要传入两个实参。
};
template<typename T>
class MyClass<T,int> {
//第二个参数是int
};
template<typename T1, typename T2>
class MyClass<T1*,T2*> {
//两个参数为指针。
};
  • 我们之前提到过特化的时候塞进去的是实参argument。塞进去的实参必须和主模板的形参数量匹配。为什么这么重要?我们理解一下:
template<typename T1> //主模板只有一个形参
class test;
//----------VERSION 1---------------------
template<typename T1, typename... T2>
class test<T1, T2 ...>{ //实参数量不匹配
};
int main(){
test<int,float, double> obj;
}
//-----------VERSION 2----------------------
template<typename T1>
class test;
template<typename T1, typename... T2> //形参可以不同但是实参必须匹配
class test<T1(T2 ...)>{//实参匹配
};
int main(){
test<int(float, double)> obj; //实参和实参必须数量对应。
}
  • 为啥version1不行,version2可以?

我们说了,特化的时候必须匹配主模板的模板形参数量。我们version 1塞进去了两个模板实参。这样发生了问题。

version2为啥可以?尽管我们特化的时候,针对这个特化的模板形参是两个。但是我们在实际传入实参的时候,T1 和 T2...被合成了一个函数类型T1(T2...)。这一整个打包起来会变成主模板眼中的T1。注意这是函数类型并非函数指针类型

  • 所以说甚至可以对成员指针进行特化:
template<typename T>
class List {//主模板
};
template<typename T, typename C>
class Class<T* C::*> { //针对成员指针的特化。T* C::*在主模板眼里是它的整个T
};
  • 为什么我们main函数中的实例化也要写成int(float, double)的形式?因为实参和实参必须相匹配。举个最简单的例子:
template<typename T1, typename T2>
class MyClass {
//主模板
};
template<typename T>
class MyClass<T,T> {
};

这是我们提到的例子。我们实例化的时候应该怎么写?是不是应该写Myclass<int, int>这种形式的?不能写Myclass<int>对吧?

  • 为啥我这么关心这个?因为这是packaged_task和function的模板实现方式
template< class >
class packaged_task; //主模板
template< class R, class ...ArgTypes >
class packaged_task<R(ArgTypes...)>; //特化。
template< class >
class function; //主模板
template< class R, class... Args >
class function<R(Args...)>;//特化。

2.7 类模板默认参数#

类模板自然可以有默认参数。比如很多STL的实现。下面是一个小例子

template<typename T1, typename T2 = int>
class obj{
public:
obj(T1 a, T2 b):val1(a), val2(b){};
T1 val1;
T2 val2;
};

2.8 类型别名 Aliases#

一般来说,我们有两种方式可以给类型定义别名。using 和 typedef

  • typedef

    template<typename T>
    class myobj{
    public:
    myobj(T a):val(a){};
    T val;
    };
    typedef myobj<int> INTmyobj;
    typedef myobj<double> DOUBLEmyobj;
    int main(){
    INTmyobj im(2);
    DOUBLEmyobj dm(2.345);
    cout << im.val << endl;
    cout << dm.val << endl;
    return 0;
    }
  • using

using usingINTmyobj = myobj<int>;
using usingDOUBLEmyobj = myobj<double>;
usingINTmyobj im(2);
usingDOUBLEmyobj dm(2.345);

2.8.1 别名模板 Alias Templates#

注意。这里是typedef和using在别名方面唯一区别。这里只有using。typedef不可以。

比如我们不可以:

template<typename T>
typedef myobj<T> intobj;

但是可以:

template<typename T>
using intobj = myobj<T>;
intobj<int> myobj(2);

2.8.2 必须显式使用typename的情况#

参看笔记STL2

2.9 类模板的类型推导#

直到C++17,我们都必须显式指出所有类模板参数的类型。除非拥有默认值。

剩下的看书。

2.10 聚合类型的模板化#

可以定义聚合类的类模板。关于聚合类,参考聚合初始化。

2.11 类模板成员函数或变量的类外实现[自己添加]#

基本类代码

template<typename T>
class test{
public:
T val;
static T s_val;
void func(T val);
T func1();
void func2();
};

类静态成员变量#

template<typename T>
T test<T>::s_val = 102;
int main(){
test<int> obj;
test<double> obj2;
cout << obj.s_val<< endl; //输出102
cout << obj2.s_val << endl; //输出102
obj2.s_val = 12345;
cout << obj.s_val<< endl; //输出102
cout << obj2.s_val << endl; //输出12345
return 0;
}
  • 类静态成员变量在模板类中依旧遵循全类共享。但是注意这里是实例化后的全类共享。也就是T为int的test和T为double的test是两种类类型。自然二者是独立开来的。因为会分别实例化代码。

类成员函数#

template<typename T>
T test<T>::s_val = 102;
template<typename T>
void test<T>::func(T val){
cout <<"func" << endl;
}
template<typename T>
T test<T>::func1(){
cout <<"func1" << endl;
}
template<typename T>
void test<T>::func2(){
cout <<"func2" << endl;
}
  • 可以看见,必须加模板头,且必须在指明类作用域的时候添加T
    • 也就是不能test::func 必须是test<T>::func

第三章 非类型模板参数#

杂记3有写。这里重新整理

3.1 类模板的非类型模板参数 - 3.2 函数模板的非类型模板参数#

template<typename T>
void func(T obj){
//...
}
int main(){
func<int>(5);
return 0;
}
  • 我们都知道模板参数一般都是类型。比如这里,T就是int。是类型。当我们希望传入一个不是类型的参数的时候,比如如果我们有时候需要一些特殊情况, 例如想要传点奇怪东西的时候就可以使用非类型模板参数。
template<typename T, int MAXSIZE> //注意语法。这里非类型模板参数不再是typename了
void func1(T obj){
vector<T>a;
a.reserve(MAXSIZE);
cout << a.capacity() << endl;
a.push_back(obj);
cout << a[0] << endl; //干啥了不解释了 忽略即可
}
  • 这里的int MAXSIZE就是非类型模板参数。因为他不是类型,而是变量。

使用非类型模板参数是有限制的。通常它们只能是:

  1. 整型常量或字面值(包含枚举,或可隐式转换的比如bool)(string double都不可以。前者是类对象,后者是浮点数)
  2. 指针类型
  3. 成员指针类型
  4. 对象/函数的左值引用
  5. std::nullptr_t
  6. 包含auto或decltype(auto)的类型 [C++17后]

当传递对象的指针或者引用作为模板参数时,对象不能是字符串常量,临时变量或者数据成员以及其他子对象。由于C++17之前,C++每次版本更新都会放宽以上限制,因此还有一些针对不同版本的限制:

  • C++11中,对象必须要有外部链接
  • C++14中,对象必须是外部链接或者内部链接

所以:传入的s必须是常量。

const int s = 8; //必须是const
func1<int, s>(5);
func2<int, 4>(5); //或者直接传入字面值。

搭配类模板偏特化的小例子#

template<typename T, bool option>
class myclass; //主模板不实现
template<typename T>
class myclass<T, true>{ //偏特化1,注意语法。T在这里依旧要写上。
public:
void func(){
cout <<"true one" << endl;
}
};
template<typename T>
class myclass<T, false>{ //偏特化2,注意语法。T在这里依旧要写上。
public:
void func(){
cout <<"false one" << endl;
}
};
int main(){
const bool myoption = true;
myclass<int, false> obj; //直接使用字面值
obj.func();
myclass<int, myoption> obj1; //或必须用const常量变量。
obj1.func();
return 0;
}
  • 两者的非类型模板参数都可以指定默认值:
template<typename T, int TS = 5> //注意语法。这里非类型模板参数不再是typename了
class myobj{
public:
myobj(T a):val(a){my_vec.reserve(TS);};
T val;
vector<T> my_vec;
};
template <typename T, int TS = 10> //注意语法。这里非类型模板参数不再是typename了
void func(T a){
vector<T> my_vec(TS);
cout <<"called" << endl;
cout << my_vec.capacity() << endl;
}
int main(){
myobj<int,8> instance(10);
cout << instance.my_vec.capacity() << endl;
//输出8
func<int, 9>(2);
//输出 called 9
return 0;
}

3.3 避免无效表达式#

非类型模板参数可以是任何编译器表达式。比如:

//函数模板
template <int size, bool judge> //注意表达式不写在这。
void func(){
cout << size << endl;
if(judge == true){
cout << "true" << endl;
}
else{
cout << "false" << endl;
}
}
//类模板
template <int size, bool judge>
class myobj{
public:
myobj(){
my_vec.reserve(size);
testfunc();
};
void testfunc(){
cout << my_vec.capacity() << endl;
if(judge == true){
cout << "true" << endl;
}
else{
cout << "false" << endl;
}
}
vector<int> my_vec;
};
int main(){
func<10, sizeof(int) == 4>(); //注意表达式写在这。写在实例化位置而非模板参数位置。
//输出true
func<10, (sizeof(int) > 4 )>(); //注意这里表达式要额外一组括号。
//输出false
myobj<10, sizeof(int) == 4> instance;
//输出10 true
myobj<10, (sizeof(int) > 4 )> instance1;
//输出10 false
return 0;
}
  • 不过如果在表达式中使用了 operator >,就必须将相应表达式放在括号里面,否则>会被作为模板参数列表末尾的>,从而截断了参数列表

3.4 使用auto做为非模板类型参数 (C++17)#

暂略。看书。

C++ 模板笔记(一):函数模板、类模板与非类型模板参数
https://sterben-01.github.io/posts/模板/
作者
Sterben_01
发布于
2022-12-14
许可协议
CC BY-NC-SA 4.0