(一)模板那些事儿:是什么?

(一)模板那些事儿:是什么?

  1. 模板基础:介绍模板的基本概念和语法,包括函数模板和类模板的定义和使用。
  2. 模板参数:详细讲解模板参数的类型,包括类型模板参数、非类型模板参数和模板模板参数。
  3. 模板实例化:解释模板实例化的概念和时机,包括显式实例化和隐式实例化。
  4. 模板特化:介绍模板特化的概念和应用场景,包括部分特化和全特化。

1. 模板基础

1.1 什么是模板

从应用层次来看,C++中的模板堪称一种极为强大的泛型编程工具,它使得编写与类型无关的代码成为可能。借助模板,只需完成一次代码编写,就能在各种不同场景中重复运用,无需针对每种类型都另行编写代码。

从概念层面来讲,对于编译器而言,模板可看作是一种基于类型的编程模式,也可称之为面向类型编程。模板的值对象涵盖类型与常量。模板特化类似于针对类型进行条件判断,它允许针对特定类型提供专门的模板实现。例如,当模板在处理多种不同类型时,如果对某些特定类型存在特殊需求,便可借助模板特化来予以满足。而模板递归则是通过持续不断地实例化模板来逐步展开。这表明模板具备类似于变量、条件判断(if - else)以及循环(loop)的能力,只不过这些能力的实现方式与常规 C++开发存在差异。正因如此,不少人觉得 C++的模板近乎一门独立的语言,基于此的编程方式便被称作模板元编程。

  • 狭义泛型编程层面:如前文所述,由于编写的是与类型无关的代码,一旦声明具体类型并完成模板实例化,代码即可实现复用。例如,编写一个简单的交换函数模板:
template <typename T>
void swap(T& a, T& b) {
    T temp = a;
    a = b;
    b = temp;
}

然而,这种实现并非尽善尽美,它无法处理指针类型的交换。为解决此问题,可以利用模板特化来实现针对指针类型的交换函数。在这个例子中,仅需对指针类型这一个例子进行特化。但倘若需要对具有某种性质的一类类型进行特化,情况又当如何呢?

例如,对于一个容器类型的clear()函数,POD 类型(Plain Old Data,即具有平凡构造函数、平凡析构函数、平凡拷贝构造函数且为标准布局的数据类型,像 C++中的基本数据类型intchar等以及满足特定条件的自定义结构体和类都属于 POD 类型)和自定义类型的实现要求便有所不同。POD 类型通常不涉及复杂的资源管理,其析构函数的概念相对薄弱,在clear()函数中直接使用memset(0)来清理内存即可;而自定义类型可能涉及资源管理,调用clear()时需要逐个调用元素的析构函数。此时,模板的条件判断能力就显得至关重要。那该如何通过模板特化来实现呢?

实际上,一般模板特化的实现更近似于switch - case的形式,只能有一个特化的实现,而无法达成类似于if - else的多重特化。为解决这一难题,C++11 引入了std::enable_if,它允许在模板参数中运用条件表达式,进而实现更为灵活的模板特化。通过这种方式,能够依据类型的特性来选择不同的实现。

  • 模板内容划分 关于模板的内容我会从两方面展开讲,简单划分为入门内容(语法层面),进阶内容(模板元编程层面)展开。具体如下:
  1. 入门使用-简单泛型编程(Generic Programming)

    • 函数模板:函数模板允许编写与类型无关的函数,通过类型参数化实现函数的泛型。例如,std::sort 可以对任何类型的容器进行排序。
    • 类模板:类模板允许编写与类型无关的类,通过类型参数化实现类的泛型。例如,std::map<Key, Value> 可以存储任何类型的键值对。
    • 变量模板:主要是用于模板元编程,类型特征那套,也可以用于表示pi在不同类型下的大小吧,比如说精度要求不高,可以floatdouble各定义一个pi吧。
    • 模板参数:模板允许编写与类型无关的代码,通过类型参数化实现代码复用。例如,std::vector<T> 可以存储任何类型的元素。
    • 模板特化:模板特化允许为特定类型提供特殊的实现,从而优化特定类型的操作。例如,std::hash 可以为不同类型提供不同的哈希函数。
  2. 进阶使用-模板元编程(Template Metaprogramming)

    • 类型特征(Type Traits):通过模板元编程,可以实现类型特征,用于在编译期检查和操作类型。进一步地,可以认为是一种函数。其参数为类型或者字面量。例如,std::is_integral 可以用于检查一个类型是否是整型。
    • 递归模板:模板元编程通常使用递归模板来实现复杂的编译期计算。例如,计算编译期常量、生成类型列表等。
    • 条件编译:通过模板元编程,可以实现条件编译,根据不同的模板参数生成不同的代码。而 SAFINAE 是模板元编程,利用 type traits 实现条件编译的一种常见手段。
  3. 模板实践

    • 不定长参数
    • 类型擦除
    • 表达式模板

