C++模板编程简述

Last Updated on 2020年7月22日

模板作为一个C++中的一个高级特性,广泛应用于各种场合,以尽可能弥补自身作为编译语言的动态能力.然而,除非必要,模板应当尽可能的简单,避免本末倒置.

模板使用基础

  • 模板特性是编译器和链接器相互配合实现的.这可以辅助我们理解它的特性.
  • 编译期实例化时
    • 模板类型参数通常得是完全类型.
    • 模板非类型参数必须是constexpr
  • 在模板编程中,编译器对T::foo型语法分析可能会有歧义,例如,T::vl * p这样的语句可能是使用T的静态成员做乘法,也可能是创建一个T::vl *型的指针.
    • 默认情况下,编译器认为给出的所有名字都是变量/函数,而非类型.
    • 此时需要typename来消除这种歧义例如typename T::MyType * p
  • TemplateName<T>才是类型名,TemplateName仅仅是模板名,而不是类型名.
    • TemplateName<T>类模板定义的内部使用模板时,TemplateName<T>可以被简写为TemplateName.
  • 规定:在模板定义阶段,模板参数名不能被隐藏/重载
  • 模板参数允许默认实参.若函数模板的所有参数都有默认实参,使用TemplateName<>来使用默认类型.
  • C++中的typedef只能用于类型别名,该特性无法用于给模板起别名,此时需要使用using模板

常见场景

  • 函数模板:批量的定义函数
template <...>
ret-type Foo(args){
....
}
  • 类模板:批量的定义类
template <...>
class Foo{
....
}
  • 成员函数模板:批量的定义成员函数
class Foo{
    template<...>
    ret-type bar(args);
}
  • friend 模板:批量的添加友元
template <xxx>
friend class Pal<xxx>;//这里要求Pal是模板

  • using 模板:批量的起别名(一般只有函数模板/类模板才需要)
template <X,Y>
using NewTempName<X,Y>=OldTempName<int,X,Y>;
// 起别名时还可以固定部分参数

模板特化

  • 我们可以通过模板特化影响最优模板匹配的结果,因为”越特化的模板”优先于”普通的模板”.
  • 至于语法结构,可以看书或者搜索.

模板实例化

  • 总结规律:
    • 全特化的函数模板,总是应该在头文件中仅声明,在独立的cpp源文件中进行定义.
    • 其他场合中,总是应该在头文件中进行完整的定义.
  • 我们在代码中使用模板时,只有三个场景:
    • a. 通过类模板创建实例,例如Myclass<int> obj;
    • b. 调用函数模板触发函数调用,例如Myfun<int>(args)
    • c. 通过类模板的实例调用成员函数,例如obj.foo()
  • 上述三个场景都会涉及模板实例化的问题.对于函数模板而言,还会涉及链接的问题.

通用规则:

  • 全特化的类是普通类,不是模板.
  • 全特化的函数是普通函数,不是模板
    • 因此,在所有编译单元中,只允许存在一份全特化函数的定义,否则会有链接期重定义错误
// 例子1. 全特化

// a. 全特化的模板是普通定义,而不是模板
template<class T>
struct Point{
    T x;
    T y;
    T area();
}

template<>
sturct Point<double>{
    double x;
    double y;
    double z; // 特化的模板 is allowed to 改变数据成员
    double area();
}

// 注意1,这里没有template<>,因为类型已经被全特化了,我们是在为一个**普通类**添加类外成员函数定义.
// 注意2,既然是普通的类外定义,那么这个定义就不能出现在头文件中,否则会出现常规的重定义错误.
double Point<double>::area(){

}

// b. 全特化的函数模板是普通函数.
template<class T>
void foo(T arg){

}

// 注意,这是一个全特化的函数模板,它是一个普通函数,因此也不能在头文件中定义.
template<>
void foo<double>(double arg){

}
  • 对于场景a, 这个特性完全是在编译期实现的.
    • 编译器在当前文件的语法树中查找Myclass<T>的全特化定义.
    • 如果找到全特化定义,则使用该版本.(用户的全特化定义也包括在内)
    • 如果找不到全特化定义,则编译器根据最优匹配,隐式地插入一份全特化的定义.
    • 注意: 上述规则只在编译期对每个编译单元独立执行,用户需要自己保证:在所有编译单元中遵循ODR约定.
  • 对于场景b和场景c,这两个特性是在编译期/链接期共同配合实现的.
    • step1. 编译器先依照重载规则查找可能被调用的函数,如果最终被调用的是模板,则在调用处根据模板实参确定待调用函数的签名function_signature,如果用户没有显式的给出模板实参,那么编译器会先依照类型推断进行模板实参推导.
    • step2. 在函数调用处,编译期只会生成一个un-resolved函数调用代码,也就是一个call function_signature语句
    • step3. 编译器根据模板实参,尝试在当前编译单元依最优匹配生成一份函数定义,并为这个定义打一个mark,说明它是编译器生成的
    • 生成操作是尝试性的,目的是为function_signature提供一个候选的链接实体,生成失败不会有任何编译期错误.
    • 代码生成后,也不会对当前编译单元内的函数调用进行reslove的操做.
    • 如果代码树中有extern 声明,表明function_signature是在外部定义的,那么可以抑制编译器的自动生成行为.
    • 另外,如果一个函数模板没有被调用,那么step1都不存在,自然不会到step3,编译器根本不可能插入任何代码.也就是所谓的”不被调用的函数模板不会参与实例化”
    • step4. 在链接期,编译器会做两件事:
    • step a. 对于编译器生成的模板函数定义,如果函数签名相同,则只保留某个编译单元的一份.
      • 至于保留哪一份,用户无法控制. 换言之,需要由用户保证所有编译单元内的函数定义都是一致的,否则就会有UB.
    • step b. 开始进行resolve, 此时,如果发现全特化的定义(全特化定义没有mark),那么全特化的定义优先于编译器生成的版本进行链接.
