【C++】C++ 中的泛型——template 浅析

本文出自神农班,神农班宗旨及班规:https://mp.weixin.qq.com/s/Kzi_8qNqt_OUM_xCxCDlKA

C++ 的模板是一个比较复杂的领域,在 C++ 中的应用十分广泛,它和 Java 中的泛型有相似之处,但本质上却有非常大的不同。出于好奇,在阿拉神农老师的指导下,写下了这篇文章,对 C++ 的模板进行一系列的学习。

在文章开始之前,各位可以先读一下阿拉神农老师对模板的一些心得体会,可能会对模板有一个更加全面的理解:

image-20190920140753464

模板的引入及意义

首先我们来思考一个问题:为什么 C++ 需要引入模板?

作为程序员,我们都具有一个非常优良的美德——懒。正是因为懒,我们才会想要去减少我们的工作量,从而发明了一系列的语言特性与工具从而降低开发的成本。C++ 相较于 C 语言最大的区别就是引入了面向对象,而面向对象的三大特性:封装、继承、多态,其实都有一个共同的目的——减少编码的成本,提高开发效率。比如我们可以通过封装来对一些重复的逻辑进行抽取,实现一些易于使用的类,来避免后期写一些重复的代码。

而引入模板,也是为了减少编码的成本。通过模板,我们可以对类型进行抽象,将一些多个类型共有的逻辑进行抽取,使得每个类型都能够适用同一套逻辑,避免了我们大量的重复代码编写。

例如,现在我们有一个将两个 int 值相加的函数 add

int add(int a, int b) {
    return a+b;
}

我们都知道,float 也是支持加法的,如果此时我们还需要实现一个对 float 相加的函数,就需要通过重载实现如下的函数:

float add(float a, float b) {
    return a+b;
}

但我们其实可以发现这两段代码除了类型不同,剩余逻辑都是基本相同的,我们完全可以通过一些手段来对这个类型进行替换,从而做到只需要一次编写,即可对不同类型的变量完成相同的工作。

说到对类型进行替换,我们首先想到的当然就是宏,通过宏我们可以实现在预编译的过程中,对一些代码中的片段进行替换。有了这样一个想法,我们可以写出如下的宏,实现对不同类型变量的 add 方法的函数声明:

#include 
#define DECLARE_ADD_FUN(type)\
type add(type a, type b) {\
    return a+b;\
}

DECLARE_ADD_FUN(int)
DECLARE_ADD_FUN(double)

