What & How & Why

模板与泛型编程

C++ Primer 笔记 第十六章


模板的定义

模板(template)属于生成函数&类的一种方式,主要通过替换类型来简化类型不同但其他都相同的主体的定义。

函数模板的定义

模板的定义由以下两部分组成:

  • 关键词 template
  • 模板参数列表template parameter list),不能为空

一个模板函数的实现:

template <typename T>
int compare(const T &v1, const T &v2)
{
    if (v1 < v2) return -1;
    if (v2 < v1) return 1;
    return 0;
}
模板参数列表中的 argument 被称为 tempalte argument;这些参数会与函数的参数绑定,而作为类型的 T 将在绑定时由编译器推断出具体的类型。

函数模板的实例化

函数模板再被调用的时候,会根据绑定参数的类型来推断出模板类型的具体类型;这个过程被称为模板的实例化instantiation)。通过实例化,编译器将为不同类型的参数生成不同的“实例”来供这些参数使用。

模板类型的 parameter

以之前的 compare()为例,由模板定义的参数列表中,T 被称为类型为模板类型的 parameter(template type parameter)。这个参数由关键字 typename 修饰,意味着其可以被当做一种类型来使用,包括但不限于:

  • 函数的返回值类型
  • 函数的参数类型
  • 函数内部的本地变量类型

// ok: same type used for the return type and parameter
template <typename T> T foo(T* p)
{
    T tmp = *p; // tmp will have the type to which p points
    // ...
    return tmp;
}
表示类型的 模板类型 parameter 需要被关键字 typename 或者 class 修饰,每一个类型都必须拥有独立的关键字:
template <typename T, class U> calc (const T&, const U&);
两个关键字是等价的,但由于 typename 更容易辨识,因此首选该关键字。

非类型的模板 parameter

除了表示类型的模板 parameter 以外,还有一种表示的模板 parameter。这种模板参数被成为非类型 parameter(Non-type parameter)。这种参数前会由具体的类型代替 typename

当模板实例化的时候,非类型的 parameter 会被替换为函数获取的对应值(或是由编译器推断决定)。由于模板的实例化实在编译期进行,因此该值必须是常量表达式。比如下方的例子,我们可以利用非类型的 parameter 对数组的长度进行替换:

template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2)[M])
{
    return strcmp(p1, p2);
}
当调用以下命令时,NM 会被编译器自动替换为 34
compare("hi", "mom");
非类型 parameter 可以表示以下类型:

  • intergral type
  • 指针
  • 对象或者函数左值引用

指针和引用绑定的 argument 必须拥有静态生命周期static life time),也就是说,指向(引用自)静态的本地对象、动态分配的对象的指针(引用)都是不能作为这种参数的。

绑定 nullptr 的指针可以作为非类型参数。

一切都是常量表达式的要求。

inline 和 constexpr 类型的函数模板

inline 或者 constexpr 函数也可以使用模板。但需要注意的是,关键字 inlineconstexpr 的位置需要在模板参数列表的后面

// ok: inline specifier follows the template parameter list
template <typename T> inline T min(const T&, const T&);
// error: incorrect placement of the inline specifier
inline template <typename T> T min(const T&, const T&);

模板需要最小化参数类型的影响

本章开头的例子 compare 中,有几点思路是可以借鉴的:

  1. 首先,我们可以通过统一运算符来减少模板对类型的依赖。比如该函数的大小比较中,只使用了 <。这是因为很多类型默认定义了 <,而不是其他的关系运算符;使用 < 可以保证更高的兼容性。该运算符甚至可以被替换为 !=(如果实际类型是迭代器),或者标准库的 less 仿函数。
  2. 将函数参数设置为 const 类型。这样可以兼容某些不允许拷贝的类型。
模板的编译

模板下的代码生成与普通函数不太一样:

  • 模板在定义可见的时候不会生成代码;只有在实例化的过程中会生成代码。
  • 编译器在模板在实例化的过程中需要其声明与定义都可见(类似 inline)。因此,通常模板声明与定义均被置于头文件中。

关键概念:

  • 模板的提供者需要保证模板对实例化时的参数(带具体类型的参数)没有依赖
  • 模板的提供者需要保证实例化时,模板的声明和定义是完整可见的
  • 而具体的,需要被实例化的类型参数的正确性,由使用者保证

实际操作中,由编写者提供带模板实现的头文件,使用者包含该头文件并提供对应的实例化信息。

编译错误几乎都处于实例化阶段

模板的编译存在三个阶段:

  1. 模板的自我编译阶段,这个阶段只会检查语法错误
  2. 模板被初次使用时,这个阶段会检查调用者的参数数量,以及参数是否具有相同的类型
  3. 模板的实例化阶段,类型相关的错误只会在这个阶段被发现(可能会在链接期报错)

比如本章开头的 compare

if (v1 < v2) return -1;  // requires < on objects of type T
如果我们传入没有定义 < 操作的对象,那么该错误是不会再模板自身被编译的时候被检查到的。只有当编译器以该对象实例化后,发现该对象并不能进行 < 运算,才会报错。也就是说,错误是由于实例化的代码不能编译引起的。从这点上看,模板自身是无法保证具体参数的合法性的,这部分内容的合法性需要由使用者保证。

类模板的定义与实例化

类模板(Class Tempalte)用于类的生成。与函数模板的不同,类模板无法推断模板参数的类型,使用时需要由用户添加。

类模板的定义

类模板的定义分几个点:

  • 类模板名之前需要加上模板关键词与模板参数列表,比如 template <typename T>
  • 类中所有由模板指定的具体类型,都需要用对应的模板参数替换掉,比如 T

Blob 类为例子:

template <typename T> class Blob {
public:
    typedef T value_type;
    typedef typename std::vector<T>::size_type size_type;
    // constructors
    Blob();
    Blob(std::initializer_list<T> il);
    // number of elements in the Blob
    size_type size() const { return data->size(); }
    bool empty() const { return data->empty(); }
    // add and remove elements
    void push_back(const T &t) {data->push_back(t);}
    // move version; see § 13.6.3 (p. 548)
    void push_back(T &&t) { data->push_back(std::move(t)); }
    void pop_back();
    // element access
    T& back();
    T& operator[](size_type i); // defined in § 14.5 (p. 566)
private:
    std::shared_ptr<std::vector<T>> data;
    // throws msg if data[i] isn't valid
    void check(size_type i, const std::string &msg) const;
};

类模板的实例化

类模板的实例化需要提供额外的信息。这些信息被称为显式模板实参 (explicit template arguments),比如下面表达式中的 int

Blob<int> ia2 = {0,1,2,3,4};
每个类模板的实例化创建的都是一个独立的类类型,相互之间没有任何联系。

在模板的作用域类引用模板类型
  • 类模板名不是类型,其实例化后的类才能表示类型;这种类型被成为实例化类型
  • 实例化类型由类模板名模板 parameter(注意不是具体的 template argument) 组成,缺一不可

比如:

//data definition 
std::shared_ptr<std::vector<T>> data;
//if instantiated by Blob<string>
//data type
shared_ptr<vector<string>>

定义模板类的成员函数
  • 模板类的成员函数会受到模板类实例化的影响。其 template parameter 与模板类一致
  • 定义于模板类外部的成员函数,其所属必须带上 template parameter list
  • 成员函数所属的类也必须带上 template parameter

//template parameter list the first
template <typename T>
//which class the member belongs the second
//if return type is the class type, it should include the template parameter as well
return-type
Blob<T>::member_function(parameter_list) { .... }

Blob 的相关实现及注意要点

普通成员中,主要替换的是所属的类,以及返回类型。注意模板关键字与模板参数列表都是必不可少的:

/* check */
template <typename T>
void Blob<T>::check(size_type i, const std::string &msg) const
{
    if (i >= data->size())
        throw std::out_of_range(msg);
}

/* back */
template <typename T>
T& Blob<T>::back()
{
    check(0, "back on empty Blob");
    return data->back();
}

/* subscript */
template <typename T>
T& Blob<T>::operator[](size_type i)
{
    // if i is too big, check will throw, preventing access to a nonexistent element
    check(i, "subscript out of range");
    return (*data)[i];
}