// 例子2. 函数模板调用的ub
// 这里 编译器分别为caller1.cpp和caller2.cpp各自生成了一份函数定义,最后保留的是哪一份是未知的
// header.h
template<class T1,class T2>
struct Caller{
    void call();
}

template<class T1,class T2>
void Caller<T1,T2>::call(){
    printf("header.h call \n");
}

// caller1.cpp
#include "header.h"

template<class T1>
struct Caller<T1,double>{
    void call();
}
template<class T1>
void Caller<T1,double>::call{
    printf("caller1.cpp call \n");
}

void caller1(){
    Caller<int,double> foo;
    foo.call();
}


// caller2.cpp
#include "header.h"

template<class T2>
struct Caller<int,T2>{
    void call();
}

template<class T2>
void Caller<int,T2>::call{
    printf("caller2.cpp call \n");
}

void caller2(){
    Caller<int,double> foo;
    foo.call();
}


// main.cpp

void caller1();
void caller2();

void main(){
    caller1();
    caller2(); // 这里,caller1()和caller2()调用会输出同样的结果,至于是哪个结果,则是UB的.
}


显式实例化

  • 之前谈到了,使用extern可以抑制编译器生成函数代码的行为,使得编译速度加快.显式实例化则是强迫编译器生成函数代码的操作.
//TemplateName.cc
#include "TemplateName.h"

template class Foo<int>;//类模板的手动实例化,会导致所有成员函数都被生成一份代码
template void func(int);//函数模板的手动实例化

模板推断特性

推断的具体规则可以参考另一篇文章类型推断

  • 可变返回类型:在函数模板中,返回类型可能会随着输入类型改变,此时可以用 尾置+decltype
template<class T>
auto foo(T Iter) -> decltype(*Iter){
    ...
    return *Iter;

}

完美转发

  • 在C++模板转发中,const T &T&&可以处理大部分需要转发的场景,但是它的转发是不完美的.因为在模板定义的大括号{}内部,使用形参名时都是在使用左值,所以总有一部分场合无法处理.此时可以使用T&& arg形参,利用类型推断和引用折叠机制,通过std::foward<T>(arg),就可以把arg重新cast为其原始类型,实现完美转发.可以参考引用类型推断内的相关内容

可变参数模板

  • 可变参数模板可以处理参数类型/个数都未知的场景.
  • 可变参数模板中,可变参数被封装的部分称为包,可以将包展开以使用包中的实参.
    • 包只能被展开为若干实参,相当于编译期的一串代码arg1,arg2,arg3
    • 模板参数的包和函数形参的包一般会配合着展开
  • 具体而言,一般使用递归的方式展开包,然后通过完美转发来逐个处理参数.
    • 由于递归模板会产生大量的函数调用,因此应当尽量以引用传值,避免拷贝引起的开销.
    • 在条件允许时,不用完美转发而使用T&&const T&也是可以的.
#include "stdafx.h"
#include <iostream>
#include <string>


// 该函数模板用于终止递归
template<class T>
void func(int simple_arg,T&& single_arg)
{
    //DO_SINGLE
}

// 主函数模板
template<class T, class... ArgList>
void func(int simple_arg, T && single_arg, ArgList&&... other_args) {
    // 本例中并没有使用simple_arg,这个参数仅仅是用于占个位置,代表了普通的传入参数.

    // process single arg
    func(simple_arg,std::forward<T>(single_arg));

    //recurse others
    func(simple_arg, std::forward<ArgList>(other_args)...);
    // 在ArgList内只含有一个参数时,func()内实质仅有两个参数,此时将会使用更加特化的single arg版本,递归就终止了。
}

//调用代码

int main()
{
    int a=10;
    double b=6.5;
    std::string ss = "shit";
    func(8, a,b,ss,a,ss);
    return 0;
}