1.2 模板的基本语法

模板的基本语法包括模板声明、模板定义和模板实例化。模板声明和定义使用 template 关键字,后跟模板参数列表。模板参数列表可以包含类型参数和非类型参数。

{
    // 函数模板
    template <typename T>
    T functionName(T parameter) {}
}

{
    // 类模板
    template <typename T>
    class ClassName {
      public:
        T memberFunction(T parameter) {}

      private:
        T memberVariable;
    };
}

{

    // C++14(变量模板)
    template <typename T>
    inline constexpr bool is_integral_v = std::is_integral<T>::value;
    static_assert(is_integral_v<int>);

    template <int N>
    constexpr int fib = fib<N - 1> + fib<N - 2>;
    template <>
    constexpr int fib<0> = 0;
    template <>
    constexpr int fib<1> = 1;
    static_assert(fib<10> == 55);
}
1.2.1 注入类名

在 C++中,类模板的注入类名(Injected class name)是指在类模板内部,类模板名可以被用作一个类型名,并且这个名字会隐式地被替换为当前实例化的模板类型。下面详细介绍其含义和使用场景。

#include <iostream>

template <typename T>
class MyClass {
public:
    // ctor里面的MyClass能直接使用,不需要再考虑模板实例化,这个就是注入类名
    MyClass(const MyClass& other) {
        std::cout << "Copy constructor called." << std::endl;
    }

    // 模板又实例化一次是多余的。
    MyClass<T> clone() {
        return *this;
    }

    static MyClass createInstance() {
        return MyClass();
    }
};

1.2.2 依赖名称
  • 依赖名称和 typename

依赖名称是模板定义中,其含义依赖于模板参数的名称。这些名称的类型或值,需在模板实例化时才能确定。

#include <iostream>

template <typename T>
class Container {
public:
    class Iterator {
    public:
        void print() {
            std::cout << "Iterator print" << std::endl;
        }
    };

    Iterator getIterator() {
        return Iterator();
    }
};

// Container<T>是依赖名称,因为其具体类型依赖于模板参数T
template <typename T>
void process(Container<T>& container) {
    // Container<T>::Iterator同样是依赖名称,其具体类型也依赖于模板参数T
    typename Container<T>::Iterator it = container.getIterator();
    it.print();
}
  • 依赖名称不仅包括依赖于模板参数实例化的类型,还可能涉及函数名等其他名称,只要其含义依赖于模板参数,均属于依赖名称。
  • 当编译器解析到Container<T>::Iterator时,由于模板尚未实例化,它无法确定这是一个类型。
  • 因此,需使用typename关键字显式告知编译器,Container<T>::Iterator是一个类型。
  • 在函数模板的参数列表中,对于依赖类型名称,typename关键字通常是可选的,因为编译器能通过上下文推断类型。但在函数返回类型中,如果涉及依赖类型名称,一般必须使用typename 关键字来明确表明其为类型。而在声明变量时,为避免编译器将依赖名称误解为其他实体(如成员函数),必须加上typename关键字。

  • 依赖名称和 template

在 C++模板编程中,当处理嵌套模板结构时,template 关键字具有特殊的用途。

#include <iostream>

template <typename T>
class Container {
public:
    // 定义了一个嵌套的模板函数 print
    template <typename U>
    void print(U value) {
        std::cout << "Value: " << value << std::endl;
    }
};