/* pop_back */
template <typename T> void Blob<T>::pop_back()
{
    check(0, "pop_back on empty Blob");
    data->pop_back();
}
构造函数中类似。如果构造函数接受任何模板类型的 parameter,需要将类模板的 template parameter 对其类型进行替换:
template <typename T>
Blob<T>::Blob(): data(std::make_shared<std::vector<T>>()) { }

template <typename T>
Blob<T>::Blob(std::initializer_list<T> il):
              data(std::make_shared<std::vector<T>>(il)) { }

类模板成员函数的实例化

类模板成员函数只有在被使用的情况下才会实例化

在模板类中使用该模板类型

模板类类型在类中可以省略 template argument。比如下例中的 BlobPtr,其类型本应该由 BlobPtr<T> 组成,但在类中 <T> 这一部分是可以省略的:

// BlobPtr throws an exception on attempts to access a nonexistent element
template <typename T> class BlobPtr
public:
    BlobPtr(): curr(0) { }
    BlobPtr(Blob<T> &a, size_t sz = 0):
            wptr(a.data), curr(sz) { }
    T& operator*() const
    { auto p = check(curr, "dereference past end");
      return (*p)[curr];  // (*p) is the vector to which this object points
    }
    // increment and decrement
    BlobPtr& operator++();        // prefix operators
    BlobPtr& operator--();
private:
    // check returns a shared_ptr to the vector if the check succeeds
    std::shared_ptr<std::vector<T>>
        check(std::size_t, const std::string&) const;
    // store a weak_ptr, which means the underlying vector might be destroyed
    std::weak_ptr<std::vector<T>> wptr;
    std::size_t curr;      // current position within the array
};
编译器默认所有模板类中的模板类型都拥有 template argument。

在模板类外使用该模板类型

如果在模板类外部使用模板类类型,那该类型必须是完全体,也就是模板名 + 模板参数列表 + 模板类所属 + 函数定义的形式,一个都不能少。比如下方的例子

// postfix: increment/decrement the object but return the unchanged value
template <typename T> //template name with template parameter list
BlobPtr<T> BlobPtr<T>::operator++(int)  //return type and class to which the function belong, must be wrote in full
{
    // no check needed here; the call to prefix increment will do the check
    BlobPtr ret = *this;  // save the current value
    ++*this;    // advance one element; prefix ++ checks the increment
    return ret;  // return the saved state
}
需要注意的是,该函数定义内部的作用域属于模板类内部,因此 BlobPtr ret 省略了 template argument 是合法的。

类模板与友元

