13633 字
68 分钟
C++ 模板笔记(四):深入模板基础、模板中的名称、实参推导与萃取
2022-12-14

第十二章 深入模板基础#

12.1 参数化声明#

C++有四种基础模板。他们既可以出现在命名空间作用域,也可以出现在类作用域。

  • 类模板
    • 嵌套类模板
  • 函数模板
    • 成员函数模板
  • 变量模板
    • 静态数据成员模板
  • 别名模板(就是using的那个)
    • 成员别名模板

此处举例说明如何类外定义类模板的成员函数模板。

template<typename T>
class myclass{
public:
template<typename U>
void func(T a, U b){ //类内
cout <<"in" << endl;
cout << a << b << endl;
}
template<typename U>
void out_class_func(T a, U b);
};
//类外
template<typename T>
template<typename U>
void myclass<T>::out_class_func(T a, U b){
cout <<"out" << endl;
cout << a << b << endl;
}

定义在类外的成员模板需要多个template<... >参数化子句:每个外围作用域的类模板一个,成员模板本身也需要一个。子句从类模板最外层开始逐行展示。

12.1.1 虚成员函数#

在尾部我们已经说明了为什么成员函数不能既是虚函数又是函数模板。额外注意一点,类模板的普通成员函数可以是虚函数。因为它们的数量是固定的

12.1.3 主模板#

类模板和函数模板都必须要有一个主模板

模板的一般性声明声明了主模板(primary templates)。如此声明的模板在模板名后无需书写尖括号模板参数子句。

非主模板会在声明类模板或变量模板的偏特化时出现。函数模板始终必须是主模板(因为函数模板没有偏特化)

12.2 模板参数(Template Parameters)#

我们之前提到过模板参数主要有三种

  • 类型模板参数
  • 非类型模板参数 (3.1~3.2)
  • 模板模板参数(5.7)

这些基本类型的模板参数中的任何一种都可以用作模板参数包的类型。但不可多种类型当做同一个参数包(4.1)

不一定所有的模板参数都需要名称。如果没有用到,则无需命名。同时需要注意的是,模板参数名可以在后续参数声明中引(使)用(但前置则不行):

template<typename T, //这个T在后面的参数声明中使用了。
T root,
template<T> class Buf>
class Structure;

12.2.1 类型模板参数#

好像没啥可说的吧?

类型参数由关键字typename或class所引导:二者是完全等价的。关键字后必须有一个简单的标识符,并且该标识符后必须带有逗号,以表示下一个参数声明的开始,闭合的尖括号>用以指示参数化子句的结束,=用以指示一个默认模板参数的起始。

12.2.2 非类型模板参数#

搭配3.1~3.2,此处做补充

针对非类型模板参数有更多深入的限制。

  • 非类型模板参数不可以有非类型指示符,比如static、mutable等等
  • 它们可以有const和volatile限定符,但是如果这种限定符出现在参数类型的最顶层,就会被忽略。换句话说,对左值引用或指针来说支持底层const
  • 在表达式中使用时,非引用类型的非类型参数始终都是prvalue。它们的地址无法被窃取,也无法被赋值。而另一方面,左值引用类型的非类型参数是可以像左值一样使用。
template<typename T, static int MAXSIZE> //例子1,错误。不能有static
void func1(T obj){
}
template<const int length> class Buffer; //二者相同。因为最顶层CV限定会被忽略
template<int length> class Buffer;

使用引用类型做为非类型模板参数时有诸多陷阱和要求。此处不做过多讲述,下面的两个链接非常有用。

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

https://stackoverflow.com/questions/28662784/reference-as-a-non-type-template-argument

12.2.3 模板模板参数#

参考 5.7

12.2.4 模板参数包#

参考第四章。此处做为补充

  • 主模板中的类模板、变量模板和别名模板至多只可以有一个模板参数包,且模板参数包必须作为最后一个模板参数。函数模板则少些限制:允许多个模板参数包,只要模板参数包后面的每个模板参数都具有默认值或可以推导
  • 类和变量模板的偏特化声明可以有多个参数包,这与主模板不同。这是因为偏特化是通过与函数模板几乎相同的推导过程所选择的。

12.2.5 模板的默认实参#

参考 1.4 2.7。此处作为补充

  • 模板参数包不能有默认实参。

  • 其他的模板参数可以有默认实参。但是它必须和相应的参数类型匹配。比如类型参数不能有一个非类型默认实参

  • 默认实参不能依赖于其自身的参数,因为参数的名称直到默认实参之后才在作用域内生效。然而,他可以依赖前面的参数

    • template<typename T, typename Allocator = allocator<T>> //OK
      class List;
  • 类模板、变量模板或别名模板的模板参数遵照函数(不是函数模板)的默认参数规则:即从有默认实参的参数开始,后面的每一个参数都必须有默认实参。

    • 是的你没看错,可以重复声明。但是只能有一个定义(ODR原则)。
template<typename T1, typename T2, typename T3, typename T4, typename T5> //可以,每一个都没有默认实参
class myclass;
template<typename T1, typename T2, typename T3, typename T4 = int, typename T5 = int> //可以,从T4开始每一个都有默认实参
class myclass;
template<typename T1, typename T2, typename T3 = int, typename T4, typename T5> //可以,由于T4和T5在上面的声明中已有默认实参,所以在这里是T3后的都有默认实参
class myclass{
public: //只有一个定义
void func(){
std::puts(__PRETTY_FUNCTION__);
}
};
//----以下是错误例子
template<typename T1 = char, typename T2, typename T3, typename T4, typename T5> //错误。T1后的参数都没有默认实参
class myclass;
  • 而函数模板的模板参数的默认模板实参则不受此限制。即不需要后续的模板参数必须都有默认模板实参:
    • 锐评:非常智能。
template<typename T1 = int, typename T2, typename T3 = float, typename T4 = char, typename T5>
void func(T1, T2, T3, T4, T5){
std::puts(__PRETTY_FUNCTION__);
}
/*
void func(T1, T2, T3, T4, T5) [with T1 = int; T2 = int; T3 = float; T4 = char; T5 = char]
*/

许多上下文不允许使用默认模板实参#

  • 偏特化(第二章说过)

  • 模板参数包(刚说过)

  • 类模板成员类外定义(不太常见)

  • 友元类模板声明(更不太常见)

  • 友元函数模板声明。除非它是定义并且在编译单元的其他任何地方都没有声明(啥东西)

12.3 模板实参(Template Arguments)#

12章至16章非常精彩,尤其是在原理分析方面。但是内容过于庞多繁杂,涉及到大量的语法和规范内容。此处暂时略过。从19章起将会讲解实践性的内容#

第十三章 模板中的名称#

13.4 派生和类模板#

类模板可以继承或被继承,模板和非模板场景之间没有显著的区别。然而,当从依赖名称引用的基类派生类模板时,有一个微妙区别。让我们首先看看非依赖型基类的情况。

13.4.1 非依赖型基类#

类模板中,非依赖型基类是具有完整类型的类,可以在不知道模板参数的情况下即可确定的基类。换句话说,这个基类的名称是使用非依赖型名称表示的。

