深圳幻海软件技术有限公司 欢迎您!

C++ 模板元编程之模板特化的概念从何而来

2023-02-28

0.前言C++里的模板能做什么呢?它好比C语言中的宏、C#和Java中的自省(restropection)和反射(reflection),是C++语言的外延。更极端一点地理解:它是一门新的图灵完备的编程语言(也就是说,C++模板能实现图灵机模型里的全部功能)。在《ModernC++Design》中,

0. 前言

C++ 里的模板能做什么呢?它好比 C 语言中的宏、C# 和 Java 中的自省(restropection)和反射(reflection),是 C++ 语言的外延。更极端一点地理解:它是一门新的图灵完备的编程语言(也就是说,C++ 模板能实现图灵机模型里的全部功能)。在《Modern C++ Design》中,作者抛出了以下几个问题:

(1)如何撰写更高级的C++程式?

(2)如何应付即使在很干净的设计中仍然像雪崩一样的不相干细节?

(3)如何构建可复用组件,使得每次在不同程式中应用组件时无需大动干戈?

解决上述问题的方法就是模板元编程。元(meta)本身就是个很“抽象(abstract)”的词,因为它的本意就是“抽象”。元编程,也可以说就是“编程的抽象”。用更好理解的说法,元编程意味着你撰写一段程序A,程序A会运行后生成另外一个程序B,程序B才是真正实现功能的程序。那么这个时候程序A可以称作程序B的元程序,撰写程序A的过程,就称之为“元编程”。

C++中,元编程的手段,可以是宏,也可以是模板。

1、为什么需要泛型编程:从宏到模板,再到元编程

如果元编程中所有的变量(或者说元编程的参数),都是类型,那么这样的编程,我们有个特定的称呼,叫“泛型”。

模板的发明,仅仅是为了做和宏几乎一样的替换工作吗?可以说是,也可以说不是。

一方面,模板可以用来替换类型,这点和宏没什么区别。只是宏在编译阶段基于文本做纯粹替换,被替换的文本本身没有任何语义。而模板会在分析模板时以及实例化模板的时候都会进行检查,而且源代码中也能与调试符号一一对应,所以无论是编译时还是运行时,排错都相对简单。

另一方面,模板和宏也有很大的不同,模板最大的不同在于它是“可以运算”的。我们来看一个例子:

void Add(uint8_t, unit8_t) {} 
  • 1.

上述函数实现了一个 uint8_t 和 uint8_t 类型的加法运算,如果现在要实现 int16 和 int16 类型的加法运算,该怎么办呢?简单点的方法如下:

if (type == 8) { 
    Add(uint8_t, uint8_t) 
else if (type == 16) { 
    Add(uint16_t, uint16_t) 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

但是这里有两个难点:

  • 首先, if(type == x) 是不存在于 C++ 中的
  • 其次,即便存在获取变量 type 的方法(Boost.Any 中的 typeid ),我们也不希望它在运行时判断,这样会变得很慢。是否可以不引入 if/else,在编译期就把 Add 的方法确定呢?

有人说,重载、虚函数也能解决如上问题:

void Add(uint8_t, uint8_t) {} 
void Add(uint16_t, uint16_t) {} 
  • 1.
  • 2.

甚至在 C 语言中定义新的结构体 Variant 或使用 void* 也能解决该问题:

struct Variant 

    union 
    { 
        uint_8 x; 
        uint_16 y; 
    } data; 
    uint32_t typeId; 
}; 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

没错,但是如果我还有 uint9_t、uint10_t 等各种类型的加法运算呢?Anyway,不管是哪种方法都很难避免 if/else 的存在。

模板与上述这些方法最大的区别在于:模板无论其参数或者是类型,它都是一个编译期分派的方法。编译期就能确定的东西既可以做类型检查,编译器也能进行优化,砍掉任何不必要的代码执行路径。

2、类模板的特化:模板世界里的 if/else

2.1 根据类型执行代码

我们先来看看一个模板的例子:

template <typename T> T AddFloatOrMulInt(T a, T b); 
// 我们希望这个函数在 T 是 float 的时候做加法运算,在 T 是 int 类型的时候做乘法运算 
  • 1.
  • 2.

那么当传入两个不同类型的变量,或者不是 int 和 float 变量,编译器就会提示错误。

从能力上来看,模板能做的事情都是编译期完成的。编译期完成的意思就是,当你编译一个程序的时候,所有的量就都已经确定了。比如下面的例子:

int a = 3, b = 5; 
Variant aVar, bVar; 
aVar.setInt(a);            // 我们新加上的方法,怎么实现的无所谓,大家明白意思就行了。 
bVar.setInt(b); 
Variant result = AddFloatOrMulInt(aVar, bVar); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

从上述代码中我们可以看到:aVar 和 bVar 都一定会是整数。所以如果有合适的机制,编译器就能知道此处的 AddFloatOrMulInt 中只需要执行 int 路径上的代码,而且编译器在此处也能单独为 int 路径生成代码,从而去掉那个不必要的 if。在模板代码中,这个“合适的机制”就是指“特化”和“部分特化(Partial Specialization)”,后者也叫“偏特化”。

2.2、如何写模板特化的代码

1.0 版本 - 伪代码

int/float AddFloatOrMulInt(a, b) // 类的静态函数 

  if(type is int) { 
    return a * b; 
  } else if (type is float) { 
    return a + b; 
  } 

 
void foo() 

    float a, b, c; 
    c = addFloatOrMulInt(a, b);        // c = a + b; 
 
    int x, y, z; 
    z = addFloatOrMulInt(x, y);        // z = x * y; 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

2.0 版本 - 函数重载

float AddFloatOrMulInt(float a, float b) 

    return a + b; 

 
int AddFloatOrMulIntDo(int a, int b) 

    return a * b; 

 
void foo() 

    float a, b, c; 
    c = AddFloatOrMulInt(a, b);        // c = a + b; 
 
    int x, y, z; 
    z = AddFloatOrMulInt(x, y);        // z = x * y; 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

3.0 版本 - 纯模板

// 这个是给float用的。 
template <typename T> class AddFloatOrMulInt 

    T Do(T a, T b) 
    { 
        return a + b; 
    } 
}; 
 