template <typename T>
void test(Container<T>& c) {
    // 此处 c.print<int>(42); 这样的写法编译器无法识别 print 是一个模板函数。
    // 因为编译器在解析代码时,对于依赖于模板参数 T 的表达式 c.print,
    // 它无法预先知道 print 是一个模板。
    // 使用 c.template print<int>(42); 明确告诉编译器,print 是一个模板函数,
    // 这样编译器才能正确地实例化这个嵌套的模板函数。
    c.template print<int>(42);
}

int main() {
    Container<double> c;
    test(c);
    return 0;
}

test 函数中,由于 Container<T> 是依赖于模板参数 T 的类型,对于 c.print 这样的表达式,编译器在解析时无法自动识别 print 是一个模板函数。使用 template 关键字(即 c.template print<int>(42);),能够明确告知编译器 print 是一个模板函数,以便编译器正确地对其进行实例化。这确保了在处理复杂模板结构时,编译器能够准确理解代码意图,避免潜在的编译错误。

2. 模板参数

2.1 类型模板参数

类型模板参数是指在模板定义中,以类型作为参数的一种机制。通过类型模板参数,我们可以让模板适应不同的数据类型,从而提高代码的通用性。例如,在常见的 std::vector 模板中,typename T 就是类型模板参数,使得 std::vector 可以存储各种不同类型的数据,如 std::vector<int>std::vector<double> 等。在模板定义中,通常使用 typenameclass 关键字来声明类型模板参数,二者在这种场景下语义相同。

2.2 非类型模板参数

非类型模板参数是模板参数的一种特殊形式,它用于在模板定义中指定常量值。与类型模板参数不同,非类型模板参数代表的是具体的常量,而非数据类型。这些常量可以是整数、枚举、指针或引用等。

使用非类型模板参数能够在编译期确定一些值,从而增强代码的灵活性和效率。例如,我们可以定义一个固定大小的数组模板,通过非类型模板参数指定数组的大小:

template <typename T, int size>
class FixedSizeArray {
    T data[size];
public:
    FixedSizeArray() = default;
    T& operator[](int index) {
        return data[index];
    }
    const T& operator[](int index) const {
        return data[index];
    }
};

这里的 int size 就是非类型模板参数,在实例化模板时,必须提供一个常量值来确定数组的大小,如 FixedSizeArray<int, 10> arr; 就创建了一个大小为 10 的 int 类型数组。

2.3 模板模板参数

模板模板参数允许我们编写接受其他模板作为参数的模板,这进一步提升了代码的灵活性和重用性。它为模板提供了一种更高层次的抽象,使得我们可以基于不同的模板容器实现通用的功能。

例如,假设有一个 Container 类模板用于存储数据:

#include <vector>

// 类模板示例
template <typename T>
class Container {
public:
    void add(const T& value) {
        data.push_back(value);
    }
    const T& get(int index) const {
        return data[index];
    }
private:
    std::vector<T> data;
};

// 模板参数还是模板的示例
template <template <typename> class ContainerType, typename T>
class Wrapper {
public:
    void add(const T& value) {
        container.add(value);
    }
    const T& get(int index) const {
        return container.get(index);
    }
private:
    ContainerType<T> container;
};

int main() {
    Wrapper<Container, int> intWrapper;
    intWrapper.add(42);
    int value = intWrapper.get(0);

    return 0;
}

在上述代码中,template <typename> class ContainerType 就是模板模板参数,它表示一个接受单个类型参数的模板。Wrapper 模板可以接受任何符合这种模板形式的容器模板,如 Container,并对其进行封装,提供统一的接口。

实际上,模板也可以看成是一种类型,只不过这种类型是在编译时才能确定的。它为 C++ 提供了强大的元编程能力,使得我们可以在编译期完成一些复杂的计算和代码生成,提高程序的运行效率和灵活性。

3. 模板实例化

隐式实例化指的是当编译器首次察觉到需要使用某个模板时,会自动为其生成对应的模板实例。也就是说,在代码中首次调用模板函数或创建模板类对象时,编译器根据传入的实际模板参数类型,自动生成具体的函数或类定义。例如:

template <typename T>
T add(T a, T b) {
    return a + b;
}

int main() {
    int result1 = add(3, 4);
    // 在此处,由于使用了add函数且传入int类型参数,编译器自动生成int类型的add函数实例,即发生了隐式实例化。

    double result2 = add(3.5, 4.5);
    // 同理,这里传入double类型参数,编译器又会隐式实例化出double类型的add函数实例。
    return 0;
}

3.2 显式实例化

显式实例化要求在代码中明确指定模板参数,以此强制编译器生成特定类型的模板实例。其主要应用场景在于优化编译过程,具体表现为:

  • 提高编译速度:在大型项目中,若某些模板实例会被频繁使用,提前进行显式实例化,可避免在多个编译单元中重复隐式实例化相同的模板,从而减少编译时间。
  • 减少代码膨胀:当模板实例化出的代码量较大且相同类型的实例在多处使用时,显式实例化可确保仅生成一份实例代码,避免因多次隐式实例化导致的代码体积增大。

示例如下:

template <typename T>
T add(T a, T b) {
    return a + b;
}

// 显式实例化add函数模板,明确指定生成int类型和double类型的add函数实例
template int add<int>(int, int);
template double add<double>(double, double);

int main() {
    int result1 = add(3, 4);
    // 这里直接使用显式实例化生成的int类型的add函数。
    double result2 = add(3.5, 4.5);
    // 同样,使用显式实例化生成的double类型的add函数。
    return 0;
}

需注意,在进行显式实例化时,要确保显式实例化的声明与模板定义在同一作用域内,否则可能导致链接错误。同时,若在同一编译单元中对同一模板进行多次显式实例化,也会引发编译错误。

  • 显式实例化怎么做?
/* -------- a.hpp --------- */
#pragma once

template <typename T>
class A {
 public:
  void f();
}
extern template class A<int>;         // 声明 A<int> 的显式实例化
extern template void A<double>::f();  // 声明 A<double>::f() 的显式实例化

/* -------- a.cpp --------- */
#include "a.hpp"

template <typename T>
void A<T>::f() {}

template class A<int>;  // 实例化 A<int>
template void A<double>::f();  // 实例化 A<double>::f()

/* -------- main.cpp --------- */
#include "a.hpp"

int main() {
  A<int>{}.f();
  A<double>{}.f();
}

3.3 模板链接

3.3.1 模板实现不能放在.cpp

在C++ 编程中,通常不建议将模板的实现放在 .cpp 文件中。这是因为模板的实例化是在编译期进行的,编译器需要同时看到模板的声明和定义,才能根据具体的模板参数生成对应的实例化代码。

当模板定义在 .cpp 文件中时,不同的编译单元(通常是不同的 .cpp 文件及其对应的 .h 文件)在编译时是相互独立的。如果一个编译单元只包含了模板的声明,而在另一个编译单元的 .cpp 文件中有模板的实现,那么在链接阶段就可能会出现问题。因为链接器无法找到模板实例化所需的定义,导致链接错误。

为了避免这种情况,一般将模板的声明和定义都放在头文件(.h.hpp)中,这样所有包含该头文件的编译单元在编译时都能获取到完整的模板信息,从而顺利进行实例化。

3.3.2 模板不能具有 C 链接

C++ 的模板不能与 C 语言的链接规范(C linkage)一起使用。链接规范决定了编译器如何生成符号以及链接器如何解析这些符号。C语言和C++ 语言的链接规范有所不同,C++ 为了支持函数重载等特性,其符号命名规则更为复杂。

模板是 C++ 特有的高级特性,依赖于 C++ 的编译和链接机制。当使用 extern "C" 来指定 C 链接规范时,它会改变符号的命名规则,使得模板实例化生成的符号无法按照 C++ 的方式被正确解析。例如:

namespace jc {
    extern "C++" template <typename T>
    void normal();  // 正确:默认 C++ 链接规范,编译器按照 C++ 的符号命名规则处理该模板函数
    extern "C" template <typename T>
    void invalid();  // 错误:不能使用 C 链接,因为 C 链接规范下无法正确处理模板实例化生成的符号
}  // namespace jc