template <typename T>
class Base{
public:
int baseval;
using Type = int;
};
class D1:public Base<double>{ //非依赖名基类。
public:
void func(){
baseval = 200;
}
Type D1val = 300;
};
int main(){
D1 obj;
obj.func();
cout << obj.baseval << obj.D1val << endl;
}
  • 模板中的非依赖型基类类似于普通非模板类中的基类,但当在模板派生中查找非限定名称时,非依赖型基类会优先考虑该名称,而后才是模板参数列表。类模板 D1 的成员 D1val 总是对应Base<double>::Type(就是 int) 的类型 Type。

13.4.2 依赖型基类#

前面的例子中,基类完全确定,不依赖于模板参数。只要知道模板定义,C++ 编译器就可以在这些基类中查找非依赖性名称。

针对这一部分看笔记深度探索c++对象模型的7.1

第十五章 模板实参推导#

15.1 推导过程#

基本的推导过程会比较**“函数调用的实参类型”与“函数模板对应位置的参数化类型”**,然后针对要被推导的一到多个参数,分别尝试去推断一个正确的替换项。每个实参-参数对都会独立分析,并且如果最终得出的结论产生矛盾(differ),那么推导过程就失败了。

这里有一个非常简单的例子:

template<typename T>
T max(T a, T b){
return b < a ? a : b;
}
auto g = max(1, 1.0);

这里第一个调用实参的类型是int,因此我们的max()模板的参数T会被推导成int。然而,第二个调用实参是double类型,基于此,T会被推导为double,但是这就与前一个推导产生了矛盾。注意:我们称之为“推导过程失败”,而不是“程序非法”。毕竟,可能对于另一个名为max的模板来说推导过程可能是成功的。也就是SFINAE

即使所有被推导的模板实参都可以一致地确定(即不产生矛盾),推导过程仍然可能会失败。这种情况发生于:在函数声明中,进行替换的模板实参可能会导致无效的结构 比如:

template<typename T>
typename T::ElementT at(T a, int i){
return a[i];
}
void f(int* p){
int x = at(p, 7);
}

这里T被推导为int*(T出现的地方只有一种参数类型,因此显然不会有解析矛盾)。然而,将T替换为int*在C++中对于返回类型T::ElementT显然是非法的,因此推导就失败了。

  • 推导规则在1.2部分讲过了。

15.2 推导语境(上下文)#

这一章是核心,:也就是比T复杂得多的参数化类型也可以匹配一个给定的实参类型。

template<typename T>
void f1(T*);
template<typename E, int N>
void f2(E(&)[N]);
template<typename T1, typename T2, typename T3>
void f3(T1 (T2::*)(T3*));
class S {
public:
void f(double*);
};
template<typename... Args>
void f4(Args... callable){
std::puts(__PRETTY_FUNCTION__);
}
template<typename... Args>
void f5(std::function<void(Args...)> callable){
std::puts(__PRETTY_FUNCTION__);
}
void callablefunc(int a, int b, int c){
cout <<"callablefunc" << endl;
};
template<typename T>
void f6(T callable){
std::puts(__PRETTY_FUNCTION__);
}
template<typename T>
void f7(vector<T> callable){
std::puts(__PRETTY_FUNCTION__);
}
void g(int*** ppp){
f1(ppp); // 形参T的类型被推导为int**
bool b2[42];
f2(b); // 形参E的类型被推导为bool,形参N的类型被推导为42
f3(&S::f); // 形参T1的类型被推导为void, 形参T2的类型被推导为S, 形参T3的类型被推导为double
std::function<void(int, int, int)> myfunc(callablefunc);
f4(myfunc); //形参Args...的类型被推导为{int, int, int}
f5(myfunc); //形参Args...的类型被推导为std::function<void(int, int, int)>
vector<int> a{1,2,3};
f6(a); //形参T的类型被推导为std::vector<int>
f7(a); //形参T的类型被推导为int
}
  • 复杂的类型声明都是用比它更基本的结构(例如指针、引用、数组、函数声明;成员指针声明;模板ID等)来组成的,匹配过程从最顶层结构开始处理,一路递归其各种组成元素。可以说基于这一方法,大部分类型声明结构都可以进行匹配,而这些结构也被称为“推导语境“。

  • 然而,有些结构不能作为推导语境。查看0.6,搜索“语境”或直接查看文档。

第十九章 萃取的实现#

19.1.1 实现简单的固定萃取#

假设我们需要对一个原始数组求和。

template<typename T>
class calc_accumu_old{
public:
T ret_val{}; //使用值初始化,确保每一个类型都有合适的初始化值。
auto getsum(const T* start, const T* end){
while(start != end){
ret_val += *start;
++start;
}
cout << ret_val << endl;
return ret_val;
}
};
int main(){
int num[] = {1,2,3,4,5};
char str[] = "abcdefg";
calc_accumu_old<int> calc;
calc.getsum(num, num+5);//正常
calc_accumu_old<char> calc_char;
calc_char.getsum(str, str+7);//错误了
return 0;
}
  • 表面上万事大吉,但是遇到char却错误了。为什么?因为char按照ASCII编码后相加,明显超出了char的数据范围,就发生了溢出,这时候怎么办?
  • 我们确实可以引入额外参数,或者针对整个类进行特化,但是非常不便。我们尝试一下萃取
template<typename T>
struct retvalTypeTraits{ //主模板
using retType = T; //类型定义
};
template<>
struct retvalTypeTraits<char>{ //针对char类型特化
using retType = int; //类型定义
};
template<>
struct retvalTypeTraits<int>{ //针对int类型特化
using retType = unsigned int; //类型定义
};
template<typename T>
class calc_accumu{
public:
using Ret = typename retvalTypeTraits<T>::retType; //为了方便起见使用别名模板。
Ret ret_val{}; //使用值初始化,确保每一个类型都有合适的初始化值。此处有问题,如果Ret类型没有默认构造,就不保证能够正确初始化。
auto getsum(const T* start, const T* end){
while(start != end){
ret_val += *start;
++start;
}
cout << ret_val << endl;
return ret_val;
}
};
int main(){
int num[] = {1,2,3,4,5};
char str[] = "abcdefg";
calc_accumu<int> calc;
calc.getsum(num, num+5);
calc_accumu<char> calc_char;
calc_char.getsum(str, str+7);
return 0;
}
  • 这样就解决了问题。我们成功应用了萃取。retvalTypeTraits就是我们提到的元函数。返回类型的元函数。

19.1.2 值萃取#

我们在上面的例子中提到,如果值初始化的类对象没有默认构造,就不能保证其被正确初始化。那么这时候我们可能需要额外新增一种萃取。也就是值萃取

template<typename T>
struct retvalTypeTraits{
using retType = T; //类型定义
static const retType zero = 0; //版本1
};
template<typename T>
struct retvalTypeTraits{
using retType = T; //类型定义
static constexpr retType zero = 0; //版本2
};
template<>
struct retvalTypeTraits<char>{ //某种特化
using retType = int; //类型定义
static const retType zero;//版本3 类内仅声明
};
const int retvalTypeTraits<char>::zero = 0;
template<typename T>
struct retvalTypeTraits{
using retType = T; //类型定义
inline static const retType zero = 0; //版本4 C++17
};
  • 版本1:使用static const。

    • 限制:只能使用整型或枚举类型进行类内初始化
  • 版本2:使用static constexpr。

    • 优点:可以使用浮点类型以及其他字面值类型进行类内初始化
    • 缺点:自定义类型可能不是字面值类型,比如其构造函数并不是constexpr
  • 版本3:类内定义类外声明

    • 缺点:比较麻烦
  • 版本4:使用inline [c++17]

    • C++17 后 静态数据成员可以声明为 inline。 inline 静态数据成员可以在类定义中定义,而且可以指定初始化器。它不需要类外定义

  • 这种就算是返回值的元函数。又因为我们既有值萃取又有类型萃取,这种可以被称为多返回值的元函数。