类模板与友元的关系可以大致定义为以下几类:

  • 一对一的关系(One-to-One Friendship
  • 特定的一对一关系
  • 一对多的关系
  • 多对一的关系
  • 多对多的关系

这些关系的对象可以是模板与具体(函数、类)的关系,也可以是模板与模板之间的关系。

一对一的关系

需要提供模板的前置声明

当友元关系为一对一的关系(One-to-One Friendship)时,其友元关系实际上是属于具体实例之间的友元关系。具体的来说,只有实例化类型匹配的模板,才能访问目标类模板。其声明方法如下:

// forward declarations needed for friend declarations in Blob
template <typename> class BlobPtr;
template <typename> class Blob; // needed for parameters in operator==
template <typename T> bool operator==(const Blob<T>&, const Blob<T>&);

template <typename T> class Blob
 {
    // each instantiation of Blob grants access to the version of
    // BlobPtr and the equality operator instantiated with the same type
  
    // class friend
    friend class BlobPtr<T>;
    
    //function friend
    friend bool operator==<T> (const Blob<T>&, const Blob<T>&);
    // ...
};
重点在于友元模板类与函数模板之后的那个 template argument <T>
friend class BlobPtr<T>;
friend bool operator==<T> (/ .... /)

当这个 <T> 存在,且与目标类模板的一致时,这种一对一的关联就建立起来了。通过以上的声明,友元关系就可以在同实例化类型的类与类(函数)之间建立,比如:

// BlobPtr<char> object is friend class to Blob<char> object
// operator==<char> function is friend function to Blob<char> object
Blob<char> ca;

特定的一对一关系:类模板的具体类实例与对应具体类

需要提供模板的前置声明。

除了上述的一种一对一关系以外,如果模板类以某个具体类作为类型进行实例化,那么实例化后的类型将可以以友元的方式访问具体类:

template <typename T> class Pal;
class C {  //  C is an ordinary, nontemplate class
    friend class Pal<C>;  // Pal instantiated with class C is a friend to C
};
上例中,Pal<C> 类型的对象可以以友元的方式访问 C

多对一的关系:类模板的所有实例与对应具体类

不需要提供任何形式的前置声明。

该关系描述的是类模板的所有实例,都是某一个具体类的友元的关系:

class C {  //  C is an ordinary, nontemplate class
    // all instances of Pal2 are friends to C;
    // no forward declaration required when we befriend all instantiations
    template <typename T> friend class Pal2;
};
以上面的例子来讲,如果模板类 Pal2 不以其实例化类型 Pal2<C> 进行友元关系的声明,那么得到的结果是: Pal2所有实例化类型都可以通过友元的方式访问 C

一对多的关系:具体类与对应类模板的所有实例

不需要提供任何形式的前置声明。

该关系的描述的是:当某个具体类声明了与类模板的友元关系,那么该具体类可以以友元的方式访问类模板的所有实例化类型对象:

template <typename T> class C2 { // C2 is itself a class template
{
    // Pal3 is a nontemplate class that is a friend of every instance of C2
    friend class Pal3;    // prior declaration for Pal3 not needed
};
上面的例子中,具体类 Pal3 可以访问所有 C2 的实例。

多对多的关系:类模板与类模板

不需要提供任何形式的前置声明。

该关系描述的是,在类模板中以非一对一形式声明的友元关系,可以使对应的类模板的所有实例以友元的方式访问目标类模板的所有实例。需要注意的是,这种情况下声明的友元类模板,必须拥有独立的 template argument,否则编译时会报 shadow 错误:

template <typename T> class C2 { // C2 is itself a class template
{
    // all instances of Pal2 are friends of each instance of C2;  no prior declaration needed
    template <typename X> friend class Pal2;
}
上例中, 所有 Pal2 的实例化类型对象都可以以友元的方式访问 C2 的所有实例化对象。注意 Pal2 的 template argument 是 X,与 C2T 是不一样的。

以模板参数类型作为友元

C++11 中提供了以模板参数类型作为友元的功能:如果类模板以某种类型实例化,且类模板中声明了对该类型的友元关系;那么所有以该种类型定义的对象,函数(甚至 built-in type)都可以以友元的方式访问类模板的该种类型实例:

template <typename Type> class Bar {
friend Type; // grants access to the type used to instantiate Bar
    //  ...
};
举例来说,如果 Type 对应的实例类型是 Foo,那么所有类型为 Foo 的对象,都可以以友元的方式访问 Bar<Foo> 类型的实例。下面是个简单的示例:
template<typename T> 
class Bar {
    friend T;
protected:
    int val= 100;
};

class Foo {
public:
    void print_bar(Bar<Foo> &bar){ std::cout<<"bar:\t" << bar.val << std::endl; }
};

int main(int argc, char const *argv[])
{
    Bar<Foo> bar;
    Foo foo;
    foo.print_bar(bar);
    return 0;
}

类模板的其他功能

Template Type Aliases

早期的 C++ 可以做类模板实例的 alias:

typedef Blob<string> StrBlob;
C++ 11 中可以对类模板直接进行 alias:
template<typename T> using twin = pair<T, T>;
twin<string> authors; // authors is a pair<string, string>
对于存在多个参数的情况,可以单独指定某个参数作为 alias:
template <typename T> using partNo = pair<T, unsigned>;
partNo<string> books;  // books is a pair<string, unsigned>
partNo<Vehicle> cars;  // cars is a pair<Vehicle, unsigned>
partNo<Student> kids;  // kids is a pair<Student, unsigned>

类模板的静态成员

类模板的静态成员以实例类型为单位:所有类型相同的实例共享一个静态成员,但不同类型的实例之间存在不同的静态成员:

// instantiates static members Foo<string>::ctr and Foo<string>::count
Foo<string> fs;
// all three objects share the same Foo<int>::ctr and Foo<int>::count members
Foo<int> fi, fi2, fi3;
因为静态成员以实例类型为单位,因此定义时需要加上 template argument:
template <typename T>
size_t Foo<T>::ctr = 0; // define and initialize ctr
与之对应的是,访问静态成员时也需要指定实例化的类型:
Foo<int> fi;                 // instantiates Foo<int> class
                             // and the static data member ctr
auto ct = Foo<int>::count(); // instantiates Foo<int>::count
ct = fi.count();             // uses Foo<int>::count
ct = Foo::count();           // error: which template instantiation?

模板参数

模板参数名字没有内在的意义;T 可以替换为任何的名字。

模板参数和作用域
  • 模板参数的名字会隐藏任意外部定义的同名
  • 被用于模板参数的名字不能被内部变量再次使用

typedef double A;
template <typename A, typename B> void f(A a, B b)
{
    A tmp = a; // tmp has same type as the template parameter A, not double
    double B;  // error: redeclares template parameter B
}
上例中,tmp 对应的 A 是模板参数,B 已经作是模板参数了,因此不能在用于内部的 double 声明。

  • 模板参数的名称是唯一的

// error: illegal reuse of template parameter name V
template <typename V, typename V> // ...

模板的声明
  • 模板的声明需要包含模板参数:

// declares but does not define compare and Blob
template <typename T> int compare(const T&, const T&);
template <typename T> class Blob;

  • 声明中的模板参数名不要求与定义中相同,但数量与类型要求一致:

// all three uses of calc refer to the same function template
template <typename T> T calc(const T&, const T&); // declaration
template <typename U> U calc(const U&, const U&); // declaration
// definition of the template
template <typename Type>
Type calc(const Type& a, const Type& b) { /* . . . */ }

所有模板的声明应该放置于使用这些模板的代码所在文件的头部。

使用模板类类型的成员

由于编译器在模板实例化之前无法确定类模板的具体类型,在下例这种情况中,编译器无法确定 T::size_type 是否是类型参数:

T::size_type * p;

  • T::size_type 是类型时,表达式是在定义名字为 p 的指针
  • T::size_type 是成员变量时,表达式是该变量与 p 的乘积

默认情况会将 T::size_type 不视作类型。如果我们希望将其视作类型,需要在前面显式的加上关键字 typename

template <typename T>
typename T::value_type top(const T& c)
{
    if (!c.empty())
        return c.back();
    else
        return typename T::value_type();
}

这种情况下不能使用 class 代替 typename

默认的 template argument

C++ 11 中允许在函数 / 类模板中使用默认的 template argument:

// compare has a default template argument, less<T>
// and a default function argument, F()
template <typename T, typename F = less<T>>
int compare(const T &v1, const T &v2, F f = F())
{
    if (f(v1, v2)) return -1;
    if (f(v2, v1)) return 1;
    return 0;
}
上例中:

  • 模板参数 F 接受的是一个 callable object 参数,默认值使用了标准库函数 less()
  • 在没有重新指定giant参数时,被使用的 f 绑定了 less,绑定方式为 F f = F()

调用时如果指定了其他的 callable object,则使用该对象作为比较策略:

bool i = compare(0, 42); // uses less; i is -1
// uses compareIsbn, result depends on the isbns in item1 and item2
Sales_data item1(cin), item2(cin);
bool j = compare(item1, item2, compareIsbn);
如果希望只使用默认的模板参数,那么三角括号 <> 对是不能省略的:
template <class T = int> class Numbers {   // by default T is int
public:
    Numbers(T v = 0): val(v) { }
    // various operations on numbers
private:
    T val;
};
Numbers<long double> lots_of_precision;
Numbers<> average_precision; // empty <> says we want the default type

成员模板

成员模板(Member Template)指本身为模板,但同时又作为类或者类模板的成员的成员函数。成员模板不能是虚函数

普通类的成员模板
  • 成员模板具有独立的模板参数列表

// function-object class that calls delete on a given pointer
class DebugDelete {
public:
    DebugDelete(std::ostream &s = std::cerr): os(s) { }
    // as with any function template, the type of T is deduced by the compiler
    template <typename T> void operator()(T *p) const
      { os << "deleting unique_ptr" << std::endl; delete p; }
private:
    std::ostream &os;
};
此处重载运算符 () 可以为指定的指针进行内存释放并打印信息。调用方式如下:
/* bulit-in variable */
double* p = new double;
DebugDelete d;    // an object that can act like a delete expression
d(p); // calls DebugDelete::operator()(double*), which deletes p
int* ip = new int;
// calls operator()(int*) on a temporary DebugDelete object
DebugDelete()(ip);

/* unique_ptr */
// destroying the the object to which p points
// instantiates DebugDelete::operator()<int>(int *)
unique_ptr<int, DebugDelete> p(new int, DebugDelete());
// destroying the the object to which sp points
// instantiates DebugDelete::operator()<string>(string*)
unique_ptr<string, DebugDelete> sp(new string, DebugDelete());

智能指针的构造函数使用方法可以 参考 cpp reference。此处的 deleter临时无名对象 DebugDelete() 的形式存在。

类模板的成员模板
  • 类模板的模板参数与成员模板参数是独立于彼此的

template <typename T> class Blob {
    template <typename It> Blob(It b, It e);
    // ...
};
这里的 TIt 会被用于两种不同的函数参数。

  • 成员模板是函数模板
  • 成员模板需要有独立的模板参数列表,用于函数参数
  • 类模板的模板参数列表在前,成员模板的列表在后

template <typename T>     // type parameter for the class
template <typename It>    // type parameter for the constructor
    Blob<T>::Blob(It b, It e):
              data(std::make_shared<std::vector<T>>(b, e)) { }
上面的例子展示了成员模板的类外定义的方法,可以看出需要同时写出类模板与成员模板的模板参数列表。

成员模板的实例化
  • 成员模板的实例化必须同时提供类模板与成员模板的 template arguments

int ia[] = {0,1,2,3,4,5,6,7,8,9};
vector<long> vi = {0,1,2,3,4,5,6,7,8,9};
list<const char*> w = {"now", "is", "the", "time"};
// instantiates the Blob<int> class
// and the Blob<int> constructor that has two int* parameters
Blob<int> a1(begin(ia), end(ia));
// instantiates the Blob<int> constructor that has
// two vector<long>::iterator parameters
Blob<int> a2(vi.begin(), vi.end());
// instantiates the Blob<string> class and the Blob<string>
// constructor that has two (list<const char*>::iterator parameters
Blob<string> a3(w.begin(), w.end());

显式实例化

由于模板本身并不是类型,因此模板的实例化需要两个部分:

  • 模板的声明
  • 模板的定义

又因为模板实例化是在编译期完成的,因此通常情况下,模板的实例化要求声明与定义同时可见;这也是为什么模板通常把声明和定义都放到头文件中。但这样会带来两个问题:

  • 没有办法将声明与实现分离
  • 如果存在多个文件,编译器在无法得知哪些文件需要使用实例化的情况下,会对所有文件进行实例化。这样会带来额外的开销。

编译器无法得知所有文件对实例化的需求(也就是说,a.cc 中的模板定义不能被 b.cc 使用),因此每个文件都需要有属于自己的实例化。

C++11: Explicit instantiation declaration vs. explicit instantiation definition

C++ 11 中提供了显式实例化explicit instantiation)来解决上述的两个问题。显式实例化允许我们手动的指定:

  • 哪些类型需要实例化
  • 哪些文件需要实例化

C++ 通过分离实例化的声明和定义来达到这样的效果:

extern template declaration; // instantiation declaration
template declaration;        // instantiation definition
这两行实际上代替了之前普通实例化的定义部分:

  • 带有 extern 关键字的这一行,是声明。这行声明告诉我们本文件中只有模板实例化的声明,实例化的定义部分请去别的文件寻找。
  • 第二行则是实例化的部分。这部分会放到需要进行实例化的文件中。当这部分对编译器可见时,编译器才会对模板进行实例化。
显式实例化的使用

需要说明的是,如果是单纯的显式实例化,只需要在使用的地方对模板进行定义即可,并不会用到 extern。比如下面的结构:

// Foo.h
// no implementation
template <typename T> struct Foo { ... };

//Foo.cpp
#include "Foo.h"
// implementation of Foo's methods

// explicit instantiations
template class Foo<int>;
template class Foo<float>;
// You will only be able to use Foo with int or float
Foo.cpp 中做了对 Foo.h 的实现。当编译器看到最下方的两行定义时,Foo<int> 类型与 Foo<float> 类型会进行实例化,并将内容存放于 Foo.o 中。

extern 的主要作用是抑制所在文件的自动实例化。比如更通常的结构是将声明 / 实现 / 使用完全分开:



这种情况下就需要抑制实现文件里的实例化,因为我们只希望在使用的地方实例化。因此:

  • 实现的文件里需要进行 extern 的声明,因为我们不会在实现文件里使用模板的实例,因此不需要实例化
  • 使用的文件里进行模板的定义,说明我们需要在这里使用模板的实例

下面是一个具体的三文件结构例子:

/* template.h */

#ifndef TEMPLATE_H
#define TEMPLATE_H

//template declaration
template <typename T> class Test;

#endif

/* template.cc */

#include <iostream>
#include "template.h"

template <typename T> class Test
{
public:
    void set(T val) { val = x; }
    void print() 	{ std::cout << x; }
private:
    T x{};
};

//extern declaration, no instantiation in template.o
extern template class Test<int>;

/* main.cc */

#include "template.h"
#include "template.cc"

//template definition 
template class Test<int>;

int main(int argc, char const *argv[])
{
	Test<int> it;
	it.set(1);
	it.print();
	return 0;
}
除此之外,函数模板也可以通过这种方式进行显示实例化。以上面的成员函数 set 举例:
/* template.h */
template <typename T> class Test
{
public:
    void set(T val); // declaration of set
  //....other implementation
};

/* template.cc */

//implementation
template <typename T>
void
Test<T>::set(T val)
{
	x = val;
}

//extern declaration
extern template void Test<int>::set(int);

/* main.cc */

//template definition
template void Test<int>::set(int);

//....function call

一些注意事项
  • extern 声明可以存在多个,可以用于任何你不想产生实例的文件
  • 实例化的内容存储于 .o 文件中。从实现上来说,显式实例化是在链接期完成的
  • 类模板的实例化会同时实例化所有成员,即便成员没有被使用。显式实例化也可以应用于单个成员(见上例)
  • 显式实例化的定义类型需要满足成员函数的要求

效率与灵活性

在实际的设计过程中,我们可以通过模板类型的特性来帮助我们区分不同的设计需求。比较典型的是智能指针的 deleter:

  • shared_ptr 是分享型指针,因此也分享 deleter,因此通过指针访问 deleter
  • unique_ptr 是独享型指针,因此其自身保存对应的 deleter

根据这两种不同的需求,我们会发现:

  • shared_ptr 允许通过指针重写自身的 deleter,因此不需要显式的指定 deleter 的模板参数默认值。
  • unique_ptr 中,deleter 是自身的一部分,因此必须在定义的时候就指定 deleter 的类型(模板参数)

这两种需求实际上体现了在运行期或者编译期绑定 deleter 的区别。

在运行期绑定 deleter

由于 shared_ptr 要求可以共享并重写 deleter,因此只能通过间接的方式(指针(或者指针类,比如作业中会用到的 std::function))来存储 deleter。这导致 deleter 的具体定义只能在运行期确认。由于编译期无法确定 deleter 的类型,因此无需为其提供模板参数类型。对应的析构函数的逻辑如下:

// value of del known only at run time; call through a pointer
del ? del(p) : delete p; // del(p) requires run-time jump to del's location
只要指向 deleter 的指针不为空,就调用 deleter 进行析构操作。

在编译期绑定 deleter

deleter 是 unique_ptr 类型的一部分,因此需要在编译期就确认下来。也就是说,我们需要为 unique_ptr 提供另外一个模板参数来指定 deleter 的类型。其析构函数始终使用一开始定义好的 deleter:

// del bound at compile time; direct call to the deleter is instantiated
del(p);   // no run-time overhead

模板参数推断

模板的参数推断指编译器会根据调用中的 arugment 的类型来确定其模板参数的类型,并用该模板参数来生成满足调用的函数实例。

类型类模板参数与类型转换

当函数的 parameter 是模板参数时:

  • 被传递的 argument 如果是 top-const,依然会被忽略。
  • 只有两种类型转换会被执行:
    • non-const 的对象传递(绑定)到 low-const 的 parameter(reference & pointer to const)
    • 在函数 parameter 类型不是引用(非引用传递)的前提下,传递函数或者数组参数将转化为指针进行传递。引用传递下,数组将以数组类型被传递(包括数组的大小)

template <typename T> T fobj(T, T); // arguments are copied
template <typename T> T fref(const T&, const T&); // references

string s1("a value"); 
const string s2("another value");

fobj(s1, s2); // calls fobj(string, string); top const in s2 is ignored
fref(s1, s2); // calls fref(const string&, const string&)
              // as a non-const object, s1 will be bind to an reference to const string parameter
              
int a[10], b[42];
fobj(a, b); // calls f(int*, int*)
// error: array are not converted to pointer here, because fref takes reference type parameter.
// as a result, array are passed as array type. since size of array is a part of its type, thus types don't match here.
fref(a, b);

函数参数使用相同的模板参数类型的情况

对于相同的模板参数类型,需要确保最终推断的结果类型完全一致,否则会导致调用错误。比如:

template <typename T>
bool compare(T, T) {....}
long lng;
compare(lng, 1024); // error: cannot instantiate compare(long, int)
由于模板参数的类型转换中不存在算术类型的转换,因此模板推断结果冲突,调用失败。这种情况下可以使用不同的模板参数来解决:
// argument types can differ but must be compatible
template <typename A, typename B>
int flexibleCompare(const A& v1, const B& v2)
{
    if (v1 < v2) return -1;
    if (v2 < v1) return 1;
    return 0;
}
上述的 compare 最终接受了两种不同的类型;但需要注意针对该两种类型的 < 运算也需要被定义。

混用模板参数与普通参数的情况

函数的 parameter 可以混用模板参数与普通参数。此时普通参数的类型转换遵循普通参数的规则(第六章中的 normal conversion)。

显式指定模板参数类型

两种情况下需要用户显式的指定模板参数的类型:

  • 模板参数类型作无法被编译器推断,比如作为返回值的时候
  • 模板参数类型需要适应用户的需求,用于创建用户指定类型的实例
如何显式指定模板实参类型

带模板的函数(类)使用时,函数名后的三角括号中就是显式指定模板参数类型的地方:

// T1 cannot be deduced: it doesn't appear in the function parameter list
template <typename T1, typename T2, typename T3>
T1 sum(T2, T3);

// T1 is explicitly specified; T2 and T3 are inferred from the argument types
auto val3 = sum<long long>(i, lng); // long long sum(int, long)

多个需要指定类型的参数的情况
  • 三角括号中指定的参数类型会从左到右依次匹配模板参数。
  • 如果指定的参数类型数量不够,则右边的模板参数不会被指定类型

需要注意的是,如果调整了函数中模板参数的位置,那么所有的参数类型必须被指定

// poor design: users must explicitly specify all three template parameters
template <typename T1, typename T2, typename T3>
T3 alternative_sum(T2, T1);

// error: can't infer initial template parameters
auto val3 = alternative_sum<long long>(i, lng);
// ok: all three parameters are explicitly specified
auto val2 = alternative_sum<long long, int, long>(i, lng);

显式指定的实参遵循普通的类型转换

当模板参数类型被显式指定时,该参数对应的 argument 遵循普通的类型转换。这一点非常实用,可以允许我们突破参数类型推断下的类型冲突:

long lng;
compare(lng, 1024);       // error: template parameters don't match
compare<long>(lng, 1024); // ok: instantiates compare(long, long)
compare<int>(lng, 1024);  // ok: instantiates compare(int, int)

尾置返回类型与转换模板类型

显式指定模板类型的前提是用户知道应该指定什么类型。但某些情况下,模板类型无法被推断,也无法由用户判断。这种情况下,解决的方式有两种:

  • 以引用的形式返回返回值
  • 使用尾置返回类型(C++ )判断返回值类型
以引用形式返回未知类型

通常情形中,造成无法判断类型的主要原因是使用间接方式访问了对象。这种情况下可以以引用的方式返回对象,我们只需要知道这是一种绑定返回对象的引用即可:

template <typename It>
??? &fcn(It beg, It end)
{
    // process the range
    return *beg;  // return a reference to an element from the range
}

vector<int> vi = {1,2,3,4,5};
Blob<string> ca = { "hi", "bye" };
auto &i = fcn(vi.begin(), vi.end()); // fcn should return int&
auto &s = fcn(ca.begin(), ca.end()); // fcn should return string&

以尾置返回形式判断返回类型

C++11 中提供的尾置声明可以用于这种情形,即通过 decltype 判断函数参数的类型。但由于常规方式中,返回值声明先于函数参数列表可见,因此必须要配合尾置返回形式才能使参数列表先可见:

// a trailing return lets us declare the return type after the parameter list is seen
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg) //decltype return a reference on lvalue
{
    // process the range
    return *beg;  // return a reference to an element from the range
}

Type Transformation

尾置返回的方法还存在一个缺陷。由于 decltype 对左值会返回其引用,因此不适用于我们想直接返回对象类型的情况。为此,标准库中提供了一类模板,允许我们对模板类型进行一系列的修改。该类模板定义于 <type_traits> 头文件中,详情如下:




此处我们使用 remove_reference 就可以将 decltype 返回的引用类型转化为对应的对象类型:

// must use typename to use a type member of a template parameter; see § 16.1.3 (p. 670)
template <typename It>
auto fcn2(It beg, It end) ->
    typename remove_reference<decltype(*beg)>::type
{
    // process the range
    return *beg;  // return a copy of an element from the range
}
这里的 type 是我们得到的对象类型。其本身 remove_reference 模板的成员,通过我们提供的 decltype(*beg) 进行实例化来去除引用。如果我们提供的类型不需要进行转换(比如模板类型本身就是直接类型,而不是指针或者引用),则 type 等同于指定的模板类型。

函数指针与实参推断

使用函数模板对函数指针进行初始化或者赋值时,编译器会利用指针的类型来推断模板的 argument:

template <typename T> int compare(const T&, const T&);
// pf1 points to the instantiation int compare(const int&, const int&)
int (*pf1)(const int&, const int&) = compare;
pf1 的定义中使用了 int 作为实例化的类型,因此,T 对应的就是 int。但需要注意的是,当同时存在多个实例化的类型时,编译器是无法进行自动推断的,因为无法决定使用哪个实例化类型:
// overloaded versions of func; each takes a different function pointer type
void func(int(*)(const string&, const string&));
void func(int(*)(const int&, const int&));
func(compare); // error: which instantiation of compare?
上例中,函数 func 同时进行了不同类型的重载,导致编译器无法判断实例化的类型。这种情况下,必须显式的指定实例化的类型
// ok: explicitly specify which version of compare to instantiate
func(compare<int>);  // passing compare(const int&, const int&)

模板的实例化开始时,每个模板参数用于实例化的类型(或者是值)都必须唯一

模板实参的推断和引用

当模板参数对应的函数参数是引用的时候,有两种情况需要讨论:

  • 函数参数为左值引用
  • 函数参数为右值引用
函数参数为 T& 的推断

当函数参数类型为 T&,也就是普通左值引用的时候:

  • 函数只能接受左值(比如以引用形式返回的值)
  • 推断结果为 argument 本身的类型
  • 如果 argument 带 const,那么推断出来的类型也带 const

template <typename T> void f1(T&);  // argument must be an lvalue
// calls to f1 use the referred-to type of the argument as the template parameter type
f1(i);   //  i is an int; template parameter T is int
f1(ci);  //  ci is a const int; template parameter T is const int
f1(5);   //  error: argument to a & parameter must be an lvalue

函数参数类型为 const T& 的推断

当函数参数类型为 const T&,也就是常量左值引用的时候:

  • 函数可以接收任意类型的 argument
  • 推断结果为 argument 本身的类型(不附带 constness)
  • 由于 const 是函数参数类型的一部分,因此无论 argument 是否为 const, T 的推断类型始终是普通的类型

template <typename T> void f2(const T&); // can take an rvalue
// parameter in f2 is const &; const in the argument is irrelevant
// in each of these three calls, f2's function parameter is inferred as const int&
f2(i);  // i is an int; template parameter T is int
f2(ci); // ci is a const int, but template parameter T is int
f2(5);  // a const & parameter can be bound to an rvalue; T is int

函数参数类型为T&& 的推断

当函数参数类型为 T&&时,也就是右值引用的时候:

函数参数类型为右值引用的模板类型,在接受 argument 时,与普通右值引用参数最大的不同,是其可以接收左值

  • 函数可以接收右值以及左值
  • 接收类型为右值的 argument 时,推断类型是右值本身的类型 T
  • 接受类型为普通左值的 argument 时,推断类型是左值引用 T&
  • 接收类型为常量左值的 argument 时,推断类型是常量左值引用 const T&



T&& 可以接收左值的原因:引用折叠

模板右值引用类型的函数可以接收左值的原因是因为引用折叠Reference Collapsing )。考虑以下过程:

  1. 当类型为 T&& 模板类型参数接收到 lvaule 时,编译器会将其视作左值引用,也就是推断其类型为 T&
  2. 此时,编译器通过间接的方式alias,template parameter)完成了右值引用对左值引用的绑定,而推断出的类型可以记做 T& &&
  3. C++ 11 中规定,如果引用的绑定中出现了间接的引用到引用的绑定,那么这种绑定会导致推断出的类型被折叠为左值引用 T&

