一个简单的字符串模板

字符串模板定义如下:

template<typename C>
class String {
public:
    String();
    explicit String(const C*);
    String(const String&);
    String operator=(const String&);

    C& operator[](int n) { return ptr[n]; }
    String& operator+=(C c);

private:
    static const int short_max = 15;
    int sz;
    C* ptr; // ptr指向sz个C
};

这个模板类 String ​接受一个类型参数 C​,使得我们可以创建不同字符类型的字符串对象。C ​的作用域延伸到模板声明的末尾,并且可以像普通类型名一样使用。

你也可以使用一个等价但更短的前缀 template<class C>​。但即使使用这种形式,C​ 仍然是一个类型名,而不是一个类名。

你会注意到 C++ 缺乏一种完全通用的机制来指明对一个模板参数 C​ 的要求。即,我们无法用 C++ 陈述“对所有...的 C​”,其中“...”表示对 C​ 的一组要求。换句话说,C++ 没有提供一种直接的方法来陈述希
望一个模板参数 C​ 是什么类型。

使用模板时,只需在类名后跟一个用 <> ​包围的类型,即可创建一个具体的类实例。例如:

String<wchar_t> ws; // 宽字符字符串
String<Jchar> js;   // 日文字符字符串

标准库提供了模板类 basic_string​,它与我们定义的模板化 String ​类似。在标准库中,string ​是 basic_string<char> ​的一个别名:

using string = std::basic_string<char>;

定义模板

模板提供了一种减少代码冗余和增加代码复用性的方法。类模板生成的类与普通类没有本质区别,且使用模板不会引入额外的运行时机制。实际上,模板可以减少生成的代码量,因为只有当成员函数被使用时才会为其生成代码。

定义类模板时,一种常见的方法是先编写并调试一个特定类,然后将其转换为模板。这种方法允许我们针对具体实例处理设计问题和代码错误,然后再处理泛化引起的问题。

类模板成员的声明和定义与非模板类成员完全一样。模板成员可以定义在模板类内部,也可以在外部定义,类似于非模板类成员。模板类成员本身也是模板,通过所属模板类的参数进行参数化。

template<typename C>
class String {
public:
    String();
    ~String();
    C& operator[](int n);
private:
    int sz;
    C* ptr;
};

template<typename C>
String<C>::String() : sz(0), ptr(new C[1]) {
    ptr[0] = '\0';
}

template<typename C>
String<C>::~String() {
	delete[] ptr;
}

template<typename C>
C& String<C>::operator[](int n) {
	return ptr[n];
}

在一个程序中,一个类模板成员函数只能有一个函数模板定义。特例化允许我们为给定的特定模板实参提供不同的模板实现。对于类模板成员函数,我们也可以用重载机制为不同实参类型提供不同的函数定义。

不能重载一个类模板名,因此,如果在一个作用域中声明了一个类模板,就不能再声明任何其他同名实体。

如果一个类型被用作模板实参,那么它必须提供模板所要求的接口。例如,如果一个类型被用作 String ​的实参,它必须提供普通的拷贝操作。

模板实例化

模板实例化是指将模板和模板参数列表生成一个具体的类或函数的过程。

例子代码:

String<char> cs;
void f() {
    String<Jchar> js;
    cs = "It's the implementation's job to figure out what code needs to be generated";
}

在这个例子中,编译器自动生成了 String<char> ​和 String<Jchar> ​类的构造函数和赋值操作符,而未被使用的其他成员函数不会生成,从而减少不必要的代码生成。

模板实例化可以节省重复定义相似代码的工作量,提高代码复用性,但可能导致定义大量几乎相同的函数,占据大量内存。

另一方面,组合使用模板和简单内联来编写程序能消除很多直接或间接的函数调用。例如,关键数据结构上的简单操作(如 sort()​ 中的 <​ 操作和矩阵运算中标量的 +​ 操作)在高度参数化的库中会被约简为单个机器指令。

类型检查

一个示例是定义函数模板来约束参数类型,但 C++ 无法直接表达这种约束要求。假设我们要编写以下代码来限制模板参数:

template<Container Cont, typename Elem>
requires Equal_comparable<Cont::value_type, Elem>
int find_index(const Cont& c, Elem e);

