【笔记】《C++Primer》—— 第16章:模板与泛型编程

时间:2022-07-22
本文章向大家介绍【笔记】《C++Primer》—— 第16章:模板与泛型编程,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

这一章介绍了面向对象编程中最复杂的部分:模板与模板编程,读起来很吃力,总结也写了很久。其中16.2的类型转换部分会有点绕,16.4的可变参数模板则很实用,可以有效提高我们的开发效率。这篇内容较多较难,可以的话应该仔细看书慢慢看。

16.1.1 函数模板

  • 上一章的OOP让我们可以在运行时处理运行前未知的动态情况,而泛型模板编程让我们可以在编译时就处理好一些动态的情况。在第二部分中介绍一些标准库容器时我们称其为泛型容器,因为它们可以利用了模板类的特性能对各种符合要求的类型进行处理,可以独立于任何类型运行
  • 模板是泛型编程的基础,一个模板就是创建类或函数的蓝图或者公式,当我们在编译时提供了足够的参数后模板就会转换为特定的类或函数
  • 模板分为函数模板和类模板两种,都可以通过参数形成特定的代码
  • 函数模板的编写方法是在函数前用template<typename T>附注模板参数列表,然后这里声明的类型T可以被使用到函数是参数和定义中。
  • 当我们调用函数模板时,编译器和以前一样可以自动按照我们的实参来推断模板参数的类型,如果想要指定类型则和使用泛型容器时一样在函数名后用尖括号标明所需要的具体类型T即可。在使用函数时,确定下来的类型会编译生成一个模板实例,实际运行的是这个模板实例
  • 由关键字class或typename带头的参数称为类型参数,这两者没有区别但建议用typename
  • 可以由具体关键字带头声明非类型参数,非类型参数表示的是一个值而不是类型,因此非类型参数在编译时会被用户提供或编译器推断的一个常量代替,从而允许我们初始化数组之类
  • 非类型参数可以是整型或指向对象或函数的指针或左值引用,但是注意绑定到非类型整型必须是常量表达式,绑定到指针或引用的对象必须有静态的生存期(都是为了可以在编译期完成所要求的)

// 类型模板参数,模板函数
// 此处的T是作为一个待定类型使用的
template<typename T>
int typeTemp(T inp) {
  return static_cast<int>inp;
}

// 非类型模板参数,模板函数
// 此处的N是作为一个待定常量表达式使用的
template<unsigned N>
int typeTemp(int inp[N]) {
  return inp[0];
}
  • 函数模板同样可以声明为inline或constexpr
  • 模板中的参数常常是const引用的,因为这样可以保证模板可以用于不可拷贝的类型
  • 模板程序应该尽量减少对实参类型的要求,例如比较大小时尽量使用小于号甚至使用less函数比较
  • 编译器在模板实例化(被输入具体参数引用)时才生成代码
  • 为了生成实例化的模板,便因此需要掌握函数模板或类模板成员函数的定义,因此模板的头文件常常包括成员的声明和定义
  • 模板的提供者必须保证模板实例化时依赖于模板参数的名字都必须有定义,其他的要保证对编译器可见。因此通常定义一个头文件包含模板定义和所有用到的成员的声明,并且使用者必须包含好模板头文件和实例化时需要用到的所有头文件
  • 大多数编译错误要等到实例化的时候才会出现,在链接时报出
  • 我们编写模板的时候代码不应该是针对具体类型处理的,但多少会有一些假设,需要在注释标出。防止错误的使用模板则是调用者的责任