上述第三部也就是是我们之前提到的引用折叠。实际上,引用折叠会发生在所有的引用以间接的方式绑定引用的场景中:

  • 左值引用绑定左值引用 T& &
  • 右值引用绑定左值引用 T& &&
  • 左值引用绑定右值引用 T&& &
  • 右值引用绑定右值引用 T&& &&

折叠后的类型推断结果可以总结为:

  • 只要引用相互绑定中有左值引用,最后的推断类型都是左值引用 T&
  • 如果是 T&& &&,则推断结果会折叠为右值引用 T&&

来看一下 f3 的例子:

template <typename T> void f3(T&&);
f3(42); // argument is an rvalue of type int; template parameter T is int

f3(i);  // argument is an lvalue; template parameter T is int&
f3(ci); // argument is an lvalue; template parameter T is const int&
当参数类型是左值 i 时,其实际类型为 int& &&
// invalid code, for illustration purposes only
void f3<int&>(int& &&); // when T is int&, function parameter is int& &&
但最终被折叠为 int&
void f3<int&>(int&); // when T is int&, function parameter collapses to int&

模板类型右值引用参数可以接受左值意味着该类型的参数实际上可以接收任意类型的 argument;也就是说这种情况下,可以被当成右值使用的 argument,也可以被当做左值使用。