19.1.3 参数化萃取#

我们在上面的基础例子中,发现一个问题。也就是我们的返回值类型是和萃取类绑定到一起的。而且萃取类是不能更改的。始终为retvalTypeTraits。

这样的话,如果用户想要增添更多的特化,就必须找到retvalTypeTraits的文件然后添加更多的特化,比较丑陋。为了解决这一问题,可以为萃取引入一个新的模板参数 PT,其默认值就是我们萃取模板的对应值。

template<typename T>
struct retvalTypeTraits{ //主模板
using retType = T; //类型定义
static const retType zero = 0;
};
template<>
struct retvalTypeTraits<char>{ //针对char类型特化
using retType = int; //类型定义
static const retType zero = 0;
};
struct my_customize_trait{ //用户定义的萃取模板
using retType = unsigned int; //类型定义
static const retType zero = 1000;
};
template<typename T, typename PT = retvalTypeTraits<T>> //第二个参数拥有默认值,默认值就是默认萃取模板
class calc_accumu{
public:
using Ret = typename PT::retType; //为了方便起见使用别名模板。
Ret ret_val = PT::zero;
auto getsum(const T* start, const T* end){
while(start != end){
ret_val += *start;
++start;
}
cout << ret_val << endl;
return ret_val;
}
};
  • PT拥有默认值,默认值就是默认萃取模板。这样的好处是用于可以完全不关心第二个模板参数。而如果确有需求,可以放入自己的萃取模板。

19.2 萃取和策略(policy)#

我们有没有发现上面的代码表面是用到了模板,但其实也就仅仅是用到了模板而已。我们的getsum对任意类型的T都有统一的计算方式。那么问题来了,如果我想要针对某种场景进行特化呢?比如某个指针是自定义的智能指针或迭代器,且没有间接访问运算符呢?

这时候我们很容易想到一点:给getsum也换成可变的。这种行为我们就可以叫做policy。策略。

我们看一下书里的例子:

template<typename T, typename Policy = SumPolicy, typename Traits = AccumulationTraits<T>>
auto accum (T const* beg, T const* end){
using AccT = typename Traits::AccT;
AccT total = Traits::zero();
while (beg != end) {
Policy::accumulate(total, *beg);
++beg;
}
return total;
}
  • 在这一版的 accum()中,SumPolicy 是一个策略类,也就是一个通过预先商定好的接口(accumulate),为算法实现了一个或多个策略的类。

  • 如果提供一个不同的策略对数值进行累积的话,我们可以计算完全不同的事情。比如我们完全可以有一个multpolicy

class SumPolicy { //加法
public:
template<typename T1, typename T2>
static void accumulate (T1& total, T2 const& value) {
total += value;
}
};
class MultPolicy { //乘法
public:
template<typename T1, typename T2>
static void accumulate (T1& total, T2 const& value) {
total *= value;
}
};
  • 但是此时注意,还记得我们把初始值设为0了吗?此时对于乘法就就会出现问题。因为乘以0永远是0。这就说明了需要仔细设计到底哪些是萃取,哪些是策略。同时,应该记住不是所有的东西都要用萃取和策略。比如标准库的accumulate就把初始值作为了第三个参数。

19.2.1 萃取和策略的区别#

  • 策略更侧重于行为,而萃取更侧重于类型

  • 策略代表的是泛型函数和类型(通常都有其常用地默认值)的可以配置的行为。萃取代表的是一个模板参数的本质的、额外的属性。

  • 萃取:

    • 萃取在被当作固定萃取(19.1)的时候会比较有用(比如,当其不是被作为模板参数传递的时候)
      • 一般来说,迭代器就是这么设计的。做为固定萃取
    • 萃取参数通常都有很直观的默认参数(很少被重写,或者简单的说是不能被重写)。
      • 比如有固定的某个萃取类::type或某个萃取类::value
    • 萃取参数倾向于紧密的依赖于一个或者多个主模板参数。
      • 比如19.2,Traits依赖于T
    • 萃取在大多数情况下会将类型和常量结合在一起,而不是成员函数。
      • 一般萃取都是采取typedef/using或static而非成员函数
    • 萃取倾向于被汇集在萃取模板中。
  • 策略:

    • 策略类如果不是被作为模板参数传递的话,那么其作用会很微弱。
      • 比如19.2,我们需要把策略类传入模板参数来确定具体使用的是哪一个Policy
    • 策略参数不需要有默认值,它们通常是被显式指定的(虽有有些泛型组件通常会使用默认策略)。
    • 策略参数通常是和其它模板参数无关的。
      • 比如19.2,策略和T无关
    • 策略类通常会包含成员函数。
      • 一般策略都是采用的成员函数而非萃取的typedef之类。
    • 策略可以被包含在简单类或者类模板中。
  • 记住,策略是表达出我们希望依照某种用户提供的规则进行某项操作。所以不一定是像上面的例子那么死板。比如std::accumulate的第四个参数要求输入一个函数对象。这就是策略。或者是容器类的allocator分配器,也是策略。再比如unordered_set的hash, keyequal, allocator都是策略。还有,unique_ptr的自定义删除器deleter,也是策略。

19.2.2 使用成员函数模板还是模板模板参数?#

我们可以像书上那样不使用成员函数模板,转而使用模板模板参数。这样就要求了我们需要调整策略类为类模板(原来是普通类+成员函数模板,现在是模板类)。这样做其实不太好,有点不直观而且麻烦许多。

19.2.3 多个策略和萃取(说白了就是多个模板参数按照什么顺序写)#

一般来说,如果这个模板参数越可能使用其默认参数,则越应该靠后。我们可以看到上面的例子中,第一个参数是类型(必须显式指定),第二个是策略类(更有可能替换,但有默认值),第三个是萃取类(不太可能被替换)。

19.3 类型函数(Type Function)#

我们在最下面提到了什么叫元函数。其实就是这里的类型函数。在书中是这样定义的:

值函数(value functions):它们接收一些值作为参数并返回一个值作为结果。

类型函数(type functions):它们接收一些类型作为参数并返回一个类型或者常量作为结果

  • sizeof就是一个经典的内置的类型函数。

19.3.1 元素类型#

比如此时我们希望实现一个函数打印某个容器的元素类型。这时候可以通过偏特化实现。

template <typename T> //主模板
struct ElementType;
template <typename T>
struct ElementType<vector<T>>{ //针对vector类型的偏特化
using Type = T;
};
template <typename T>
struct ElementType<list<T>>{ //针对list类型的偏特化
using Type = T;
};
template <typename T>
struct ElementType<deque<T>>{ //针对deque类型的偏特化
using Type = T;
};
//.....其他容器类型
template<typename T>
void printType(const T& container){
std::puts(__PRETTY_FUNCTION__);
cout << typeid(typename ElementType<T>::Type).name() << endl; //打印元素类型
}
int main(){
vector<int> vec_a;
vector<bool> vec_b;
deque<char> deq_a;
list<double> list_a;
printType(vec_a);
printType(vec_b);
printType(deq_a);
printType(list_a);
return 0;
};
  • 此时是一个不错的实现方式。当然了,如果我们把容器分类为标准库容器(提供了value_type的容器)和自定义容器,则可以这样实现:
template <typename T>
struct ElementType{ //针对提供了value_type或没有写入偏特化的类型的 默认实现
using Type = typename T::value_type;
};
template <>
struct ElementType<myobj>{ //针对myobj类型的特化。
using Type = int;
};

注意,如果某个类型没有value_type的同时我们没有针对其类型定义偏特化萃取类模板,则会出现问题。需要特别注意。

  • 我们可能会觉得这样写是脱裤子放屁。然而不是。它**允许我们根据容器类型参数化一个模板,但是又不需要提供代表了元素类型和其它特性的参数。**比如下面这个函数
template<typename T, typename C>
T sumOfElements (C const& c);

在这个函数中,我们的返回类型是容器的元素类型。如果此时使用的话,我们必须显式指定T的类型。因为T的类型无法通过函数参数进行推导。

  • 所以,针对这样的情况,我们可以换成这样的模板:
template<typename C>
typename ElementType<C>::Type sumOfElements (C const& c);

这个时候我们就不必显式指定返回类型。因为可以通过萃取类模板进行推导,通过类型函数得到。

在上面的情况下,ElementType被称之为萃取类。因为它是用来获取一个已有容器类型的萃取。因此我们发现萃取类的功能并不局限于描述容器参数的特性,而是可以描述任意主参数T的特性

  • 如果想更方便一点,可以使用别名模板。
template<typename T>
using a_ElementType = typename ElementType<T>::Type; //别名模板
template<typename C>
a_ElementType<C> sumOfElements (C const& c){
cout <<"called" << endl;
}

19.3.2 转换萃取(Transformation Traits)#

在刚才的一节中,我们主要使用萃取的“抽取”特性。也就是访问主参数的某些特性。在这一节中,我们会使用萃取的“转换”特性。比如添加或移除引用,CV限定等。

删除引用#

  • 删除引用是比较常见的萃取操作。比如标准库的remove_reference。其具体实现已经在下面介绍了。此处不赘述。

添加引用#

  • 对于添加引用,标准库提供的是add_rvalue_reference和add_lvalue_reference。注意我们在11.2.1中提到的。add_rvalue_reference会施加引用折叠。一定要格外注意。

移除CV限定#

  • 对于这一系列操作,标准库提供了remove_cv/remove_volatile/remove_const

退化#

  • 对于退化操作,标准库提供了decay。其模拟实现可以在书中的这一节简单了解。

19.3.3 预测型萃取(Predicate Traits) --- 也就是类型预测(判断)#

我们之前所提到的萃取类型都是针对单个模板参数的。我们针对该模板参数进行修改或提炼。有些时候我们会针对多个模板参数进行萃取。这种就叫做类型预测。也就是一个返回true或false的类型函数。

经典的预测型萃取有比如is_same等。尤为注意在预测型萃取中,我们会返回一个bool值。为了支持标记派发,产生bool的萃取实现都应该通过std::true_type和std::false_type的类型进行继承。

19.3.4 针对返回结果类型进行萃取(Result Type Traits)#

假设我们有一个需求,是对两个vector求和的函数模板。我们的第一个版本可能会长成这个样子:

template<typename T>
vector<T> add(const vector&<T> vec1, const vector<T>& vec2);

但是我们可能会允许两个不同类型的vector相加。但是我们如何处理返回值呢?

这个时候可以使用返回值类型模板:

class wtf{
public:
int val;
wtf() = default;
wtf(int x):val(x){};
wtf operator+(const wtf& rhs) const{ //细节1
cout <<"wtf+" << endl;
return wtf(val + rhs.val);
}
};
template<typename T1, typename T2>
struct VecPlusTrait{ //返回值类型萃取
using Type = decltype(T1() + T2()); //细节2
};
template<typename T1, typename T2>
vector<typename VecPlusTrait<T1, T2>::Type> operator+(const vector<T1>& vec1, const vector<T2>& vec2 ){
if(vec1.size() != vec2.size()){
return {};
}
vector<typename VecPlusTrait<T1, T2>::Type> myvec;
myvec.reserve(vec1.size());
for(int i = 0; i < vec1.size(); i++){
myvec.emplace_back(vec1[i] + vec2[i]);
}
return myvec;
}
int main(){
vector<wtf> vec1{1,2,3,4,5,6,7};
vector<wtf> vec2{2,3,4,5,6,7,8};
auto ret = vec1+vec2;
for(auto& i:ret){
cout <<i.val << endl;
}
return 0;
};
  • 这里细节较多。

  • 第一个细节:wtf 的 operator+必须加const。原因是下面调用wtf的operator+的时候,我们入参的容器是常量引用接受的。此时从容器中提出来的元素也是常量引用。此时常量引用不能调用wtf的非常量成员函数。会报错。

  • 第二个细节:此处我们使用的decltype要记住传入的是表达式。类型+()等于创建匿名对象,然后调用其对应类型的operator+然后判断表达式的返回值。

  • 但是这里有个问题,如果wtf的operator+返回的是一个引用呢?或者更为常见的例子是它返回的是一个const wtf,这样就出问题了。容器不能储存引用也不能储存常量。那么这时候不要忘记使用decay,像这样

template<typename T1, typename T2>
vector<typename std::decay<typename VecPlusTrait<T1, T2>::Type>::type> operator+(const vector<T1>& vec1, const vector<T2>& vec2 ){ //使用decay
if(vec1.size() != vec2.size()){
return {};
}
vector<typename std::decay<typename VecPlusTrait<T1, T2>::Type>::type> myvec; /使用decay
myvec.reserve(vec1.size());
for(int i = 0; i < vec1.size(); i++){
myvec.emplace_back(vec1[i] + vec2[i]);
}
return myvec;
}
  • 这里还有第二个问题,我们用的decltype要求T1和T2类型必须可以构造临时对象(有并且可以访问默认构造)。但是我们并不一定非得要让这两个类拥有默认构造,所以这时候可以用declval
template<typename T1, typename T2>
struct VecPlusTrait{
using Type = decltype(declval<T1>() + declval<T2>());
};

19.4 基于SFINAE的萃取#

我们在8.4和void_t中介绍了SFINAE和其做为排除某些重载函数或排除某些偏特化的技巧。这里再聊聊。

19.4.1 使用SFINAE排除某些重载函数#

比如我们想模仿标准库写一个简单的is_default_constructible

template<typename T>
struct ChectIsDefaultConstructible{
private:
template<typename U, typename = decltype(U())> //细节1
static char check(void*); //细节2
template<typename>
static int check(...); //细节3
public:
static constexpr bool value = is_same<decltype(check<T>(nullptr)), char>::value; //细节4
};
int main() {
cout <<std::boolalpha;
cout << ChectIsDefaultConstructible<myclass>::value << endl;
return 0;
}