此代码意图规定 Cont ​必须是容器类型,且 Elem ​能够与 Cont ​的元素类型进行比较。但 C++ 并没有内置的语法来直接表达这种概念。

类型等价

如果对一个模板使用相同的模板实参,我们希望得到相同的生成类型。

String<char> s1;
String<unsigned char> s2;
String<int> s3;

using Uchar = unsigned char;
using uchar = unsigned char;

String<Uchar> s4;   // 等价于 String<unsigned char>
String<uchar> s5;   // 等价于 String<unsigned char>
String<char> s6;    // 不等价于 String<unsigned char>

在这个示例中,String<Uchar> ​和 String<uchar> ​被视为等价,因为 Uchar ​和 uchar ​都是 unsigned char ​的别名。但 String<char> ​与 String<unsigned char> ​不是同一类型,因为 char ​和 unsigned char ​是不同类型。

template<typename T, int N>
class Buffer {};

Buffer<char, 10> b1;
Buffer<char, 10> b2;
Buffer<char, 20 - 10> b3;

在此例中,Buffer<char, 10> ​和 Buffer<char, 20 - 10> ​被视为相同类型,因为 20 - 10 ​被编译器简化为 10​,编译器会将其视为相同的常量值。

假设 Circle ​是 Shape ​的子类,但 vector<Circle*> ​并不能转换为 vector<Shape*>​,即使两者在类型上具有继承关系。

Shape* p = new Circle();
vector<Shape*> q = new vector<Circle*>();  // 错误:无法转换
vector<Shape*> r = { new Circle() };       // 错误:无法转换
vector<Shape*> s = vector<Circle*>();      // 错误:无法转换

错误检查

编译器在模板定义和实例化过程中会进行不同层次的错误检查。

模板定义时可以捕获语法和一般类型错误。

template <typename T>
struct Link {
    Link* pre;
    Link* suc // 语法错误: 缺少分号
    T val;
};

template <typename T>
class List {
    Link<T>* head;

public:
    List() : head{ 7 } {}                    // 错误:用整数初始化指针
    List(const T& t) : head(new Link<T>{ 0, o, t }) {} // 错误:未定义标识符“o”

    void print_all() const;
};

template <typename T>
void List<T>::print_all() const {
    for (Link<T>* p = head; p; p = p->suc) {
        std::cout << *p;  // 依赖于 T 类型必须支持输出运算符 <<
    }
}

而依赖于模板参数的错误则会在模板第一次实例化时检查。

class Rec {
    std::string name;
    std::string address;
};

void f(const List<int>& li, const List<Rec>& lr) {
    li.print_all();   // 正确,因为 int 类型支持输出
    lr.print_all();   // 错误,因为 Rec 类型没有定义输出运算符 <<
}

在这个示例中:

  • li.print_all()​ 是合法的,因为 int​ 类型支持 <<​ 运算符。
  • lr.print_all()​ 会产生编译错误,因为 Rec​ 类型没有定义 <<​ 运算符,因此不能直接输出。

C++ 实现实际上可以将所有类型检查都推迟到程序链接时,而确实有一些错误的最早可能发现时刻就是链接时。不管类型检查是什么时候进行的,所应用的检查规则都相同。

类模板成员

与普通类类似,模板类可以包含多种类型的成员,包括:

  • 数据成员:变量和常量
  • 成员函数
  • 成员类型别名:通过 using ​或 typedef ​定义
  • static 成员:包括 static ​函数和数据
  • 成员类型:如类型成员类
  • 成员模板:如成员类模板

此外,模板类还可以定义 friend ​成员,类似于普通类的 friend ​机制。

数据成员

模板类可以包含任意类型的数据成员。非 static ​数据成员可以在类定义或构造函数中初始化。例如:

template<typename T>
struct X {
    int m1 = 7;
    T m2;
    X(const T& x) : m2(x) {}
};

此外,非 static ​成员变量可以是 const​,但不能是 constexpr​。

成员函数

与普通类相似,模板类的非 static ​成员函数可以在类内部或外部定义。例如:

template<typename T>
struct X {
    void mf1() { /* 内部定义 */ }
    void mf2();
};