实例分析:函数模板与右值引用参数

以下模板函数中:

template <typename T> void f3(T&& val)
{
    T t = val;  // copy or binding a reference?
    t = fcn(t); // does the assignment change only t or val and t?
    if (val == t) { /* ... */ } // always true if T is a reference type
}

  • val 是右值时,T t = val 是拷贝操作,因此改变 t 不会改变 val
  • val 是左值时, T t= val 是绑定操作,因次改变 t 也会改变 val

实践中,右值引用参数主要用于两种情况:

  • 模板转发其 argument
  • 模板重载(与构造函数的重载类似)

理解 std::move

std::move 是模板参数引用推断机制的一个非常好的实践例子,其大致的实现如下:

// for the use of typename in the return type and the cast see § 16.1.3 (p. 670)
// remove_reference is covered in § 16.2.3 (p. 684)
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
   // static_cast covered in § 4.11.3 (p. 163)
   return static_cast<typename remove_reference<T>::type&&>(t);
}
考虑到 std::move 的功能:

  • 该函数应允许任意类型的 argument
  • 该函数应该最终将输入的 argument 类型转化为其对应类型的右值引用

可以看到的是,move 的返回值是一定是一个右值引用。因此,我们只需要考虑如何将 t 的类型转换为右值引用返回即可。

那么,上面的设计的思路就很明显了:

  1. 通过右值引用的函数模板参数,允许 std::move 接收任意类型的 argument
  2. 通过 ’‘remove_reference’‘ 的实例化去除 argument 可能存在的引用
  3. 通过 static_cast 将得到的类型转化为其右值引用并返回