核心原理就是利用函数重载。如果类型T是可默认构造的,那么T()是合法表达式。如果T()不合法,该选择会被丢弃,转而匹配万能匹配的第二个函数。至于具体返回值,仅是当做标签使用。细节4当中就是判断其重载后函数的返回值是否是我们期待的。

  • 首先第一个细节,我们没有使用T而是使用U。原因是我们在细节4当中把T做为函数的U传了进去。如果直接使用T像这样:template<typename, typename = decltype(T())>。因为类模板在实例化的时候会直接把所有的T换成传入的类型。如果传入的类型不可默认构造,则会出现意外的结果。比如我们这里使用了一个已删除的默认构造函数(如果传入的类型是不可默认构造的)。虽然g++不会报错(clang会报错),但是结果是错的。
  • 第二个细节和第三个细节是使用static是为了可以不通过对象调用。同时函数入参必须有区别。为了不麻烦,我们传入空指针。
  • 细节三是...可以接受任意类型和数量的参数。因为函数不能仅通过返回值重载。
  • 细节四是因为静态不能类内初始化。除非是静态constexpr对象。

19.4.2 使用SFINAE排除偏特化#

  • 还记得我们在void_t章节详细讲述的它和SFINAE的关系吗?这里又来啦。还是19.4.1的例子,判断是否可以默认构造。但是我们从依靠函数重载做决定变成了依靠偏特化做决定。
template<typename T, typename = void> //原因不解释了。都在void_t章节
struct ChectIsDefaultConstructible : false_type{}; //主模板默认是false_type
template<typename T>
struct ChectIsDefaultConstructible<T, void_t<decltype(T())>> : true_type{};
int main() {
cout <<std::boolalpha;
cout << ChectIsDefaultConstructible<myclass>::value << endl;
return 0;
}

是不是感觉明显比使用函数重载的清爽了很多?但是19.4.1依靠函数重载的实现可以允许我们使用额外的辅助函数或辅助类。此处依靠偏特化的则要求我们必须能把条件放入模板参数的声明中。

19.4.4 SFINAE友好的萃取。注意什么是立即语境。#

SFINAE并不能永远保护我们。如果错误发生在非立即语境,则会出现问题。比如我们19.3.4写的VecPlusTrait萃取,当我们的两个传入参数并不能相加的时候就不会发生SFINAE,而是一个硬错误。

立即语境(立即上下文)#

下面的俩链接过于抽象。什么是立即语境?

直接上下文基本上是模板声明(包括模板参数列表、函数返回类型和函数参数列表)。

只有函数类型、其模板参数类型及其显式说明符的直接上下文中的无效类型和表达式才会导致推导失败。SFINAE

在推断或者替换一个备选函数模板的时候,任何发生在类模板定义的实例化过程中的事情都不是函数模板替换的立即上下文

只有在函数类型或其模板形参类型的立即语境中的类型与表达式中的失败是 SFINAE 错误。如果对替换后的类型/表达式的求值导致副作用,例如实例化某模板特化、生成某隐式定义的成员函数等,那么这些副作用中的错误被当做硬错误。

通俗解释就是,我们调用函数的时候首先要实例化,如果实例化阶段就非法,那是硬错误。因为没到SFINAE阶段。所以这不是立即语境。

如果实例化阶段结束,到了重载阶段,这个阶段发生的错误可以被SFINAE掉。这是立即语境。

当你发现模板声明中的东西可以直接确定的时候,这就是立即语境。当你发现模板声明中的东西不能直接确定,而是需要再实例化一个别的模板,那么这个实例化一个别的模板的过程中就不是立即语境。

或者是理解为模板只管替换本身成不成功,不管其他的。如果模板本身替换失败,则触发sfinae

如果不是立即语境,那么在非立即语境中的错误都是硬错误。

QQ截图20230205014845

https://stackoverflow.com/questions/15260685/what-exactly-is-the-immediate-context-mentioned-in-the-c11-standard-for-whic

https://www.cppstories.com/2022/sfinea-immediate-context/

回到例子中来。假设我们单独为某两个没有相加操作的类的数组提供一个单独的相加函数:

vector<noaddA> operator+(vector<noaddA>& a, vector<noaddB>& b){
cout << "called" << endl;
return{};
}

还是会出现问题。因为程序无法判断是这个单独为noaddA和noaddB准备的更好还是模板的更好。然后会尝试实例化。但是实例化中又会遇到noaddA没有和noaddB相加的操作的问题。因为我们有一个萃取的decltype操作。在两个operator+中,返回值的萃取操作涉及到了额外的模板实例化,所以这部分不是立即语境,如果此时decltype的表达式无效,则会立即报错。

既然如此,那么我们就需要改一下。

template<typename, typename, typename = void> //void_t章节介绍。此处不介绍更多了。
struct HasPlusFunction: false_type{}; //判断类型是否有内置的相加操作。
template<typename T1, typename T2>
struct HasPlusFunction<T1, T2, void_t<decltype(declval<T1>() + declval<T2>())>> : true_type{}; //如果有相加的函数,则没问题。细节1
class wtf{
public:
int val;
wtf() = default;
wtf(int x):val(x){};
wtf operator+(const wtf& rhs) const{ //细节1
cout <<"wtf+" << endl;
return wtf(val + rhs.val);
}
};
class noaddA{
public:
int val;
noaddA() = default;
noaddA(int x):val(x){};
};
class noaddB{
public:
int val;
noaddB() = default;
noaddB(int x):val(x){};
};
template<typename T1, typename T2, bool = HasPlusFunction<T1, T2>::value>
struct VecPlusTrait{ //第二次萃取,细节2
using Type = decltype(declval<T1>() + declval<T2>());
};
template<typename T1, typename T2>
struct VecPlusTrait<T1, T2, false>{}; //针对没有内置相加操作的类型的偏特化。
template<typename T1, typename T2>
vector<typename std::decay<typename VecPlusTrait<T1, T2>::Type>::type> operator+(const vector<T1>& vec1, const vector<T2>& vec2 ){
if(vec1.size() != vec2.size()){
return {};
}
vector<typename std::decay<typename VecPlusTrait<T1, T2>::Type>::type> myvec;
myvec.reserve(vec1.size());
for(int i = 0; i < vec1.size(); i++){
myvec.emplace_back(vec1[i] + vec2[i]);
}
return myvec;
}
vector<noaddA> operator+(vector<noaddA>& a, vector<noaddB>& b){
cout << "called" << endl;
return{};
}
int main(){
vector<wtf> vec1{1,2,3,4,5,6,7};
vector<int> vec2{2,3,4,5,6,7,8};
auto ret = vec1+vec2;
for(auto& i:ret){
cout <<i.val << endl;
}
vector<noaddA> vec3{1,2,3,4,5,6,7};
vector<noaddB> vec4{2,3,4,5,6,7,8};
auto ret2 = vec3+vec4;
return 0;
};
  • 既然在两个operator+中我们涉及到了额外的模板实例化操作,那么我们再次增加一层。注意我们的VecPlusTrait有了第三个参数。这个参数涉及到了HasPlusFunction的实例化。我们通过HasPlusFunction可以判断一个类型是否有内置的加法操作。这里可以正确SFINAE掉。因为它发生在HasPlusFunction的里面。这个里面是立即语境。因为针对HasPlusFunction的选择操作没有额外的模板介入。
  • 随后,我们会获取到true或false。如果是true,则decltype不会出现问题。因为合法。如果是false则会直接返回。不会经过decltype语句。
  • 因为我们是在两个operator+中进行抉择,如果刚才的返回是false,则这时候我们的模板operator+会被SFINAE掉。因为只有萃取部分不是立即语境(对替换后的类型的实例化某些模板特化中的错误被当做硬错误。理解为进入了另一个实例化的模板中)。但是这时候脱离了萃取部分,仅仅是两个operator+中选择的话,是立即语境。所以就会去专门选择noaddA和noaddB指定的operator+重载版本。
  • 注意,如果我们的相加操作的类型同时内置支持相加,并且也写出了单独的版本,此时会选择单独版本。因为两者重载决议中显然会选择非模板函数。
    • 我们在1.5和重载决议中提到:重载决议只看声明,不看定义。但是我们的萃取,decltype之类的都在声明里。所以它依旧会去进行计算。