// 这个是给int用的。 
template <typename T> class AddFloatOrMulInt 

    T Do(T a, T b) 
    { 
        return a * b; 
    } 
}; 
 
void foo() 

    float a, b, c; 
 
    // 我们需要 c = a + b; 
    c = AddFloatOrMulInt<float>::Do(a, b);         
    // ... 觉得哪里不对劲 ... 
    // 啊!有两个 AddFloatOrMulInt,class 看起来一模一样,要怎么区分呢! 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.

好吧,问题来了!如何要让两个内容不同,但是模板参数形式相同的类进行区分呢?特化!特化(specialization)是根据一个或多个特殊的整数或类型,给出模板实例化时的一个指定内容。

4.0 版本 - 模板特化

// 首先,要写出模板的一般形式(原型,即初始化,不能省) 
template <typename T> class AddFloatOrMulInt 

    static T Do(T a, T b)  // 注意这里必须得是静态方法!!! 
    { 
        return T(0); 
    } 
}; 
 
// 其次,我们要指定T是float时候的代码: 
template <> class AddFloatOrMulInt<float

public
    static float Do(float a, float b) 
    { 
        return a + b; 
    } 
}; 
 
// 再次,我们要指定T是int时候的代码,这就是特化: 
template <> class AddFloatOrMulInt<int

public
    static int Do(int a, int b) //  
    { 
        return a * b; 
    } 
}; 
 
int foo() 

    return AddFloatOrMulInt<float>::Do(1.0, 2.0); 

 
int main()  

    std::cout << foo();  // 输出结果 3.0 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.

解释:

// 我们这个模板的基本形式是什么? 
template <typename T> class AddFloatOrMulInt; 
 
// 但是这个类,是给T是 int 的时候用的,于是我们写作 
class AddFloatOrMulInt<int>; 
// 当然,这里编译是通不过的。 
// 但是它又不是个普通类,而是类模板的一个特化(特例)。 
// 所以前面要加模板关键字template,以及模板参数列表 
template </* 这里要填什么? */> class AddFloatOrMulInt<int>; 
 
// 最后,模板参数列表里面填什么?因为原型的T已经被int取代了。所以这里就不能也不需要放任何额外的参数了。所以这里放空。 
template <> class AddFloatOrMulInt<int

    // ... 针对 int 的实现 ...  

 
// Done! 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

至此,第一个模板特化的代码已经写完了。这里的 AddFloatOrMulInt 如同是一个函数,却只能在编译期间执行。如果你体味到了这一点,那么恭喜你,你的模板元编程已经开悟了。

3、总结

 

本文核心只讲了两个问题:一是为什么需要泛型编程,重点介绍了宏、模板和元编程的关系;二是模板类的特化代码如何编写。关于特化,还有很多细节知识,在之后的文章中我们继续探究,另外将还介绍偏特化等知识点,敬请期待。