std::move 是如何运作的

考虑下面代码:

string s1("hi!"), s2;
s2 = std::move(string("bye!")); // ok: moving from an rvalue
s2 = std::move(s1);  // ok: but after the assigment s1 has indeterminate value
std::move 接收右值时:

  • T 被推断为 string
  • remove_reference 实例化为 remove_reference<string>,得到的类型是 string
  • 因此 move 的返回值类型为 string&&
  • 返回的是 static_cast<string&&>(t);由于 tT&&,因此不做处理

std::move 接受左值 s1 时:

  • T 被推断为 string&
  • remove_reference 实例化为 remove_reference<string&>,通过实例化去除引用,依然得到类型 string
  • 因此 move 的返回值类型依然是 string&&
  • 最终返回执行 static_cast<string&&>(t)
  • 由于 t 的类型被折叠为 string&,因此执行 static_cast<string&&>(t),结果依然返回 string&&

static_cast 可以转换左值到右值引用又一次体现了 C++ 的设计哲学;提供灵活性,但前提是你要知道你自己在做什么。比如这类情况,转换的前提是要求左值可以被安全的截断;而 static_cast 又是一种显式的强制转换。C++ 通过这种方式将处理权完全移交给了程序员,但同时又通过显式的方式来提醒程序员。

转发 / Forwarding

在实际应用中,某些函数需要将接收的 argument 原封不动的转交给其他函数。具体的说,我们希望保存以下的特性:

  • 左值 / 右值
  • constness
实例:argument 转发过程中丢失信息

下面是一个转发过程中 argument 发生变化的例子:

// template that takes a callable and two parameters
// and calls the given callable with the parameters ''flipped''
// flip1 is an incomplete implementation: top-level const and references are lost
template <typename F, typename T1, typename T2>
void flip1(F f, T1 t1, T2 t2)
{
    f(t2, t1);
}

void f(int v1, int &v2) // note v2 is a reference
{
    cout << v1 << " " << ++v2 << endl;
}

f(42, i);        // f changes its argument i
flip1(f, j, 42); // f called through flip1 leaves j unchanged
上面是一个函数 flip1,内嵌一个打印函数 f,会将接受的参数 t1t2 交换顺序进行打印,并对 t2 对应的参数进行自增。但实际上,该程序无法达到预期的效果,因为 t 对应的 argument j 在转发的过程中发生了变化:




  • 由于 t1 以复制的方式接收的是左值 j,因此 T1被推断为 int
  • 那么 flip1 实际上实例化为:

void flip1(void(*fcn)(int, int&), int t1, int t2);
因此传递给 int &v2 的是独立的拷贝 t2,而不是 j

改良:使用右值引用