16.1.2 类模板

  • 类模板与函数模板一大不同是类模板不会推断参数的类型,所以我们必须在尖括号中指定类型,这些信息叫显式模板实参列表
  • 一个类模板的每个实例都是一个独立的类,一个实例化的类型总是包含模板参数的
  • 与之前说过的一样,在模板类外定义成员函数时需要先指明模板实参列表的标签,然后说明成员所在的类且包含模板实参,然后用作用域运算符指出目标成员
  • 与函数模板有些相通,类模板的成员函数只有在使用时才会实例化,也就是我们并不需要一个完美的模板,只要满足当前类型的实例化即可
  • 在类模板自己的作用域中,也就是函数体或类体部分,我们可以直接使用模板名而不需要实参,就像已经完成了实参匹配一样
  • 类模板与另一个模板直接最常见的友元是一对一的友元,首先模板需要声明所有需要用到的名字,然后在声明友元时标注出目标类的具体模板实参
  • 类模板也可以一对多友元,方法是直接将目标模板的名字标为友元,这样就与目标模板的所有实例都成为了友元。要注意如果声明了目标友元的模板实参标识符,这些标识符需要与自身类模板的标识符不同

// 需要保证目标友元在作用域中可见
template<typename T> class friendTemp1;
template<typename T> class friendTemp2;

// 类模板
template<typename T> class classTemp {
        // 一对多的友元,template用不到的时候可以不用写,注意要有不同的标识符
        template<typename X> friend class friendTemp1;
        // 一对一的友元,模板实参需要写明白
        friend class friendTemp2<int>;
};
  • C11中我们可以让模板类型参数自己成为友元,从而提高了灵活性
  • 由于模板不是一个类型(实例化后才是),所以我们不能用typedef来起类型别名,但是C11让我们可以用using来起模板的类型别名。在起类型别名时我们会将整个模板类作为一个别名,其中我们可以将一些参数固定住
// 类模板的全参数别名
template<typename A, typename B> using shortTemp = classTemp<A,B>;
// 固定一些参数的类模板别名
template<typename A, typename B> using ssTemp = classTemp<int, double>;
  • 类模板一样可以有static成员,但是由于要保证每个static有且仅有一个定义,而类模板的每个实例都有自己独有的static,因此我们需要将static也定义为模板,此时static也一样只有才用到时才会被实例化

16.1.3-16.1.4 模板参数&成员模板

  • 模板参数没有实际意义,我们通常命名为T但是实际上我们可以用任何合法名字
  • 模板参数遵守普通的作用域原则,但我们不能在模板内重用模板参数名
  • 与函数一样声明中的模板参数不需要与定义时相同
  • 模板的名字可能是一个数据成员也可能是一个类型成员,默认情况下C++假定作用域运算符访问的名字不是类型,如果我们希望它是类型则需要在前面加typename标识
  • C11允许我们为函数模板和类模板提供默认参数,做法和默认函数实参类似但是写在模板参数列表里,也只能出现在最右侧
  • 如果有模板为所有参数都提供了默认实参,那我们也应用空尖括号对来实例化它
// 类模板的默认实参
template<typename A = int, typename B = double> class defaultTemp {
};

// 实例化时若想要使用全部默认实参则也不要忘了空尖括号对
defaultTemp<> test;
  • 模板套模板的时候成为成员模板,用起来和其他的模板一样,只是在内部也需要一个template<X>声明
  • 当需要在类外部定义类成员模板时,要注意此时需要两个template连用来说明标识符

// 模板类
template<typename A> class ATemp {
  // 成员模板的内部声明
  template<typename B> void BFunc(B inp);
};

// 成员模板的外部定义
template<typename A>
template<typename B>
void ATemp<A>::BFunc(B inp){
  return;
}
  • 成员模板的实参同样可以由编译器依据传入的参数来推断