template<typename T>
void X<T>::mf2() { /* 外部定义 */ }

模板类的成员函数可以是 virtual​,但一个虚成员函数名不能在用作一个成员函数模板名。

成员类型别名

模板类可以使用 using ​或 typedef ​引入成员类型别名。例如:

template<typename T>
class Vector {
public:
    using value_type = T;
    using iterator = Vector_iter<T>;
};

static​ 成员

在类模板中,static ​数据成员或函数成员在整个程序中只能有一个定义。例如:

template<typename T>
struct X {
    static constexpr Point p{ 100, 250 }; // Point 必须是一个字面值常量类型
    static const int m1 = 7;
    static int m2 = 8; // 错误:不是const
    static int m3;
    static void f1() { /*...*/ }
	static void f2();
};

template<typename T> int X<T>::m1 = 88; // 错误,有两个初始化器
template<typename T> int X<T>::m3 = 9;
template<typename T> void X<T>::f2() { /*...*/ }

与非模板类一样,const​ 或 constexpr​ 的 static​ 字面值可以在类内初始化,不必在类外定义。

模板中的 static ​成员只有在需要时才会生成相应的定义。

template<typename T>
struct X {
    static int a;
    static int b;
};

int* p = &X<int>::a;

如果这些就是程序中所有用到 X<int> ​的地方,编译器会报告 X<int>::a​“未定义”,而对 X<int>::b ​就不会(报“未定义”错误)。

成员类型

与“普通类”一样,我们可以将类型定义为类模板的成员。照例,成员类型可以是一个类或是一个枚举。

template<typename T>
struct X {
    enum E1 { a, b };
    enum E2; // 错误:基础类型未知
    enum class E3;
    enum E4 : char;

    struct C1 { /* ... */ };
    struct C2;
};

template<typename T>
enum class X<T>::E3 { a, b }; // 必需的

template<typename T>
enum class X<T>::E4 : char { x, y }; // 必需的

template<typename T>
struct X<T>::C2 { /* ... */ }; // 必需的

成员枚举可以在类外定义,但在类内声明中必须给出其基础类型。
class ​的 enum ​的枚举量照例是在枚举类型的作用域中,也就是说,对于一个成员枚举类型来说,枚举量在所在类的作用域中。

成员模板

一个类或一个类模板可以有模板成员:

template<typename Scalar>
class complex {
    Scalar re, im;

public:
    complex() : re(0), im(0) {} // 默认构造函数
    template<typename T>
    complex(T rr, T ii = 0) : re{ rr }, im{ ii } {}

    complex(const complex&) = default; // 拷贝构造函数
    template<typename T>
    complex(const complex<T>& c) : re{ c.real() }, im{ c.imag()) } {}
};

这种定义允许在数学上合理的复数类型转换,并阻止不合理的隐式转换。例如:

complex<float> cf;
complex<double> cd {cf}; // 正确:使用float向double的转换
complex<double> cf2 {2.0, 3.0}; // 错误:没有隐式的double->float转换

要注意的是,从 complex<double>​ 向 complex<float>​ 窄化转换的错误直至 complex<float>​ 的模板构造函数实例化时才会被捕获,而且造成转换错误的唯一原因是我在构造函数的成员初始化列表中使用了 0​ 初始化语法(见 6.3.5 节),而这种语法不允许窄化转换。

使用(旧的)()​ 语法会使我们受到窄化错误的困扰。例如:

template<typename Scalar>
class complex {
    Scalar re, im;

public:
    complex() : re(0), im(0) {} // 默认构造函数
    template<typename T>
    complex(T rr, T ii = 0) : re(rr), im(ii) {}

    complex(const complex&) = default; // 拷贝构造函数
    template<typename T>
    complex(const complex<T>& c) : re(c.real()), im(c.imag()) {}
};

complex<double> cf2 {2.0, 3.0}; // 发生窄化转换

模板和 virtual

成员模板不能是 virtual​。例如:

class Shape {
    // 错误:虚模板
    template<typename T>
    virtual bool intersect(const T&) const = 0;
};

虚函数表不支持这种写法,因为它会在继承链中添加复杂性,且无法直接通过传统的虚函数机制进行实现。因此,C++ 禁止成员模板为虚函数,以保持实现的简洁性和一致性。