上面的例子中,存在(潜在)的问题有:

  • 左值在拷贝的过程中成了右值(j to temporary t1
  • 以拷贝的方式传递参数也可能导致 constness 的变化

根据右值引用以下的特性,将 flip 的函数 parameter 设置为右值引用类型,即可解决大部分问题:

  • 右值引用接受左值,并以引用的形式传递左值
  • 右值引用接受右值,会以右值引用的形式传递右值
  • 传递过程中,constness 的信息不会丢失

比如下面的 flip2 实现:

template <typename F, typename T1, typename T2>
void flip2(F f, T1 &&t1, T2 &&t2)
{
    f(t2, t1);
}
在之前的调用中:



j 通过引用折叠使 T1 的类型被推断为了 int&,从而完成了与 t1 的绑定。因此,这个版本的 f 可以成功修改 j 值。

潜在的问题

上述的方法中存在一个问题:

  • f 是一个普通的函数
  • 具体函数的类型转换中不存在引用折叠

这带来的问题是,如果普通函数中的参数类型是右值引用,而上游传递的参数类型是左值(引用),那么普通函数是无法完成左值到右值的转换的。比如将 f 改为下面 g

void g(int &&i, int& j)
{
    cout << i << " " << j << endl;
}
这种情况下,我们无法将一个左值(引用) t2 传递给 i


使用 std::forward

标准哭中提供了模板 std::foward 用于处理此类情况。std::forward 的 parameter 类型为 T&&,通过指定其参数的模板类型 T,从而返回该模板类型的右值引用,比如 forward<T> 会返回 T&&。该模板定义于 <utility> 头文件中。具体的使用方式:

template <typename Type> intermediary(Type &&arg)
{
    finalFcn(std::forward<Type>(arg));
    // ...
}
其实现原理也使用了引用折叠:

  • 当接受的参数是左值时,发生了两次引用折叠:
    • 第一次引用折叠推断模板类型为 T& &&,最终折叠为 T&
    • 第二次引用折叠发生在返回:返回的类型是 std::forward<T&>,也就是 T& &&,最终折叠为 T&
  • 当接受参数是右值:
    • 模板参数被推断为 T
    • 返回的类型是 std::forward<T>,也就是 T&&




std::forward 主要:

  • 利用模板参数的引用折叠引用性质,使用了模板来避免普通右值引用无法接收左值的问题
  • 同时也通过利用引用折叠,保证了传递进来的 argument 的左右值特性不会发生变化

std::forward 不应使用 using 声明。

函数模板与重载

当函数模板参与到重载函数的匹配中时:

  • 所有成功实例化的模板实例均是 candidate function
  • 属于 candidate function 的实例同时也是 viable function,因为函数模板不可能在函数名/类型/数量不匹配的情况下实例化
  • 关于 viable function 的匹配等级标准也适用于函数模板

当同时存在几个最佳匹配结果的时候,还需要额外考虑:

  1. 候选者中,如果只有一个非模板函数,那么该函数会被选择
  2. 如果没有非模板函数,则选择更加特殊的版本(处理范围更窄的版本)
  3. 否则,调用会具有二义性

实例:模板重载与匹配规则

以书上的内容为例,实现一个 debug_rep() 函数,该函数接收一个对象,并将对象的内容以 string 的形式返回。

只存在唯一 viable 的情况

首先,我们实现两个版本的 debug_rep()

  • 第一个的参数类型是 const T&
  • 第二个的参数类型是 T*

实现如下:

// print any type we don't otherwise handle
template <typename T> string debug_rep(const T &t)
{
    ostringstream ret; // see § 8.3 (p. 321)
    ret << t; // uses T's output operator to print a representation of t
    return ret.str(); // return a copy of the string to which ret is bound
}

// print pointers as their pointer value, followed by the object to which the pointer points
template <typename T> string debug_rep(T *p)
{
    ostringstream ret;
    ret << "pointer: " << p;         // print the pointer's own value
    if (p)
        ret << " " << debug_rep(*p); // print the value to which p points
    else
        ret << " null pointer";      // or indicate that the p is null
    return ret.str(); // return a copy of the string to which ret is bound
}
如果进行以下调用:
string s("hi");
cout << debug_rep(s) << endl;
s 在此处是 string 类型,因此:

  • 第一个 const T& 的版本可以接受 sT 被推断为 string
  • 第二个 T* 的版本无法实例化,因为编译器无法通过一个非指针类型的 argument 去推断一个指针的类型

因此,此处选则 const T& 版本

存在多个 viable 的情况

上述实现中,如果将调用改变为:

cout << debug_rep(&s) << endl;
那么两个版本的函数模板都会成为 viable

  • const T& 版本接受指针,T 被推断为 string*,最后的实例化结果为 debug_rep(const string* &)
  • T* 版本接受指针,T 被推断为 string,最后的实例化结果为 debug_rep(string* &)

由于 const T& argument 会发生 constness 的改变(rank2),因此 T* 版本是最佳匹配。

存在特殊版本的情况

将上面的调用改为:

const string *sp = &s;
cout << debug_rep(sp) << endl;

  • const T& 版本接受 const pointer to string,T 被推断为 const string*。最终实例化的结果为 debug_rep(const string* &)
  • T* 版本接受 ,T 被推断为 const string,最终实例化结果为 debug_rep(const string*)

由于编译器无法分辨指针与指针的引用之间的区别,此时两者均是普通意义上的最佳匹配(exactly match,普通函数到这一步已经会导致二义性了)。但需要注意的是,由于 const T& 可以接受的 argument 类型更加广泛(包括但不限于指针),因此 T* 版本是更加特殊的版本。根据之前的规则,T* 版本是最佳匹配。

存在唯一非模板函数 viable 的情况

我们再为 debug_rep() 添加一个普通函数的重载:

// print strings inside double quotes
string debug_rep(const string &s)
{
    return '"' + s + '"';
}
当以下面命令进行调用时:
string s("hi");
cout << debug_rep(s) << endl;
候选有两个:

  • const T&, T 被推断为 string,实例化结果为 const string &
  • const string&

此处提供的参数类型为 const string 类型,因此两个函数在普通意义上都是最佳匹配。根据之前的规则,当候选者中存在唯一的,非模板函数时,该函数版本为最佳匹配版本。

复合情况

考虑以下调用:

cout << debug_rep("hi world!") << endl; // calls debug_rep(T*)

  • const T& 版本:T 会被推断为数组类型 char[10]
  • T* 版本: T 会被推断为 const char
  • 普通 const string& 版本:const char* → string

由于普通非模板版本中存在低等级的类型转换,因此首先排除该版本。到此,const T&T* 版本都是普通意义上的最佳匹配;根据规则,选择更特殊的版本 T* 实际实现中,我们更倾向于为 const char* 设计两个非模板版本的重载,并使用其调用接受 string 的普通版本:

// convert the character pointers to string and call the string version of debug_rep
string debug_rep(char *p)
{
    return debug_rep(string(p));
}
string debug_rep(const char *p)
{
    return debug_rep(string(p));
}

有关函数前置声明是非常必要的

上面的例子中,我们使用 char* 版本的 debug_rep() 调用了 const T& 版本的 debug_rep() 。如果不对该版本进行前置声明,编译器很可能会自动选择之前的某个其他版本:

template <typename T> string debug_rep(const T &t);
template <typename T> string debug_rep(T *p);
// the following declaration must be in scope
// for the definition of debug_rep(char*) to do the right thing
string debug_rep(const string &);
string debug_rep(char *p)
{
    // if the declaration for the version that takes a const string& is not in scope
    // the return will call debug_rep(const T&) with T instantiated to string
    return debug_rep(string(p));
}
这是因为对于未声明函数的调用,普通函数与成员函数的处理方式不同:

  • 在普通函数中,使用未声明的函数是语法错误行为
  • 在函数模板中,如果有任意重载版本可以使该函数实例化,那么编译器不会报错。

上面的例子中,如果我们不对普通的 const string& 版本进行前置声明,则编译器会自行调用 const T& 版本。

Variadic 模板

C++11 提供了 Variadic template,用于支持处理多个不同类型的参数。Variadic template 使用参数包parameter pack)对参数进行管理。定义 Variadic template 时,参数包需要分为两部分定义:

  • 模板参数包(template parameter pack
  • 函数参数包(function parameter pack

Variadic template 通过省略号参数(ellipsis)来表示参数包。模板参数包定义的是类型,而该类型会被用于定义函数参数包:

// Args is a template parameter pack; rest is a function parameter pack
// Args represents zero or more template type parameters
// rest represents zero or more function parameters
template <typename T, typename... Args>
void foo(const T &t, const Args& ... rest);
在对 Variadic template 进行类型推断的时候,编译器还会根据 argument 的数量来推断参数包中参数的数量:
int i = 0; double d = 3.14; string s = "how now brown cow";
foo(i, s, 42, d);    // three parameters in the pack
foo(s, 42, "hi");    // two parameters in the pack
foo(d, s);           // one parameter in the pack
foo("hi");           // empty pack

/* instantiation */
void foo(const int&, const string&, const int&, const double&);
void foo(const string&, const int&, const char(&)[3]);
void foo(const double&, const string&);
void foo(const char(&)[3]);

计算包中的参数数量

参数包中的参数数量可以通过 sizeof… 运算符进行计算。该运算符不会执行 argument:

template<typename ... Args> void g(Args ... args) {
    cout << sizeof...(Args) << endl;  // number of type parameters
    cout << sizeof...(args) << endl;  // number of function parameters
}

sizeof… 实际上是 sizeof 与省略号运算符的组合。这是 pack expansion 的一个最基础应用;后面将会提到。

实例:打印不同数量类型的参数

C++ 可以通过 initializer_list 接受数量不同的参数;但这些参数需要有相同的类型。使用 Variadic template 可以允许我们编写打印数量不同,并且类型也不同的参数。

在设计上,由于 Variadic template 经常与递归函数搭配。这是因为参数包的处理分为两步:

  • 当前层级处理 pack 中的第一个元素
  • pack 中剩余的参数通过递归的方式送到下一个层级展开

因此,我们的打印函数 print() 也应该以递归的方式来实现。也就是说,print() 需要一个 non-variadic 的版本来结束递归。具体实现如下:

// function to end the recursion and print the last element
// this function must be declared before the variadic version of print is defined
template<typename T>
ostream &print(ostream &os, const T &t)
{
    return os << t; // no separator after the last element in the pack
}
// this version of print will be called for all but the last element in the pack
template <typename T, typename... Args>
ostream &print(ostream &os, const T &t, const Args&... rest)
{
    os << t << ", ";           // print the first argument
    return print(os, rest...); // recursive call; print the other arguments
}

variadic 版本的 print 的设计

上面的实现中,variadic 版本的 print() 需要三个参数:

ostream &print(ostream &os, const T &t, const Args&... rest)
而其内部的递归调用中,print() 只有两个参数:
return print(os, rest...);
之所以可以这么做,当 argument 以参数包的形式进行传递时,可以以参数组合的方式进行接收。也就是说,我们传递到下个层级 ’‘print()’‘ 参数包 rest,在传递的时候被分为了两个部分:

  • 参数包中的第一个参数(在当前的层级中打印出来)
  • 参数包中剩余的参数




通这样的递归调用,参数包中的参数将逐步减少。当参数包中只存在一个参数时,候选这将会有两个。比如下面的调用:
print(cout, i, s, 42);  // two parameters in the pack
该递归最后一次调用将只有一个参数:
print(cout, 42);
此时,我们的 variadic 版本 和 non-variadic 版本的 print 都是 viable(variadic 版本中,当前递归层级的 rest 为空)。由于 non-variadic 版本更加特殊,因此最后会调用该版本,并使递归成功结束。

习题中有一个问题,如果 non-variadic 版本 对 variadic 版本不可见会导致什么后果?
书上给出了 warning,说会导致无限递归:

A declaration for the nonvariadic version of print must be in scope when the variadic version is defined. Otherwise, the variadic function will recurse indefinitely.

但在实践过程中,编译无法通过,会报没有匹配函数的错误。这一点其实不难解释:从之前的例子可以看出来,当参数包为空的时候并不会 “占用” 参数的位置。如果不存在 non-variadic 的版本,那么该递归将继续进行到 print(cout);此时没有任何一个 print() 的版本可以对应这个调用,因此编译器报错。

参数包的扩展

我们可以通过提供指定的 pattern 对参数包进行扩展:

  • 扩展定义方式:partten + …
  • 扩展结果:pattern 将会应用到参数包内每个元素中

template <typename T, typename... Args>
ostream &
print(ostream &os, const T &t, const Args&... rest)// expand Args
{
    os << t << ", ";
    return print(os, rest...);                     // expand rest
}
上例中,我们对 print() 进行了两个部分的拓展:

  • 在函数参数中:模板参数列表提供的类型参数包,被扩展为了带引用的类型参数包(Args→const Args&)
  • 在调用中:rest 本身作为 pattern,扩展的效果是生成以逗号分隔的元素列表:

而通过扩展,print() 可以达到以下效果:

print(cout, i, s, 42);  // two parameters in the pack
//instantiation
ostream& print(ostream&, const int&, const string&, const int&);
//equivalent call
print(os, s, 42);

以函数作为 pattern

函数也可以作为 pattern 应用到参数包中的每一个元素上:

template <typename... Args>
ostream &errorMsg(ostream &os, const Args&... rest)
{
    // print(os, debug_rep(a1), debug_rep(a2), ..., debug_rep(an)
    return print(os, debug_rep(rest)...);
}
上例 print() 调用中添加了 debug_rep(rest) 这样的 pattern,因此包中每个成员都会作为参数执行一遍 debug_rep。也就是说,如果调用 errorMsg
errorMsg(cerr, a,b,c,d);
实际上是调用了:
print(cerr, debug_rep(a), debug_rep(b), debug_rep(c), debug_rep(d));

注意区分两种写法:

  • debug_rep(rest)… 是对每个元素都应用一遍 debug_rep()
  • debug_pep(rest…) 是对元素列表应用一次 debug_rep(),( 等同于 debug_rep(a,b,c,d))

转发与参数包

由于 variadic template 往往是基于已有的工具函数进行适配,参数传递在其实现过程中是很常见的。为了保证参数传递的准确性,variadic template 往往与 std::forward 一起使用。

emplace_back() 的实现

emplace_back() 是基于 variadic template 开发出来的,以 strvec 为例,其实现大致如下:

/* declaration */
class StrVec {
public:
    template <class... Args> void emplace_back(Args&&...);
    // remaining members as in § 13.5 (p. 526)
};
/* defination */
template <class... Args>
inline
void StrVec::emplace_back(Args&&... args)
{
    chk_n_alloc(); // reallocates the StrVec if necessary
    alloc.construct(first_free++, std::forward<Args>(args)...);
}
上例中的两个个要点:

  1. 在函数的声明中对函数参数的类型扩展为了右值引用
  2. consturct 函数中,以 std::forward<Args>(args) 作为 parrten,对调用进行了扩展

其中第二个要点:

std::forward<Args>(args)...
实际上指对于每个实例化的类型,以及其对应的参数,都进行转发:
//Ti represents the type of the ith element in the template parameter pack
//ti represents the ith element in the function parameter pack
std::forward<Ti>(ti)
比如:
svec.emplace_back(10, 'c'); // adds cccccccccc as a new last element
实际上参数包被拓展为:
std::forward<int>(10), std::forward<char>(c);
也就是以当前地址为起始,构建 10 个内容为 c 的 char 变量 。

Variadic Templates + Forwarding 是一种很经典的设计方式。这种类型的函数都有两个特点:

  • 参数包是被扩展的右值引用包
  • 参数包的传递会经过 std::forward 的拓展

其优势在于,可以接受任何数量/类型的参数并将这些参数原封不动转发给工具参数。

模板的特化

单个模板往往不能处理所有的问题。当某些类型需要特殊处理时,我们可以基于当前的模板,为其定义一个额外的模板版本进行处理。定义这个模板的过程被成为模板的特化template specialization)。

特化模板的定义

以下面的例子为例:

template <typename T> int compare(const T&, const T&);
通常情况下,用于比较的两个对象都是通过运算符来实现的。但当我们需要比较两个 literal string 时,用于比较的方法是 strcmp。此时一般版本的 compare 就不适用了,我们需要定义对应的特化版本:
// special version of compare to handle pointers to character arrays
template <>
int compare(const char* const &p1, const char* const &p2)
{
    return strcmp(p1, p2);
}
特化版本以 template<> 起头,后面紧跟具体的返回值以及参数列表。返回值与参数列表必须匹配原有的模板,比如本例中:

  • 特化版本需要处理的是 const char* 类型
  • 标准版本中是 const T& 类型
  • 此处需要将 T 替换为特化版本需要处理的类型
  • 因此最终类型是 const char* const&
函数的重载与特化
  • 特化是对模板的指定实例化,是用户代替编译器实现实例化的过程
  • 特化不是重载,因此不会参与重载函数的匹配

需要特别注意的是,特化遵循普通的作用域规则。有几点要注意:

  • 特化版本的源模板声明要对特化版本的声明可见
  • 特化版本的声明要对其使用可见。否则,编译器将会使用源模板进行实例化,这种错误非常难查。
  • 特化版本不能使用与源模板版本相同的参数。编译器无法查出此类错误

推荐的做法:

  • 将特化版本与其源模板声明在同一个头文件中
  • 所有的源模板的声明需要置于所有的特化版本之前

类模板的特化

类模板也可以针对特定的类型进行特化。

实例:使用类模板特化生成 Sales_data 类型的哈希函数

无序关联容器一章中有过自定义哈希值的计算方法。当时采用了提供哈希函数的方式;另一种方法是对标准库的哈希函数进行特化

特化标准库的哈希函数有几个准备工作:

  • 需要重载调用运算符 (),该运算符接收容器的 key 作为参数,并返回 size_t 类型的返回值
  • alias 两个类型:result_typeargument_type
  • 默认构造函数与拷贝赋值运算符(可使用隐式生成版本)

除此之外,由于 std::hash 处于命名空间 std 中,因此对其的特化需要在同一个命名空间下才能生效:

// open the std namespace so we can specialize std::hash
namespace std {
}  // close the std namespace; note: no semicolon after the close curly
下面是实现方式:
// open the std namespace so we can specialize std::hash
namespace std {
template <>           // we're defining a specialization with
struct hash<Sales_data> // the template parameter of Sales_data
{
    // the type used to hash an unordered container must define these types
    typedef size_t result_type;
    typedef Sales_data argument_type; // by default, this type needs ==
    size_t operator()(const Sales_data& s) const;
    // our class uses synthesized copy control and default constructor
};
size_t
hash<Sales_data>::operator()(const Sales_data& s) const
{
    return hash<string>()(s.bookNo) ^
           hash<unsigned>()(s.units_sold) ^
           hash<double>()(s.revenue);
}
} // close the std namespace; note: no semicolon after the close curly
几个要点:

  • Sales_data 的哈希函数由指定的三个成员的哈希函数进行或运算得到
  • 特化版本对源版本的匹配工作:
    • 需要为源模板的构造函数参数 std::equal_to<Key> 指定兼容的 Sales_data 版本的 == 运算符,方便构造
    • 返回值与源版本需要一致
  • hash<Sales_data> 实例需要作为 Sales_data 的友元(也可单独友元运算符 ()
  • 该特化需要与 Sales_data 定义在同一个 header 中,保证用户可以使用

特化后的类 / 函数不再是模板。本例中,因为特化版本定义在了 Sales_data.h 中, 如果该头文件被多个文件引用,会造成特化版本的多重定义。解决方案通常是将其声明为 inline。另外一种方法是文件外定义,但书中并不建议如此。

类模板的偏特化

类模板的特化允许只为源模板提供一部分 argument;这种方式的特化被成为类模板的偏特化Class-Template Partial Specializations)。偏特化有两个限制:

  • 必要的 argument 必须提供
  • 只有类模板可以进行片特化
实例以及应用

Type transformation 模板中的 remove_reference 是一个很好的例子:

// original, most general template
template <class T> struct remove_reference {
    typedef T type;
};
// partial specializations that will be used for lvalue and rvalue references
template <class T> struct remove_reference<T&>  // lvalue references
    { typedef T type; };
template <class T> struct remove_reference<T&&> // rvalue references
    { typedef T type; };
可以看出来的是:

  • 与特化(全特化)最大的不同是,偏特化依然是模板
  • 偏特化的版本源版本参数类型不一致

在调用中,偏特化的类型会优先调用对应版本:

int i;
// decltype(42) is int, uses the original template
remove_reference<decltype(42)>::type a;
// decltype(i) is int&, uses first (T&) partial specialization
remove_reference<decltype(i)>::type b;
// decltype(std::move(i)) is int&&, uses second (i.e., T&&) partial specialization
remove_reference<decltype(std::move(i))>::type c;

单独特化类成员

C++ 也允许单独对类模板的常规能源进行特化。比如下面的例子,Foo 类模板的 bar() 成员进行了特化:

template <typename T> struct Foo {
    Foo(const T &t = T()): mem(t) { }
    void Bar() { /* ... */ }
    T mem;
    // other members of Foo
};
template<>           // we're specializing a template
void Foo<int>::Bar() // we're specializing the Bar member of Foo<int>
{
     // do whatever specialized processing that applies to int
}
Bar 版本属于 Foo<int> 的成员。当使用 Foo<int> 类型的对象对 Bar 进行调用时,会优先调用该版本:
Foo<string> fs;  // instantiates Foo<string>::Foo()
fs.Bar();        // instantiates Foo<string>::Bar()
Foo<int> fi;     // instantiates Foo<int>::Foo()
fi.Bar();        // uses our specialization of Foo<int>::Bar()