19.5 简单实现is_convertible#

我们在下面介绍了is_convertible的用法。这里尝试进行一个简单实现。

template <typename From, typename To>
struct IsConvertible{
private:
static void try_call(To); //细节1
template<typename>
static std::false_type check(...); //细节2
template<typename F, typename = decltype(try_call(declval<F>()))> //细节3
static std::true_type check(void*);
public:
using Type = decltype(check<From>(nullptr)); //细节4
};
template <typename From, typename To>
struct IsConvertibleValue:IsConvertible<From, To>::Type{}; //细节5
int main(){
cout << std::boolalpha;
cout << is_convertible<int, string>::value << endl;
cout << IsConvertibleValue<int, string>::value << endl;
cout << is_convertible<string, int>::value << endl;
cout << IsConvertibleValue<string, int>::value << endl;
cout << is_convertible<double, int>::value << endl;
cout << IsConvertibleValue<double, int>::value << endl;
cout << is_convertible<int, double>::value << endl;
cout << IsConvertibleValue<int, double>::value << endl;
}
  • 首先,所有成员函数都要是static为了可以直接调用。
  • 第一个细节。这个函数的目的是使用细节3中的decltype检查From类型参数能否放入To类型的形参。如果合法证明可以convert。不合法证明不可以。会被丢弃。转而匹配细节2的万能fallback方案。
  • 第二个细节。...匹配任意数量任意类型的参数。做为fallback方案
  • 第三个细节。我们采用declval避免创建临时对象。然后传入try_call后使用decltype判断是否合法。
  • 第四个细节。显式指定模板函数check的形参F的类型是From。然后传入一个参数。最后使用decltype判断是false_type还是true_type。
    • 注意必须有F,如果直接写declval<From>则会在实例化阶段报错。
  • 第五个细节。IsConvertibleValue将会继承自计算后的IsConvertible的type类型。也就是要么继承自false_type要么继承自true_type。

特殊情况#

上面的代码无法处理以下三种情况:

  • 向数组类型的转换要始终返回 false,但是在上面的代码中,try_call()声明中的类型为 To的参数会退化成指针类型,因此对于某些 From 类型,它会返回 true。
  • 向指针类型的转换也应该始终返回 false,但是和 1 中的情况一样,上述实现只会将它们当作退化后的类型。
  • 向(被 const/volatile 修饰)的 void 类型的转换需要返回 true。但是不幸的是,在 To 是 void 的时候,上述实现甚至不能被正确实例化,因为参数类型不能包含 void 类型(而且 try_call()的定义也用到了这一参数)。

解决这个问题可以引入额外模板参数,使用is_array, is_function和is_void(注意不是void_t)来进行判断。

19.6 探测(检查)成员#

19.6.1 检查类型成员 (Detect Member Type)#

查看void_t章节。但是这里额外增加一点注意事项。

struct A {
using mytype = int; //类A含有mytype类型
};
struct B{
using mytype = void; //类B也含有mytype类型
};
template<typename T1 , typename T2 = void > //主模板,第二个参数默认值是void,非常重要
struct has_type_member : std::false_type { //主模板继承false_type
};
template<typename T1> //针对第二个参数进行偏特化
struct has_type_member<T1, std::void_t<typename T1::mytype>>: std::true_type { //偏特化继承true_type,并且使用void_t进行类型判断。
};
int main(){
auto a = has_type_member<A>::value;
auto b = has_type_member<B>::value;
cout << a << endl;
cout << b << endl;
auto c = has_type_member<A&>::value; //注意这两个
auto d = has_type_member<B&>::value; //注意这两个
cout << c << endl;
cout << d << endl;
}
  • 比如上面代码。如果正常传入一个类型本身没什么问题。但是我们如果传入引用就会出现问题。因为引用类型确实没有成员。引用类型和其本身类型并不是同一种类型。但是我们可能会希望,当我们传递进来的模板参数是引用类型的时候,依然根据其指向的类型做判断。为了这一目的,我们可以使用remove_reference进行辅助。
template<typename T1 , typename T2 = void >
struct has_type_member : std::false_type {
};
template<typename T1> //针对第二个参数进行偏特化
struct has_type_member<T1, std::void_t<typename remove_reference<T1>::type::mytype>>: std::true_type { //偏特化继承true_type,并且使用void_t进行类型判断。
};

注入类的名字 (injected-class-name)杂记4中提过。#

  • 注入类名是在类的作用域内该类自身的名字。
    • 在类作用域中,当前类的名字被当做它如同是一个公开成员名一样;这被称为注入类名(injected-class-name)。该名字的声明点紧跟类定义的开花括号之后。
    • 与其他成员类似,注入类名可被继承,但是依旧受可见性制约。在私有或受保护继承的场合,可能导致某个间接基类的注入类名在派生类中最后变得不可访问。
  • 所以我们的萃取代码,可以检测注入类的名字。换句话说,就是检测是否继承自某个特定类。
    • 这里仍然受到可见性限制,也就是私有继承会返回false
struct A{
};
struct B:public A{
};
struct C:private B{
};
int main(){
auto a = has_type_member<A>::value;
auto b = has_type_member<B>::value;
auto c = has_type_member<C>::value;
cout << a << endl; //true
cout << b << endl; //true
cout << c << endl; //false
}

19.6.2 检查任意类型成员。#

目前只能通过宏来实现这一操作。##的作用在杂记4

#define DEFINE_HAS_TYPE(MemType)\
template<typename, typename = void>\
struct HasTypeT_##MemType : std::false_type{};\
template<typename T>\
struct HasTypeT_##MemType<T, std::void_t<typename T::MemType>> : std::true_type{} // 注意没有分号!
struct A{
using mytype = int;
};
struct B{
using anothertype = float;
};
DEFINE_HAS_TYPE(mytype); //我们上面故意去掉的分号原因是这里是那个分号。
DEFINE_HAS_TYPE(anothertype);
int main(){
cout << boolalpha;
cout << HasTypeT_mytype<A>::value << endl;
cout << HasTypeT_mytype<B>::value << endl;
cout << HasTypeT_anothertype<A>::value << endl;
cout << HasTypeT_anothertype<B>::value << endl;
}
  • 由于我们传入的变量叫做MemType。所以我们传入mytype的时候,HasTypeT_##MemType会变成HasTypeT_mytype。其他的同理。

19.6.3 检查非类型成员#

void_t中介绍了。稍微补充一下。

针对数据成员,也可以使用检查成员函数的第二种方式:<取成员地址>是否合法来判断。

但是:

  • 成员名字,也就是&T::Member中的Member名字不能有歧义。比如不能是重载,不能是多继承中的同名成员。
  • 必须可以访问。
  • 必须是非类型和非枚举成员,否则&无效。
  • 如果是static静态成员,则对应类型必须提供有效的operator&,如果无效,如设置operator&为私有则无效。