16.1.5-16.1.6 控制实例化&效率与灵活性

  • 模板在被使用时才会实例化,这意味着当多个独立编译的文件用了同样的模板时,相同的实例可能会被实例化在多个对象文件中,这会造成资源的浪费。为了解决这个问题,我们要进行显式实例化
  • 通常的实例化做法是在所有需要得到模板声明的地方对模板的声明注明是extern的,这样编译器不会在这个模板实例化的时候生成代码而是去程序别处查找模板的实例
  • 然后我们要保证这个extern出现在所有用到模板的代码的前面,接着一般创建一个实例化文件在运行最早期的地方一起完成所需模板的实例化定义,即没有extern的模板声明,这个做法称为显式实例化
  • 但是显式实例化会实例化模板的所有成员,包括内联的成员函数,因为编译器不了解哪些是无用哪些是有用,因此我们需要保证这个模板能对所有可能的类型正常工作
  • 对于模板设计者来说,模板编写存在一大两难抉择,效率与灵活性的抉择。例如shared_ptr与unique_ptr对于删除器的设置上:
    • shared_ptr为了灵活性,为了能随时更改删除器,在模板类内保存了一个指针指向不确定类型的删除器,在运行时绑定删除器,但是此时每次访问删除器都需要经历指针的间接指向
    • unique_ptr为了性能,将删除器的类型在模板参数中传入,编译时绑定,这样之后使用的时候可以直接调用实例化的删除器,但是无法在实例化后更改删除器了

16.2 模板实参推断

  • 从函数实参来确定模板实参的过程称为模板实参推断,在模板实参推断过程中,编译器用函数调用中的实参类型来查找哪些函数版本最为匹配
  • 对于函数模板与普通非模板函数不太一样,编译器通常不对实参进行类型转换从而只有几个类型转换会应用在实参上,编译器偏向于生成新的模板实例来适配
  • 只有const转换和数组指针和函数指针会自动进行转换,其他的算术转换之类的不会自动进行
  • 注意模板实参的个数,要保证传入的实参的类型可以符合模板实参,不要出现模板实参只有一个类型而传入了两种类型的实参的情况,因为并不会自动转换适配
  • 即使是模板函数,对于其中被指定的类型则仍会进行以前正常的类型转换
  • 如果模板实参不会出现在函数实参中(例如模板实参对应着函数的返回值类型),则我们可以在调用函数时像实例化模板一样用尖括号按顺序指定所需的实参,此时只有最右方的实参可以在能被推断的情况下省略
  • 如果显式指定了实参类型,那么就可以自动正常进行类型转换
  • 有时我们需要使用编译确定下的参数类型来作为返回值的类型,我们可以用尾置返回来完成这个目标:

// 使用尾置返回来返回待定的类型,此时它可以使用函数的参数
template<typename T>
auto fun(T inp) -> decltype(*inp) {
        return *inp;
}
  • 有时候我们无法直接得到所需要的类型,因为我们对会传递进来的参数的类型实际上几乎一无所知,甚至不知道它是不是指针是不是引用是不是右值引用之类,我们需要能够动态地将这些语言特性消去从传入的参数中提取出我们想要的类型。我们通常使用标准库头文件type_traits中的类来进行特殊的类型转换,这些类常常被用作"模板元编程",下表简单地介绍了它们,使用的方法和普通的模板一样,用途也都在名字里了,例如remove_reference<T>::type会得到T去掉引用后的类型,如果T不是引用则直接返回T本身:
  • 当我们用函数模板来得到函数指针时,编译器会按照函数指针的类型来确定模板的类型,如果不能从指针确定类型,则直接报错。当函数指针的调用存在歧义时,我们可以显式指定指针类型来消歧义
  • 具体来说编译器是如何从模板函数的调用中推断具体的实参类型呢,要分为几种情况
    • 当函数的参数是普通左值时,正常推断,很多参数无法传递进去
    • 当函数的参数是左值引用如T&时,代表我们只能传递给他一个左值,此时如果传的是T则得到类型T,如果传的是const T则得到const T
    • 当函数的参数是const引用时,我们直到我们可以传递给他任何实参,此时const时函数参数本身,所以推断出的类型将不再有const部分,基本上是将类型本身取出来了
    • 当函数的参数是右值引用时,我们可以传递右值,此时推断的过程类似左值引用的推断,也会随传递的参数有无const而受到改变。
    • 通常情况下我们不能将左值传递给右值引用参数,但是C++设置了两个重要的例外来允许这种传递:
      • 左值如i传递给模板类型的右值引用时,编译器会推断参数类型为左值引用i&
      • 如果我们通过类型别名或模板参数之类的方法间接定义了引用的引用(正常情况下无法定义),会产生引用的“折叠”,(X&)&,(X&)&&,(X&&)&都折叠为X&,(X&&)&&折叠为X&&,也就是删去两个引用符
    • 所以如果给右值引用参数传递左值,则应用特例1得(int)&&->int&,(const int)&&->const int&;如果给右值引用参数传递左值引用,则发生折叠,结果是(int&)&&->int&,即左值引用不变。总结起来我们可以给右值引用类型传递任意类型的值,但是这个引用一般用在模板转发或模板重载中,因为难以判定是否是引用的特性会引发一些特别的问题
  • 标准库的std::move函数是理解右值引用作为参数的很好的例子,因为这个函数就是通过右值引用来达到传递左值也可以返回右值引用的特性的:

// move的定义,目标是对任意形式的输入都进行类型推断并返回推断的类型T的右值引用
        // 根据实参推断出T的类型,左值则推断出左值引用t&,右值则是去掉右值引用的t
        // 按照推断出来的类型T实例化emove_reference<T>::type
        // 得到去掉引用引用的类型t,这样便确定下返回值是t&&
        // 如果是左值,则直接加上右值引用并正确返回
        // 再回去看模板函数的参数,发现此时的实参T&&则由两种情况,t&&或(t&)&&,发生引用折叠得t&
        // 所以对于左值,实例化得 t&& move(t& inp); 对于右值,实例化得 t&& move(t&& inp)
template<typename T>
typename remove_reference<T>::type&& move(T&& inp) {
        return static_cast<typename remove_reference<T>::type&&>(inp);
}
  • 在move中我们在返回正确的类型时进行了强制类型转换static_cast,这里要注意是有另一个特例,我们不能隐式将左值转为右值引用,但是可以用static_cast显式转换且这个这个对左值的截断是安全的
  • 看了move的实现后尽管我们可以自己实现左值到右值引用的转换了,但是还是推荐用move,这样让代码更统一可靠
  • 某些函数需要将实参连通类型原封不动地传递给其他函数,需要保持实参的所有性质包括const和左右值属性等,此时我们需要用到“转发”
  • 完成函数参数转发的关键是利用右值引用参数,当使用右值引用参数是输入参数的const和左右值属性会得到保持,因为const由于底层const特性不会被删去,左值会成为右值引用,右值会成为拷贝
  • 但是直接利用右值引用参数会丧失右值引用属性,这是我们可以通过让右值引用后进入函数的参数调用utility文件中的forward函数,这个函数利用引用折叠特性让左值引用返回左值引用,右值返回右值引用,正好就达到了恢复属性的目标。然后再用得到的信息正确的参数传递给其他函数,这就是转发操作

16.3 重载与模板

  • 函数模板可以被另一个模板或非模板函数重载,与平时一样名字相同的函数需要参数不同才能重载
  • 但是对于函数模板来说,实参调用的函数会是重载版本中的哪一个需要按照以下规则来判断:
  • 上面复杂的规则总结起来就是“更特例化”,在没有歧义的情况下,永远会调用发生了最少改变,最精确匹配,最不需要调用自定义类型转换(内置类型转换的优先级更高),最不需要调用模板的那个重载
  • 当编译器缺少一个合适的重载函数时,编译器也会从模板函数中实例化出可以调用的合适的函数
  • 因此一般在编写重载函数的时候会编写多个比较特例的函数然后保留一个接受const T&的模板函数来兜底防止失去匹配
  • 在定义任何函数前异地你更要记得声明所有重载的函数版本防止编译器忽略你想要的版本而实例化了另一个