int main() {}

而使用 extern "C++" 时,即为默认的 C++ 链接规范,编译器和链接器能够正确处理模板相关的符号。

3.3.3 模板的外链接与静态链接

在C++ 中,模板通常具有外链接(external linkage)特性。这意味着模板的定义在整个程序中是共享的,不同的编译单元可以引用同一个模板实例化生成的代码。当多个编译单元都需要使用某个模板的特定实例时,链接器会确保这些实例是同一个。

然而,对于静态模板函数(即在模板函数定义前加上 static 关键字),它会具有内部链接(internal linkage)。具有内部链接的实体在当前翻译单元内可见,但在其他翻译单元中不可见。这是因为 static 关键字改变了模板函数的链接属性,使得每个编译单元都拥有自己独立的该静态模板函数的实例,而不会与其他编译单元共享。例如:

template <typename T>  // 外部链接,不同编译单元可共享该模板实例化生成的代码
void external();

template <typename T>  // 内部链接,每个编译单元都有自己独立的该静态模板函数实例
static void internal();

namespace {
    template <typename>  // 匿名命名空间内的模板具有内部链接,同样每个编译单元独立实例化
    void other_internal();
}

struct {
    template <typename T>  // 无链接:这种在结构体内部定义的模板不能被重复声明,仅在结构体内部使用
    void f(T) {}
} x;

int main() {}

理解模板的链接特性对于编写大型项目以及避免链接错误至关重要。在实际编程中,应根据需求合理选择模板的链接方式,以确保代码的正确性和高效性。

4. 模板特化

4. 模板特化

在 C++ 模板编程领域,模板特化(Template Specialization)是一项极为关键的技术。它赋予开发者针对特定的模板参数设定独特实现的能力,进而显著增强代码的灵活性与性能表现。模板特化主要细分为部分特化和全特化两种类型。

模板特化在众多实际编程场景中具有不可或缺的作用:

  • 优化特定类型:当涉及某些特定类型时,为其量身定制优化后的实现,能够有效提升程序性能。例如,在处理 int 类型数据时,由于其使用频率高且特性明确,可提供特殊实现以加快处理速度。
  • 处理特殊情况:针对特定类型或者特定条件下的特殊状况,模板特化可发挥重要作用。以指针类型为例,因其涉及解引用操作和内存管理等复杂问题,通过为指针类型提供特殊实现,能够更妥善地处理这些情况。
  • 实现类型特征:借助模板特化,能够实现类型特征(type traits),这对于在编译阶段检测类型属性至关重要。比如,通过模板特化实现检查某个类型是否为指针类型、是否为整数类型等功能。

4.1 模板特化

#include <iostream>

namespace FullSpecialization {
    // 主模板
    template <typename T>
    class MyClass {
      public:
        void print() { std::cout << "Primary template" << std::endl; }
    };

    // 模板特化
    template <>
    class MyClass<int> {
      public:
        void print() {
            std::cout << "Full specialization for int" << std::endl;
        }
    };
}  // namespace FullSpecialization

4.2 模板偏特化

namespace PartialSpecialization {
    // 主模板
    template <typename T, typename U>
    class MyClass {
      public:
        void print() { std::cout << "Primary template" << std::endl; }
    };

    // 偏特化-部分模板参数特化
    template <typename T>
    class MyClass<T, int> {
      public:
        void print() {
            std::cout << "Partial specialization: second parameter is int"
                      << std::endl;
        }
    };
}  // namespace PartialSpecialization

4.3 特化的推断规则

在C++ 中,当存在多个模板都能匹配给定的模板参数时,编译器会依据偏序规则来选择最合适的模板。若无法明确判断哪个模板更特化,编译器将报出歧义错误。

所谓“更特化”,可以这样理解:对于两个模板,如果在所有能使其中一个模板匹配的模板参数实例化情况下,另一个模板也能匹配,并且存在至少一种情况,使得后者匹配而前者不匹配,那么前者就是更特化的模板。换个角度看,如果把模板参数的匹配情况看作集合,更特化的模板所对应的参数匹配集合是另一个模板参数匹配集合的真子集。