使用嵌套

在模板中尽量避免嵌入类型,除非它们真正依赖于所有模板参数!

尽量保持信息的局部性通常是一个好主意。这样,就更容易找到一个名字,并且更不容易与程序中的其他东西相互干扰。这种思路就引出了成员类型。

但是,对于类模板的成员,我们必须考虑参数化对成员类型是否恰当。更形式化地说,一个模板成员依赖于所有模板实参,当成员的行为实际上并未使用所有模板实参时,这种依赖就会不幸产生副作用。

template<typename T, typename Allocator>
class List {
private:
    struct Link {
        T val;
        Link* succ;
        Link* prev;
    };
    // 其他代码
};

如果定义了 List<double, My_allocator>::Link ​和 List<double, Your_allocator>::Link​,编译器会认为它们是不同类型,从而导致代码膨胀。为了避免这种情况,可以考虑将 Link ​的定义与 Allocator ​模板参数分离,使得不同的 Allocator ​可以共享同一个 Link ​类型。

template<typename T, typename Allocator>
class List;

template<typename T>
class Link {
    template<typename U, typename A>
    friend class List;
    T val;
    Link* succ;
    Link* prev;
};

再以 Iterator ​为例:

template<typename T, typename A>
class List {
public:
    class Iterator {
        Link<T>* current_position;
    public:
        // ... 常用的迭代器操作 ...
    };

    Iterator<T, A> begin();
    Iterator<T, A> end();
    // ...
};

Iterator ​类型实际上不需要 Allocator​,因此在以下代码中:

void fct(List<int, My_allocator>::iterator b, List<int, My_allocator>::iterator e) {
    auto p = find(b, e, 17);
    // 其他代码
}

如果不将 fct ​定义为模板函数,当使用不同的 Allocator ​时会导致不兼容。

解决此问题的方案是将 Iterator ​提取出模板类,这样不同的 Allocator ​就可以互相兼容:

template<typename T>
struct Iterator {
    Link<T>* current_position;
};

template<typename T, typename Allocator>
class List {
public:
    Iterator<T> begin();
    Iterator<T> end();
    // 其他代码
};

这样定义后,List ​的选择器类型与不同的 Allocator ​实例可以互相替代,从而避免了类型依赖问题。

友元

在模板类中,可以将函数或其他类声明为 friend​。这允许模板类的某些成员对友元开放访问权限。以下是一个例子,其中 Matrix ​和 Vector ​类都是模板:

template<typename T> class Matrix;

template<typename T>
class Vector {
    T v[4];
public:
    friend Vector operator*<>(const Matrix<T>&, const Vector&);
    // ...
};

在此设计中,Vector ​类定义了一个友元运算符 operator*​,用于实现 Matrix ​与 Vector ​的乘法操作。通过将该运算符声明为友元,operator* ​可以直接访问 Matrix ​和 Vector ​类的私有成员。

在声明友元函数时,<> ​是必须的,因为它明确地表明友元函数是一个模板函数。如果省略 <>​,则编译器会将其视为非模板函数。

模板类也可以指定其他类为友元,例如:

template<typename T>
class my_other_class {
    friend T;                 // 将模板参数T作为友元
    friend My_class<T>;       // 将带有模板参数的类作为友元
    friend class T;           // 错误:重复的‘class’关键字
};

友元关系不能传递,也不能继承。

友元函数的实例化基于其使用的具体类型,当有对应类型的调用时才会实例化友元函数模板。

函数模板

函数模板实参

函数模板通过类型推断和显式指定模板参数提供了灵活的方式来处理不同类型的数据。推断机制在大多数情况下可以自动确定模板参数,而在需要时也可以手动指定。

template<typename T>
T* create(); // 创建一个T类型的指针

void f() {
    int* p = create<int>(); // 显式指定模板参数为int
}

函数模板重载

在 C++ 中,可以声明多个同名的函数模板,甚至可以让普通函数和函数模板同名。当调用重载函数时,编译器利用重载解析机制找到匹配的重载函数或函数模板实例。例如:

template<typename T>
T sqrt(T);