19.6.4 检查其他表达式 (比如是否有特定操作,如大于小于)#

这种基于偏特化的SFINAE可以应用在很多方面,包括其他表达式。也可以把多个条件放在一起(都放在void_t里面,只要一个条件无效就整个偏特化丢弃。)比如我们下面的例子是探测两个类型T1和T2是否有小于操作。

struct A {
};
struct B{
bool operator <(A o1){ //B和A的小于操作
}
};
bool operator <(int, A o1){ //int和A的小于操作
}
template<typename T1 , typename T2, typename = void >
struct has_less_operation:std::false_type {
};
template<typename T1, typename T2>
struct has_less_operation<T1, T2, std::void_t<decltype(declval<T1>() < declval<T2>())>>: std::true_type { //判断是否有小于操作
};
int main(){
cout <<std::boolalpha;
cout << has_less_operation<int, int>::value << endl;
cout << has_less_operation<string, string>::value << endl;
cout << has_less_operation<B, A>::value << endl;
cout << has_less_operation<int, A>::value << endl;
cout << has_less_operation<string, int>::value << endl;
cout << has_less_operation<A, B>::value << endl;
/*
输出
true
true
true
true
false
false
*/
}

采用这种方式探测表达式有效性的萃取是很稳健的:如果表达式没有问题,它会返回 true,而如果<运算符有歧义,被删除,或者不可访问的话,它也可以准确的返回 false。

19.7 其他的萃取技术#

19.7.1 If-then-Else#

个人理解这个的用处非常像enable_if。但我们提到了,enable_if不能用于类模板。

template<bool cond, typename TypeIfTrue, typename TypeIfFalse>
struct IfthenElse{
using Type = TypeIfFalse;
};
template<typename TypeIfTrue, typename TypeIfFalse>
struct IfthenElse<true, TypeIfTrue, TypeIfFalse>{
using Type = TypeIfTrue;
};
int main() {
IfthenElse<(sizeof(int) >= 4), int, double>::Type a;
cout << boolalpha;
cout << is_same<int, decltype(a)>::value << endl; //true
}
  • 核心目的是如果表达式cond是true,则让type是truetype。反之亦然。

  • 标准库提供的是std::conditional

19.7.2 检查操作是否抛出异常#

  • 我们在杂记4中介绍了noexcept可以是运算符,也就是后面可以跟随一个表达式。这样做可以通过表达式结果是true或false来声明函数是否是noexcept。
  • 这种形式比较经典的应用在了vector的移动构造函数中。如果元素的移动构造不能保证noexcept,就不会调用移动构造而是调用拷贝构造。

我们也用我们的方式来实现一下。

  • 准备一个针对true_type和false_type的类型萃取。这一步的目的是可以在某个类型没有移动或构造函数的时候仍旧可以SFINAE。
    • 没有移动或构造函数,则T1(declval<T1>())无效
template<bool check = false> //默认是false
struct bool_extraction{
using value = false_type;
void func(){
cout <<"false" << endl;
}
};
template<>
struct bool_extraction<true>{ //true的偏特化。
using value = true_type;
void func(){
cout <<"true" << endl;
}
};

第一个版本:使用基于默认类型的SFINAE。