以下通过代码示例详细说明:

#include <cassert>

template <typename T>
int f(T) {
    return 1;
}

template <typename T>
int f(T*) {
    return 2;
}
template <typename T>
int f(const T*) {  // 3
    return 3;
}

int main() {
    assert(f(0) == 1);  // 0 推断为 int,匹配第一个模板
    assert(f(nullptr) == 1);

    int* p = nullptr;
    assert(f(p) == 2);  // 两个模板均匹配,第二个模板更特殊。T*是T的真子集

    const int* pp = nullptr;
    assert(f(pp) == 3); // 两个模板均匹配,第三个模板更特殊。const T*是T和T*的真子集
}

通过这样的规则和示例,希望能帮助你更清晰地理解模板特化的推断过程。

99. quiz

1. 不同源文件都使用了vector<int>,有没有违反 odr 原则?实例化了几次?代码里面会有多少vector<int>的定义?

在 C++ 中,模板实例化的行为取决于编译器和链接器的实现。通常情况下,如果多个源文件都使用了相同的模板实例(例如 vector),编译器会在每个使用该模板实例的源文件中生成一份实例化代码。然而,链接器会负责消除重复的实例化代码,只保留一份最终的实例化代码。

也就是说不同源文件使用了相同的模板实例就会各自实例化,但是链接器会优化,合并重复实例代码。注意,合并了重复的实例代码,只是减轻了代码生成的体积,实例的开销没办法去掉,所以模板用多了,编译会慢一些,开销在于模板实例化。

2. 什么是 ADL?

ADL(Argument - Dependent Lookup,依赖于实参的查找)是 C++ 中一种特殊的函数查找机制。在常规的函数调用中,编译器会在函数调用点的作用域以及包含该函数调用的命名空间中查找函数声明。而 ADL 则额外在实参类型所属的命名空间中查找函数声明,这使得在调用函数时,即使函数不在当前作用域内直接可见,只要它在实参类型相关的命名空间中,也能被找到并调用。

#include <iostream>

namespace MyNamespace {
    struct MyType {};

    void someFunction(MyType myArg) {
        std::cout << "Function in MyNamespace called" << '\n';
    }
}

template <typename T>
void myTemplateFunction(T arg) {
    someFunction(arg);
}

int main() {
    MyNamespace::MyType obj;
    myTemplateFunction(obj);
    return 0;
}

在上述代码中,myTemplateFunction 模板函数内部调用 someFunction 时,虽然 someFunction 不在 myTemplateFunction 的直接作用域内,但因为 arg 的类型是 MyNamespace::MyType,ADL 机制会在 MyNamespace 命名空间中查找 someFunction,从而成功调用该函数。这展示了 ADL 如何让模板函数在不明确知道具体类型细节的情况下,利用参数类型在相应命名空间中找到合适的函数进行调用。

3. 下面这些代码的注释处为什么不行?

namespace StringLinkErrorTpl {

    template <const char* s>
    class S {
      public:
        void print() { std::cout << s << std::endl; }
    };

    const char str[] = "abc";  // external linked object
    void syntax_literal_string_ok_example() {
        S<str> i;
        i.print();
    }

    void syntax_literal_string_fail_example() {
        const char str[] = "abc";  // running linked object
        // S<str> i; // not ok
        // i.print();
    }

    void task() {
        syntax_literal_string_ok_example();
        syntax_literal_string_fail_example();
    }
}  // namespace StringLinkErrorTpl

namespace PtrLinkErrorTpl {
    template <int* buf>
    struct ArrPtr {};

    template <int (*fun)()>
    struct FuncPtr {};

    void task() {
        static int buffer[5] = {1, 2, 3, 4, 5};
        ArrPtr<buffer> arrPtr;

        // int buffer2[5] = {1, 2, 3, 4, 5};  // not ok
        // ArrPtr<buffer2> arrPtr2;

        auto func = []() -> int { return 42; };
        FuncPtr<func> funcPtr;
    }
}  // namespace PtrLinkErrorTpl



    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好