template<typename T>
complex<T> sqrt(complex<T>);

double sqrt(double);

void f(complex<double> z) {
    sqrt(2);       // 调用 sqrt<int>(int)
    sqrt(2.0);     // 调用 sqrt(double)
    sqrt(z);       // 调用 sqrt<complex<double>>(complex<double>)
}

在上述代码中,sqrt ​函数被重载为不同的模板和普通函数,以支持不同类型的参数。

接下来,我们对这些特例化版本和所有普通函数应用普通函数重载解析规则:

  1. 找到参与重载解析的所有函数模板特例化版本。具体方法是检查每个函数模板,确定如果作用域中没有其他同名函数模板或函数的话,哪些模板实参会使用(如果有的话)。对于调用 sqrt(z)​,sqrt<double>(complex<double>)​ 和 sqrt<complex<double>>(complex<double>)​ 将成为候选。
  2. 如果两个函数模板都可以调用,且其中一个比另一个更特殊化,则接下来的步骤只考虑最特殊化的版本。对于调用 sqrt(z)​,sqrt<double>(complex<double>)​ 比 sqrt<complex<double>>(complex<double>)​ 更特殊化,因为任何匹配 sqrt<t>(complex<t>)​ 的调用也都能匹配 sqrt<t>(T)​。
  3. 对前两个步骤后还留在候选集中的函数模板和所有候选普通函数一起进行重载解析,方法与普通函数重载解析相同。如果一个函数模板实参是通过模板实参推断确定的,则不能再对它进行提升、标准类型转换或用户自定义类型转换。对 sqrt(2)​,sqrt<int>(int)​ 是精确匹配,因此它优于 sqrt(double)​。
  4. 如果一个普通函数和一个特例化版本匹配得一样好,那么优先选择普通函数。因此,对 sqrt(2.0)​,sqrt(double)​ 优于 sqrt<double>(double)​。
  5. 如果没发现任何匹配,则调用是错误的。如果我们最终得到多个一样好的匹配,则调用有二义性,这也是一个错误。

总结就是:相同匹配情况下普通函数优先,如果函数模板实参是类型推断确定的,则不能对它进行提升、标准类型转换或用户自定义类型转换。

例如:

template<typename T>
T max(T, T);

const int s = 7;

void k() {
    max(1, 2);           // 匹配 max<int>(1, 2)
    max('a', 'b');       // 匹配 max<char>(‘a’, ‘b’)
    max(2.7, 4.9);       // 匹配 max<double>(2.7, 4.9)
    max(s, 7);           // 匹配 max<int>(s, 7)
    max('a', 1);         // 错误:no instance of function template "max" matches the argument list argument types are : (char, int)
    max(2.7, 4);         // 错误:no instance of function template "max" matches the argument list argument types are : (double, int)
}

二义性消解

在 C++ 中,可以通过显式特化或添加重载声明来解决函数模板的二义性问题。

通过显式特化消解二义性

可以通过显式指定模板参数来消除调用时的二义性。例如:

void f() {
    max<int>('a', 1);      // 调用 max<int>('a', 1)
    max<double>(2.7, 4);   // 调用 max<double>(2.7, 4)
}

在这个例子中,通过显式指定 <int> ​和 <double> ​模板参数,确保了调用的是期望的 max ​模板特化版本,从而避免二义性。

通过添加重载声明消解二义性

另一种方法是为常见的参数组合定义明确的重载版本。例如:

inline int max(int i, int j) { return max<int>(i, j); }
inline double max(int i, double d) { return max<double>(i, d); }
inline double max(double d, int i) { return max<double>(d, i); }
inline double max(double d1, double d2) { return max<double>(d1, d2); }

在此代码中,通过定义特定类型的重载函数来避免模板实例化时的二义性,使编译器能够选择正确的重载版本。例如:

void g() {
    max('a', 1);       // 调用 max(int('a'), 1)
    max(2.7, 4);       // 调用 max(2.7, 4)
}

在这种设计中,inline ​关键字的使用可以确保没有额外开销,并使这些重载版本的调用效率更高。

实参带入失败