16.4 可变参数模板

  • 可变参数模板就是一个能接受数目可变类型也可变的参数的类,那些可变的参数部分称为参数包。参数包自然也有两种:模板参数包,函数参数包
  • 参数包用起来比initializer_list更自由,因为类型和数目都可变了
  • 我们在需要标记为参数包的参数类型后面加上三点省略号…如下

// 首先需要写模板参数包
template<typename T, typename... Q>
// 然后函数参数中对应模板参数包写函数参数包
int test(T t, Q... q) {
        // 用sizeof...()可以返回参数包中参数的数量
        return sizeof...(q);
}
  • 对于不同的函数调用,编译器会实例出不同版本的模板函数,这里要注意一个模板只能有一个参数包存在,且参数包一般被写在最右方防止二义性,如果出现了二义性,我们可以显式在调用时尖括号里标明各个模板参数的类型
  • 可变参数的模板函数通常是一种递归函数,一般我们编写的时候都会递归地分析包中的内容并调用直到终止,将包中的内容分解成元素称为包扩展
  • 包扩展的一种用法是用来扩展提取输入的参数:
// 递归终止函数,一般是处理参数包的最后一个函数用的
template<typename T>
void print(T inp) {
        cout << inp;
}

// 包扩展函数,通常递归调用自己,参数是传入的包但是第一个参数是固定的
// 通过固定的第一个参数从包中提取出一个参数输出,然后继续递归
// 通过省略号对参数进行包扩展,会将包中的内容展开为一个重载函数调用
template<typename T, typename... bag>
void print(T inp, bag... b) {
        cout << inp;
        print(b...);
}

int main() {
        print(1, 2, 3, 4, 5);        // 会输出12345
        return 0;
}
  • 包扩展的另一种用法是对包中的每个元素都自动调用一个指定的函数,并返回处理后的返回值:
// 和前面的print一致
template<typename T>
void print(T inp);
template<typename T, typename... bag>
void print(T inp, bag... b);

// 测试改变的元素
int add(int i) {
  return i + 1;
}

// 包装函数
template<typename... bag>
void func(bag... b) {
  // 包扩展在这里,通过对包调用函数后用省略号扩展
  // 相当于让整个包的每个元素都进行了一次函数处理然后才传入
  print(add(b)...);
}

int main() {
  func(1, 2, 3, 4, 5);        // 会输出23456
  return 0;
}
  • 然后类似16.2的转发部分,我们可以将可变参数模板和forward与右值引用组合起来,具体的方法就是按照包扩展的第二种用法来调用forward

16.5 模板特例化

  • 有时候我们希望对于一些特殊的类型可以不要进行模板化操作而是自动选择所需的特殊版本,这称为模板特例化
  • 模板特例化的写法是将template尖括号中的需要特例化的内容删去,然后对下方用到的模板类型转为需要确定的类型
  • 要注意即使我们需要特例化所有的类型参数也要保留一个空的尖括号做标记
  • 完全的模板特例化的本质是模板的一个实例,而不是重载,因此特例化不会影响函数的匹配。但如果只是部分特例化的模板则仍然是模板,依然会参与匹配,部分特例化的版本的模板参数列表是原始模板参数列表的一个子集或者是一个特例化版本
  • 通常为了正常的模板匹配我们都会在同一个头文件中写好所有同名模板的声明,而且其中模板在前,特例化版本在最后面
  • 我们也可以特例化类模板,此时必须在原模板定义的命名空间中特例化它。打开命名空间的方法是写namespace XXX{},这个大括号中的区域相当于目标命名空间内,我们可以在里面操作。常用的用法是打开std空间特例化标准库函数
  • 我们甚至可以只特例化类中的某个成员函数而不是整个模板,写法其实就是将模板类中的某个函数在外部定义,然后这个定义以特例化模板函数的方法写出即可