int main() {
    std::cout<

这样,我们就不再需要再重复进行函数的编写了,可以通过宏定义对指定的类型根据模板函数生成对应的函数定义。

虽然解决了代码大量编写的问题,但使用宏定义进行这些函数或类的模板开发实际上是非常不方便的,主要有以下的原因:

  1. 在编写宏定义时,编辑器并不会对宏定义中的代码进行语法的检查,只有使用宏定义时才会意识到自己的代码出现了语法错误,如果有一个极其复杂的宏则会给我们的开发带来极大的困难。
  2. 出现错误时,仅仅根据宏定义使用时所提示的错误,是很难定位到具体的错误点的。

虽然宏定义的实现方式不够优雅,但这种在编写代码时使用一些以后才会指定的类型的思想还是非常值得我们借鉴的,这种思想现在通常被称为『泛型』。

为了引入泛型思想,C++ 加入了模板这一特性,通过模板我们可以在 C++ 代码编写过程中,不关注于变量的具体类型,将一些对变量进行相似操作的逻辑抽象出来,将类型的绑定延迟到使用时进行。通过这样的设计,类型就可以像我们编写函数时的参数一样,调用时再进行指定了。

比如,前面的 add 函数,我们就可以以如下的方式进行编写:

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

int main() {
    std::cout << add(1,3) << std::endl;
}

C++ 最初引入模板的目的正是为了提供泛型机制,不过在经历了许多前辈的探索后,人们发现了模板的更多可能性,通过模板的一些特性拓展出了许多十分复杂的领域(比如:模板元编程)。因此,我们不能仅仅认为模板就是泛型,泛型只是模板的应用中其中的一个部分而已

我们使用模板的这种方式,被称为『元编程』。元编程是一种编程的抽象,它的核心思想是:我们可以通过编写一段代码 A,通过这段代码 A 来生成真正能实现功能的代码 B,这个编写代码 A 的过程就是元编程,也就是为真正实现功能的代码编写它们的『元』。

模板就是这样,我们在开发时编写了一套模板,但这套模板并不是能为我们运行时的程序提供具体的功能。当我们使用模板后,会在编译的过程中对使用到的模板的具体类型进行『实例化』,这个实例化的过程,就会为我们生成了一个真正实现功能的代码。因此模板这个名字是非常贴切的,真正运行的代码正是根据这套模板而生成的。

模板的基本语法及特性

C++ 的模板主要分为两类,模板函数与模板类,我们将它们分开进行讨论,在进行讨论之前,我们研究一下如何进行模板的声明。

模板的声明

template 

通过上面的语句,我们就完成了一个模板的声明,其中 template 是 C++ 中的关键字,表明我们后续将会定义一个模板函数或模板类,而 < > 中的 typename T 则是模板的参数,这里的 T 类似于函数的形参,只是这个模板参数的名字,可以任意指定。typename 则表明了这个模板参数的类型,一般我们会使用 classtypename,而整型也可以作为这里模板参数的类型,这点我们放到后面进行讨论。

关于 typenameclass,它们几乎是完全等价的。在最开始时,定义模板时只能使用 class,但由于 class 我们一般也会用来进行类的定义,因此很多人会将这两者的概念混淆,认为指定了 class 的模板参数意味着只能传入类类型,而普通的数据类型如 int 则不能传入,但事实却不是如此。因此为了避免这种概念混淆的情况,引入了 typename 这一关键字。

typename 与 class 的区别

几乎等价意味着在某些特殊的情景下,它们仍然是有所不同的。这主要体现在了它们有一些额外的适用场景:

typename 的特殊用途在于当一个类具有子类,而我们想要使用到这个子类比如创建一个子类对象时:

template 
void func() {
    typename T::subClazz clazz;
}

我们可以通过 typename 来告诉编译器我们要使用的是这个类的子类,而不是这个类中的一个静态成员。

class 的额外应用场景我们都知道,那就是声明一个类。

模板函数

模板函数以 template 的模板声明开始,而模板参数列表中的类型可以在函数的参数、返回值、函数体内进行使用,比如我们前面所见到的 add 模板函数:

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

当需要使用模板函数时,只需要以 模板函数名<类型>(参数); 的形式进行使用即可。

模板类

模板类与模板函数一样,以 template 的模板声明开始,比如下面这个支持不同类型元素的 List 类:

template 
class List {
private:
    D* data;
public:
    void add(const D& data);
    D& get(int index);
};

当我们需要使用模板类时,就可以通过 模板类名<类型> 的形式,作为一个独立的类型来使用了。同时,模板类中也是可以包含模板函数的,称为成员模板,这个模板函数的参数可以与该类的模板不同。

类型推断

在使用函数的时候, <类型> 不是必须的,在一些情况下它是可以省略的。编译器会尝试从模板的使用处对模板参数中的类型尝试进行推断,比如调用前面的 add 函数时,编译器就会从我们的两个传入的参数对 T 的类型进行推断。

但有的情况下编译器就无法对类型进行推断了(比如我们采用了不同类型的参数传入),此时就需要我们指定对应的类型:

int a = 10;
double b = 20;
add(a, b);

同时,编译器还不支持根据返回值进行类型的推断,比如我们下面的这个 get 函数:

template
D& get(int index) {
    return data[i];
}

如果我们用 int a = get(5); 这种方式去尝试调用,编译器则会报错,因为编译器无法通过返回值的类型进行类型推断,因此必须采用 int a = get(5) 这样的形式。

在多个模板参数的情况下,我们可以对模板的参数进行部分指定:

template 
void fun(T0 arg0, T1 arg1) {
    // ...
}

我们可以通过 fun() 这样的形式进行部分指定,这里默认会认为这个指定的类型 float 是模板参数中的第一个参数,因此编译器会尝试去推断第二个参数 T1。也就是说,模板参数的部分指定默认是在模板参数中从前往后指定的

整型参数

在 C++ 的泛型中还对整型的参数进行了支持,这里的整型不只是我们平时所指的 int,而是一个比较宽泛的概念,包括了布尔值、各种大小的整型甚至指针。

它的最简单的应用就是作为一个常数出现,例如下面的例子:

template 
class List {
private:
    D data[size];
public:
    // ...
};

这样我们就可以对这个 List 的初始大小进行指定,比如 List

不过需要注意的是,由于模板的替换是在编译期间完成,因此这个参数需要是编译期间就能够确定的

整型参数还有许多的应用,比如下面的这个比较疯狂的例子中,整型参数就是一个函数指针:

template 
class A {
public:
    int test(int a, int b) {
        return fun(a, b);
    }
};

int multi(int a, int b) {
    return a * b;
}

int main() {
    A a;
    std::cout<

可以看到,模板这个特性还是十分有趣的,有非常多的可能性。整型参数除了作为常数,还有许多的用途,比如让类型可以像整数一样运算。

模板的实例化

首先,什么是实例化?实例化就是一个使用具体的类型或值替换模板参数,从而通过模板产生具体的类或函数的过程,它往往发生在编译或链接阶段。主要可以分为显式实例化以及隐式实例化

那么为什么要实例化呢?因为我们要使用具体的某个模板。如果不进行实例化,它就不是一个具体的类或函数,我们无法对它进行使用。而模板经过了实例化,它就变成了一个具体的类或函数,可以供使用者使用。

显式实例化

显式实例化也就是我们主动通过某些方式进行实例化,其具体形式就是通过 template 关键字后加上函数或模板的声明,并填入对应的模板参数。

template int add(int a, int b);
template class Array;

隐式实例化

隐式实例化主要指在我们使用模板后,编译器会根据我们使用的模板自动进行实例化,不再需要我们对其进行显式的实例化。

int main() {
    add(30, 40);
}

到这里可能各位会有疑惑,既然有了隐式实例化,那我们为何还需要进行显式的实例化呢?这点我也比较疑问,最后在知乎上找到了答案:C++函数模板,隐式实例化,显式实例化,显式具体化?

大致总结一下就是:显式实例化的主要应用是在一些库的设计中,当其模板参数基本是可以确定的时候,可以将其实现通过显式实例化放在库中,从而加快使用者的编译速度,同时可以避免向使用者暴露其具体实现。C++ 的 string 就是通过 basic_string 显式实例化得来的。

模板的特化

C++ 的模板还支持一种名叫特化的操作。特化,指的就是特例化,也就是为模板编写一些特例。例如我想针对 int 类型写一套单独的实现,区别于其他类型下的实现,就可以通过特化来实现。特化一般分为两种:全特化与偏特化。

全特化

在 C++ 中,加入我们希望一个函数,能够在不同的类型下有不同的行为,我们可以如何处理呢?例如这里我们希望对一个函数来说,如果两个参数是 int,则对它执行乘法,如果两个参数是其他类型 ,我们对它进行加法。

也就是说,我们需要针对 int 类型的函数调用进行一些特殊的处理。我们这种 Java 程序员可能第一时间想到可以通过 instanceof 来对变量的类型进行判断,但 C++ 并没有提供给我们一个判断变量类型的语法。此时,我们就可以介绍一个 C++ 模板的特殊使用方式——模板特化。

特化,顾名思义就是特殊化,也就是对一些特殊的类型提供一套单独的代码实现,我们这里对函数和类进行分别讨论。

模板函数特化

对于模板函数,如果我们要对它进行特化,只需要重新写一个将 template<> 中的模板参数列表留空,将模板参数替换为具体类型的函数即可。比如下面的例子:

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

这里有一个模板函数,我们只需要在类名中指定特化的类型 int,将用到模板参数 T 的地方换为 int,并将模板参数列表留空的方法即可,这样编译器就会知道它是前面的模板的特化形式。

template <>
int add(int a, int b) {
    return a * b;
}

可能这样看上去与重载非常相似,但这里实际上不并是重载,而是针对 int 类型的模板函数进行了特殊化的处理,这样我们调用时,如果是被特化的类型,就会调用到我们实现的特化模板而不是原来的模板了。

这里由于我们的函数参数中已经能够推倒出我们的特化的类型为 int,因此不再需要在函数名后添加

模板类特化

不单单是模板函数可以进行特化,模板类实际上也可以进行特化。我们只需要将 template<> 中的模板参数留空,并为对应的类指定具体类型即可。比如下面的例子:

template 
class Add {
public:
    static T add(T a, T b) {
        return a + b;
    }
};

如果我们需要进行特化,只需要如下写即可:

template <>
class Add {
public:
    static int add(int a, int b) {
        return a + b;
    }
};

这样我们就对 Add 类的模板进行了 int 类型的特化,编译器并不会认为它是重复的类定义,而是认为它是上面的模板的一个特化形式。

偏特化

为什么前面的标题上要加上一个『全』呢?实际上,不论是上面的模板函数特化还是模板类特化,我们其实都是能够在代码编写时确定其具体类型的,可以说模板被特化为了一个具体的函数或类。比如我们可以简单地认为 Add 就是一个具体的类型。

但有些情况下我们不只是想对一个具体类型进行特殊化处理,而是想要对许多有相同特征的类型进行特殊化处理(比如我们想要对所有的指针进行特殊化处理),此时如果采用全特化,那我们需要对每个指针类型都写一套一样的模板,显然是不合理的。

这时候,我们希望对一部分类型进行特殊化,但这并不是具体到某个类型的完全特化,因此我们往往称它为『偏特化』,它生成的往往是一系列的类所对应的模板。

比如下面的这个 add 函数,我们对它的参数进行了偏特化,使用 T 对应的指针 T* 作为参数时,便会调用这个偏特化的版本:

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

template 
T add(T* a, T* b) {
    return *a + *b;   
}

这样当我们传入的参数是指针时,程序便会调用到这个偏特化的版本。但显然对于这个指针形式的调用,使用前面的原型版本也没有问题,编译器为何就会调用到这个偏特化的版本呢?

这里涉及到模板的匹配规则,较为复杂,简单点来说模板是从特殊到一般进行匹配的,类似于我们计算机网络中的最长匹配规则,由于我们的偏特化版本更为特殊,因此采用的是这个版本进行匹配。

如何区分

全特化和偏特化其实是比较相似的,我们该如何才能对它们二者进行区分呢?

从我的角度来说,全特化的『全』,就代表了一个全部的意思,也就是对模板参数列表进行了完全的特化,因此全特化的产物是一个可以完全确定的代码片段,类似于实例化后的产物。

而偏特化则与全特化对应,只是对模板参数列表的部分进行了特化,它的产物并不是一个完全确定的代码片段,而是另一个『元』,可以由它通过实例化生成各种具体的代码。可以说它的产物是一类代码。

这样应该就可以很方便地对于全特化与偏特化进行区分了,例如对指针类型的模板参数进行特化,就是一种偏特化。因为指针有各种不同类型的指针,因此它并不是一个可以完全确定的最终产物。

模板中的递归

有了前面的知识储备,让我们现在尝试用模板实现一个简单的二进制转十进制的元函数。

大家第一个想到的可能是用模板函数来通过循环计算十进制的值。但由于我们使用模板元编程的目的是为了将一些计算在编译期完成,提高运行时效率,这里使用循环只能生成一个对应数据的循环模板函数,具体值仍然需要运行时进行计算,这就与我们的目的背道而驰了。因此我们需要换个思路,采用递归的方式来实现:

template 
class toBinary {
public:
    const static unsigned long value = toBinary::value * 2 + N % 10;
};

template <>
class toBinary<0> {
public:
    const static unsigned long value = 0;
};

可以看到,在上面的例子中我们在 toBinary 这个模板类中又使用了同一个模板类,传入了不同的参数,实现了递归使用模板的效果,从而计算出了二进制对应的十进制值。同时我们对值为 0 时进行了特化,从而避免了递归的无限制进行。

看来模板是支持递归的。在模板元编程中,递归的使用是十分广泛的

与 Java 泛型的异同

C++ 的模板与 Java 的泛型都是为了引入泛型这种设计思想。在这次学习 C++ 模板之前,还没有意识到原来 C++ 中的模板与 Java 的泛型竟然是那么的不同,下面我们来梳理一下它们之间的异同:

不同点:

  • C++ 的模板是采用了一种类似宏的设计思想,在编译的过程中对模板进行实例化,从而根据模板参数生成不同的代码。而 Java 的泛型则更像是一种语法糖,它会在编译期对泛型的类型进行擦除,最终在编译出的字节码中泛型会根据规则变为 Object 或指定的父类。
  • C++ 的模板由于采用实例化的方式会造成代码膨胀,而 Java 泛型则不会。
  • C++ 的模板支持整数类型的参数,而 Java 泛型则只支持类型参数。
  • C++ 的模板支持在实例化之前对未知的类成员进行使用,而 Java 的泛型则不支持,可以采用 super 等关键字实现类似的效果。
  • C++ 的一份模板对应了编译后的多份代码,而 Java 泛型则对应了同一份类型擦除后的代码。
  • C++ 的模板无法在运行时支持新类型,而 Java 动态性则更强,可以在运行时支持一些新的类型(如 ClassLoader 加载的类)
  • 相对与 C++ 的模板,Java 的泛型由于类型擦除机制,因此在运行时是类型不安全的,例如可以通过反射在 List 中插入 String 对象。
  • C++ 支持模板类型数组,而 Java 则不支持(因此 ArrayList 采用了 Object[]

相同点:

  • C++ 的模板与 Java 的泛型都是对泛型这一思想的实现
  • C++ 的模板与 Java 的泛型都是编译期的实现

那么为什么 Java 的泛型没有做得比 C++ 的模板更安全,功能更强大呢?我认为主要原因有以下几点:

  1. 历史原因,Java 中的泛型是在 JDK5 之后引入的,这样设计可以兼容之前版本的代码
  2. 更强大的功能意味着更复杂的语法,并且失去了引入泛型的初衷

参考资料

《深入理解 Android Java 虚拟机 ART》第五章》

C++模板深度解析 (转载)

《C++模板元编程》

《CppTemplateTutorial》

点赞

发表评论

电子邮件地址不会被公开。必填项已用 * 标注