在使用函数模板时,编译器会检查传入实参是否与模板的完整声明相匹配,包括返回类型的要求。如果匹配失败,编译器会尝试寻找其他重载,而不是直接报错。这种机制被称为 SFINAE(Substitution Failure Is Not An Error),即“实参代入失败不是错误”。

#include <vector>
using namespace std;

template<typename Iter>
typename Iter::value_type mean(Iter first, Iter last);

void f(vector<int>& v, int* p, int n) {
    auto x = mean(v.begin(), v.end()); // 正确
    auto y = mean(p, p + n);           // 错误
}

在第一个调用中,mean(v.begin(), v.end()) ​能够成功匹配,因为 vector<int>::iterator ​具有名为 value_type ​的成员类型,因此模板匹配成功。

在第二个调用中,mean(p, p + n) ​匹配失败,因为 int* ​没有名为 value_type ​的成员,因此不能实例化 mean ​的第一个模板版本。

SFINAE 机制会忽略无法实例化的模板。例如:

#include <vector>
using namespace std;

template<typename Iter>
typename Iter::value_type mean(Iter first, Iter last);

template<typename T>
T mean(T* first, T* last); // 2号模板

void f(vector<int>& v, int* p, int n) {
    auto x = mean(v.begin(), v.end()); // 正确
    auto y = mean(p, p + n);           // 错误
}

在调用 mean(p, p + n) ​时,虽然实参可以匹配 1号模板​,但因 int* ​没有 value_type​,编译器会跳过该模板,转而尝试匹配 2号模板​,最终成功调用 2号模板​。

重载和派生

在函数模板的重载解析中,C++ 会确保模板能够与继承机制结合使用。

template<typename T>
class B { /* 基类定义 */ };

template<typename T>
class D : public B<T> { /* 派生类定义 */ };

template<typename T>
void f(B<T>*);

void g(B<int>* pb, D<int>* pd) {
    f(pb); // 调用 f<int>(pb)
    f(pd); // 调用 f<int>(static_cast<B<int>*>(pd))
}

重载和非推断的参数

在 C++ 函数模板中,对于未用于模板参数推断的函数实参,其处理方式与非模板函数实参完全相同。即,类型转换规则仍然适用。

考虑以下模板函数 get_nth​,用于获取容器中的第 n ​个元素:

template<typename T, typename C>
T get_nth(C& p, int n); // 获取第n个元素

其中,第一个参数 p ​的类型为 C​,该类型在调用 get_nth ​时从实参推断得出,因此不进行类型转换。第二个参数 n ​是一个普通整型参数,因此可以接受类型转换。

struct Index {
    operator int(); // 将Index类型转换为int
    // 其他代码...
};

void f(vector<int>& v, short s, Index i) {
    int i1 = get_nth<int>(v, 2);     // 严格匹配
    int i2 = get_nth<int>(v, s);     // short 到 int 的标准类型转换
    int i3 = get_nth<int>(v, i);     // 用户自定义类型转换:Index 到 int
}

在这个例子中,get_nth ​的第一个参数 v​(类型为 vector<int>&​)用于推断模板参数 C​,所以不进行类型转换。而第二个参数 n ​可以接受类型转换:

  • i1 ​调用 get_nth<int>(v, 2);​,严格匹配,直接传递整数 2​。
  • i2 ​调用 get_nth<int>(v, s);​,将 short ​类型的 s ​转换为 int​。
  • i3 ​调用 get_nth<int>(v, i);​,通过用户定义的类型转换将 Index ​类型的 i ​转换为 int​。

这种处理方式有时称为显式特例化(explicit specialization),适用于非推断参数的情况。

模板别名

在 C++ 中,可以使用 using ​或 typedef ​语法为一个类型定义别名。using ​语法更为常用,因为它支持模板别名,可以固定模板的某些参数,从而简化使用。例如:

template<typename T, typename Allocator = allocator<T>>
using Cvec = vector<T, Allocator>;

通过上述定义,Cvec ​是一个模板别名,它表示使用 Allocator ​分配器的 vector ​类型。以下是其应用示例:

Cvec<char> vc = {'a', 'b', 'c'}; // vc 的类型为 vector<char, allocator<char>>

在这个例子中,Cvec<char> ​等价于 vector<char, allocator<char>>​,简化了模板的书写。