template<typename T1, typename = true_type> //必须是true_type
struct Is_Nothrow_Move_Constructible : std::false_type{
};
template<typename T1>
struct Is_Nothrow_Move_Constructible<T1, typename bool_extraction<noexcept(T1(declval<T1>()))>::value> : std::true_type{
};
  • 如果noexcept(T1(declval<T1>())表达式无效,则会匹配至bool_extraction的主模板。

  • 为什么Is_Nothrow_Move_Constructible默认值必须是truetype?

    • 原理和void_t基本差不多。
    • 如果是false_type,假如T1的拷贝构造或移动构造是非noexcept,则类型会被推导为<T1, false_type>。此时由于下面的比上面的更特化。导致继承了下面的,导致value是true_type。如果是noexcept,则类型会被推导为<T1, true_type>。但是我们的默认值是false_type则相当于指定<T1, false_type>为模板实参。此时模板实参和推导的模板实参不匹配,第二个版本会被丢弃。主模板可以匹配,则回退至主模板。继承了false_type。
    • 所以我们看到,如果默认值是false_type,则结果会完全相反。

第二个版本:使用基于void_t的SFINAE

template<typename T1, typename = void>
struct Is_Nothrow_Move_ConstructibleV2 : std::false_type{
};
template<typename T1>
struct Is_Nothrow_Move_ConstructibleV2<T1, void_t<decltype(T1(declval<T1>()))>> : bool_extraction<noexcept(T1(declval<T1>()))>::value{
};
  • 说实话,我真的没感觉出来有什么具体的差别。但是在看到第一个版本默认值类型的坑后,我觉得第二个版本更加人道一些。更少的magic。

测试:

struct A { //无拷贝构造和移动构造
A(){}
A(const A&) = delete;
A(A&&) = delete;
};
struct B{ //有拷贝构造和移动构造且都为noexcept
B(){}
B(const B&) noexcept{}
B(B&&) noexcept{}
};
struct C{ //有拷贝构造和移动构造且都无noexcept
C(){}
C(const C&){}
C(C&&){}
};
struct D{ //有拷贝构造和移动构造且只有拷贝构造为noexcept
D(){}
D(const D&) noexcept{}
D(D&&){}
};
cout <<"A test:" << endl;
cout << is_nothrow_move_constructible<A>::value << endl; //false
cout << Is_Nothrow_Move_Constructible<A>::value << endl;
cout << Is_Nothrow_Move_ConstructibleV2<A>::value << endl;
cout <<"B test:" << endl;
cout << is_nothrow_move_constructible<B>::value << endl; //true
cout << Is_Nothrow_Move_Constructible<B>::value << endl;
cout << Is_Nothrow_Move_ConstructibleV2<B>::value << endl;
cout <<"C test:" << endl;
cout << is_nothrow_move_constructible<C>::value << endl; //false
cout << Is_Nothrow_Move_Constructible<C>::value << endl;
cout << Is_Nothrow_Move_ConstructibleV2<C>::value << endl;
cout <<"D test:" << endl;
cout << is_nothrow_move_constructible<D>::value << endl; //false
cout << Is_Nothrow_Move_Constructible<D>::value << endl;
cout << Is_Nothrow_Move_ConstructibleV2<D>::value << endl;
cout <<"int test:" << endl;
cout << is_nothrow_move_constructible<int>::value << endl; //true
cout << Is_Nothrow_Move_Constructible<int>::value << endl;
cout << Is_Nothrow_Move_ConstructibleV2<int>::value << endl;
cout <<"string test:" << endl;
cout << is_nothrow_move_constructible<string>::value << endl; //true
cout << Is_Nothrow_Move_Constructible<string>::value << endl;
cout << Is_Nothrow_Move_ConstructibleV2<string>::value << endl;
  • 需要详细说明几个问题。我们可以理解移动是特殊的拷贝操作。所以如果
    • 同时拥有拷贝构造和移动构造。只移动构造为noexcept,则是true。
    • 同时拥有拷贝构造和移动构造。只拷贝构造为noexcept,则是false。
    • 提供拷贝构造,不声明且不提供(不是=delete)移动构造,且拷贝构造是noexcept,则是true。
      • 原因是编译器虽然压根不隐式生成移动构造,但不会标记为弃置。
    • 提供拷贝构造,但是声明移动构造为弃置(=delete),就算拷贝构造是noexcept,也是false。
      • 由于我们使用了declval。declval是强制转换为右值引用。
      • 因为这是=delete的意义。我们已经通过declval转换为右值引用了。然后进行构造函数匹配的时候检测到移动构造这个签名最为匹配,但是是delete,就不应该继续选择次级匹配的。而是提示使用了不该用的东西。

坑点 - 为什么必须要用declval?#

为什么必须用declval,不就是少构造一个么?

原因不仅是避免构造,旁通掉构造函数被删除或不可用的情况,更是因为它可以旁通掉构造函数为非noexcept的情况。

假设有如下代码:

struct B{
B(){}
B(const B&) noexcept{
}
B(B&&) noexcept{
}
};
cout << noexcept(B(declval<B>())) << endl; //true
cout << noexcept(B(B())) << endl; //false

为啥有区别?

  • declval不要求构建对象。所以构造函数不参与。直接换成B&&。B(B&&)自然匹配noexcept的移动构造。返回true。
  • 非declval要求构建对象。有构造函数参与。但是构造函数为非noexcept。最终结果自然是非noexcept。返回false。

19.8 类型分类#

19.8.1 检测基本类型#

检测基本类型我们可以使用标准库的std::is_fundamental或std::intergral进行检测。具体的原理其实就是写一堆偏特化。

19.8.2 检测复合类型#

指针#

检测指针类型我们可以使用标准库的std::is_pointer。具体原理也是偏特化。

引用#

检测引用类型我们可以使用标准库的std::is_lvalue_reference 和 std::is_rvalue_reference。具体原理还是偏特化。

数组#

检测数组类型我们可以使用标准库的std::is_array。具体原理依旧是偏特化。

指向成员的指针#

针对这个类型可以使用std::is_member_pointer, std::is_member_object_pointer和std::is_member_function_pointer。原理是偏特化和提到的函数类型。

template<typename T>
struct IsPointerToMemberT : std::false_type {
};
template<typename T, typename C>
struct IsPointerToMemberT<T C::*> : std::true_type { //也可以写为<T(C::*)>。之前函数类型的那种必须加括号是因为语法限制。
using MemberT = T;
using ClassT = C;
};
class cls {};
int main() {
cout << (IsPointerToMemberT<int cls::*>::value //也可以写为<int(cls::*)>。之前函数类型的那种必须加括号是因为语法限制
? "T is member pointer"
: "T is not a member pointer") << '\n';
cout << (IsPointerToMemberT<int>::value
? "T is member pointer"
: "T is not a member pointer") << '\n';
}
  • 注意带括号和不带括号的写法在这里是一个意思。

19.8.3 检测函数类型#

针对函数类型我们可以使用标准库的std::is_function()。具体细节就是一堆偏特化。

值的注意的是,这个东西的实现中,偏特化的数量非常多。一是因为会有任意多的参数,需要用可变参数。其次就是需要处理CV限定。

19.8.4 检测类类型#

这个就比较有意思了。因为我们不能专门偏特化或枚举某个未知类型。但是我们可以利用一个技巧:

  • 表达式X Y::*只有在Y是类类型的时候才合法。
    • 注意,我们只利用声明判断。所以X具体是什么类型我们不关心。随意选一个就好。
template<typename T, typename = void>
struct IsClassT : std::false_type {
};
template<typename T>
struct IsClassT<T, void_t<char T::*>> : std::true_type { //这里char是随便选的。而且依旧加括号不加括号都可以。
};
int main() {
cout << std::boolalpha;
cout << IsClassT<int>::value << endl; //False
cout << IsClassT<myobj>::value << endl; //True
cout << IsClassT<myobj&>::value << endl; //False
cout << IsClassT<myobj*>::value << endl; //False
}
  • 标准库中提供了std::is_class和std::is_union

19.8.5 检测枚举类型#

  • 标准库提供了std::is_enum。最基础的原理是检查一个类型是否是基础类型,指针类型,引用类型,数组类型,成员指针类型,函数类型和类类型。都不是的话就是枚举类型。

19.9 策略特征#

我们在19.2详细探讨了策略和萃取的区别。但是我们在那一章节提到的是策略类而非策略特征。

  • 策略类通常独立于其他模板参数。
  • 策略特征通常是与模板参数相关联的唯一属性。

19.9.1 利用if-then-else 处理只读参数类型#

我们在19.7.1谈到了if-then-else可以看做是一种enable_if。具体的用法之一就是判断传入参数的类型应该是值还是引用。

  • 假设我们打算针对小于两个指针大小的数据类型采用值传递,大于两个指针大小的数据采用常量左值引用传递:
    • 尽管这么做不一定正确。因为对象的拷贝或移动成本不一定和对象的体积成正相关。比如一个10个int的类和一个两个指针但是每一个指针都持有一个大型资源的类。或者是本身就不大的容器类。针对这些情况我们可以进行特化和偏特化处理
//IfthenElse查看19.7.1
template<typename T>
struct PassByWhat{
using Type = typename IfthenElse<sizeof(T) <= 2*sizeof(void*), T, const T&>::Type; //如果小于两个指针大小就是T,否则就是const T&
void func(){
std::puts(__PRETTY_FUNCTION__);
cout <<"normal" << endl;
}
};
template<typename T>
struct PassByWhat<vector<T>>{ //举个例子。针对vector类型的特化。如果下面传入的是vector<T>如vector<int>则会正确匹配到这里。不要惊讶。
using Type = const vector<T>&;
void func(){
std::puts(__PRETTY_FUNCTION__);
cout <<"vector" << endl;
}
};
template<typename T1, typename T2>
void testfunc(typename PassByWhat<T1>::Type p1, typename PassByWhat<T2>::Type p2){
std::puts(__PRETTY_FUNCTION__);
}
int main() {
myobj obj;
testclass2 testobj;
testfunc<myobj, testclass2>(obj, testobj);
}
/*
myobj const
testclass2 const
myobj copy const
void testfunc(typename PassByWhat<T1>::Type, typename PassByWhat<T2>::Type) \
[with T1 = myobj; T2 = testclass2; typename PassByWhat<T1>::Type = myobj; typename PassByWhat<T2>::Type = const testclass2&]
myobj dest
testclass2 dest
myobj dest
*/
  • 针对vector<T>类型的偏特化可以正确匹配:
PassByWhat<vector<int>> check;
check.func();
/*
void PassByWhat<std::vector<_Tp> >::func() [with T = int]
vector
*/
  • 这么做有缺点。第一个缺点是函数声明乱,很复杂。第二个缺点是此时无法使用函数模板的参数推导。因为模板参数只出现在函数参数的限定符中,并不是函数参数的本身。这里是不推导语境。

    • 用有限定标识指定的类型的 嵌套名说明符(作用域解析运算符 :: 左侧的所有内容)

    • 我们看到我们函数签名的所有的模板形参均在作用域解析运算符左侧。

    • 可以配合与一个包装函数模板,提供完美转发服务。

C++ 模板笔记(四):深入模板基础、模板中的名称、实参推导与萃取
https://sterben-01.github.io/posts/模板-4/
作者
Sterben_01
发布于
2022-12-14
许可协议
CC BY-NC-SA 4.0