模板别名绑定部分参数

一般来说,如果为模板指定了所有参数,就可以得到一个具体类型;但如果只绑定一部分参数,则得到的是一个新的模板。例如:

template<typename T>
using Vec = vector<T, My_alloc<T>>;

Vec<int> fib = {0, 1, 1, 2, 3, 5, 8, 13}; // fib 的类型为 vector<int, My_alloc<int>>

Vec<int> ​使用了指定的分配器 My_alloc​,这种方式简化了代码的书写,使得模板在不同的分配器需求下更易于维护。

模板别名的等价性和特化

在使用别名时,别名与原始模板类型是等价的,这意味着可以用别名来访问模板的特化。例如:

template<int N>
struct int_exact_traits {
    using type = int;
};

template<>
struct int_exact_traits<8> {
    using type = char;
};

template<>
struct int_exact_traits<16> {
    using type = short;
};

template<int N>
using int_exact = typename int_exact_traits<N>::type;

int_exact<8> a = 7; // int_exact<8> 是一个 8 位整数

在这个例子中,int_exact ​是 int_exact_traits<N>::type ​的别名,通过特化 int_exact_traits​,可以根据 N ​的值选择不同的类型。这种方式提供了一种简化代码的手段,使得代码在处理特化时更加清晰和简洁。

源码组织

在 C++ 中,有三种主要的方式来组织模板代码:

  1. 在一个编译单元中定义模板

    • 在使用模板之前,编译单元包含其完整的定义。这种方式较为直接,适用于简单的项目,但在大型项目中不便于管理。
  2. 在一个编译单元中只包含模板声明

    • 在使用模板的编译单元包含模板声明,而模板定义可能位于稍后的位置。这种方式允许在头文件中声明模板,并在源文件中提供实现。
  3. 模板声明和定义分离的方案

    • 仅在使用模板之前包含声明,模板定义在其他编译单元中。这种方式虽然简化了项目管理,但 C++ 并不支持模板定义和使用的分离编译。

由于历史原因,C++ 不支持第三种方法。因此,通常的做法是在需要用到模板的编译单元中都包含模板定义(使用 #include ​指令),将模板代码放入头文件中。

在一个编译单元中定义模板

可以将模板定义写入头文件,如 out.h​:

// 文件 out.h
#include <iostream>

template<typename T>
void out(const T& t) {
    std::cerr << t;
}

在需要使用 out ​的地方,可以通过 #include "out.h" ​来包含头文件:

// 文件 user1.cpp
#include "out.h" // 使用 out()

这种方式使得每个使用 out ​模板的文件都包含模板定义,减少编译器优化和消除多余目标代码的任务。

在一个编译单元中只包含模板声明

可以将声明放在头文件中(如 out.h​),而将定义放在源文件中(如 out.cpp​):

// 文件 out.h
template<typename T>
void out(const T& t);
// 文件 out.cpp
#include <iostream>

template<typename T>
void out(const T& t) {
    std::cerr << t;
}

用户代码中则可以分别包含声明文件和定义文件:

// 文件 user3.cpp
#include "out.h" // 声明 out()
#include "out.cpp"   // 定义 out()

链接

模板链接规则指的是在使用模板生成的类和函数时,链接器如何处理模板的定义和声明。模板代码的一个重要特性是,如果模板的定义发生变化,所有使用该模板的代码通常需要重新编译。这在大型项目中可能导致大量的重新编译工作,因为模板的代码通常位于头文件中。

通过封装减少模板依赖

如果我们知道程序中通常会处理特定类型的数据(例如 double ​类型的数据),可以通过封装模板的方式来简化代码依赖。例如:

double accum(const vector<double>& v) {
    return accumulate(v.begin(), v.end(), 0.0);
}

在上述代码中,定义了一个非模板函数 accum​,用于处理 vector<double> ​的数据求和。这个函数内部使用了 std::accumulate ​模板函数,但它只在 accum ​函数中调用,从而隐藏了对模板的依赖。

通过这种封装方式,其他代码不需要直接包含 <numeric> ​头文件,因为对 accumulate ​的依赖已经被隐藏在 accum ​函数内部,这样就减少了其他文件的编译依赖。