0%

重返C++:C++ 类型擦除

对于 C++ 中的匿名函数,除了写 auto 外,还可以使用 std::function 作为类型接受匿名函数:

1
2
3
std::function<void(int)> func = [](int x) {
std::cerr << x << "\n";
};

问题来了,刚开始的时候我以为 std::function<void(int)> 就是匿名函数的返回类型,在 github 上给别人发送 PR 时就发生了笑话。

实际上这两个类型并不相同,function 是一个类型擦除容器,而 lambda 匿名类型简单来说就是重载了 operator() 的类。由于 std::function 有转换构造函数,lambda 表达式得以调用这个转换构造函数,构造出这一个 std::function对象,所以这个赋值发生了隐式类型转换。

在一些代码中,我们可能无法保留原有的数据类型,上面的匿名函数就是典型的例子。这个时候需要用一种通用的类型去使用它们,需要去掉对象原有的数据类型,也就是类型擦除 (Type Erasure)。

常见的类型擦除

Void* 擦除

C 语言中的类型擦除技术为 void*,比较常见的场景是 memset,接受一个 void* 指针,可以将给定字节的内容置为 value

1
void * memset ( void * ptr, int value, size_t num );

但是 void* 有很大的缺陷,在某些场景需要恢复数据类型才能使用。这就需要开发者在编程的时候时刻注意,运行到这里时,变量应该是什么类型,维护极其困难。

1
2
3
void func(void *ctx) { 
auto sparse_ctx = (SparseData *)(ctx); // 必须记住参数类型才能复原
}

继承擦除

类的继承也可以实现类型擦除的效果,用基类的指针指向子类:

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
#include <iostream>

class Shape {
public:
virtual void show() = 0;
};

class Circle : public Shape {
public:
void show() {
std::cerr << "I am a circle\n";
}
};

class Rect : public Shape {
public:
void show() {
std::cerr << "I am a rect\n";
}
};

void func(Shape *obj) { // 基类指针指向子类
obj->show();
}

int main() {
Circle c;
Rect r;
func(&c);
func(&r);

return 0;
}

但是在很多情况下,这是很难做到的,因为它要求每个实现类型都继承自某个基类,甚至是毫不相关的类型。

模板擦除

C++ 中,可以通过模板参数实例化的形式,变相的实现了类型擦除:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void Compare(T* data1, T* data2, std::size_t size) {
for (std::size_t i = 0; i < size; i++) {
unsigned char *p1 = (unsigned char *)data1 + i;
unsigned char *p2 = (unsigned char *)data2 + i;
if (*p1 != *p2) { // 逐字节比对
std::cerr << " compare failed \n";
return;
}
}
std::cerr << " compare success \n";
}

但是使用模板并没有完全的擦除类型,T 仍然是函数原型的一部分。这样我们其实一直都保留着元素的具体类型信息,好处:

  • 完整的类型安全性,没有任何环节丢掉了类型信息
  • 因此不需要动态绑定,所有环节都是静态的,没有运行时性能损失

但也有坏处:

  • 模板类型会作为模板函数或模板类的原型的一部分,即 vector<int>vector<double> 是两个类型,没办法用一个类型来表示
  • 每次用不同的参数类型来实例化模板时,都会新生成一份代码,导致编译出来的二进制文件很大

Type Erasure

C++ 中我们可以结合继承与模板,实现出类型擦除技术,将不同类型用同一种类型表示。假设我们此时有一个计数器类 ClassA,可以完成递增和递减的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ClassA {
public:
int a;
ClassA() {
a = 0;
}

void Increase() {
a += 1;
}

void Decrease() {
a -= 1;
}

void GetValue() {
std::cerr << "val = " << a << "\n";
}
};

我们期望在 main 函数中进行类型擦除,当接受的类型为 ClassA 时,可以进行递增和递减;在接受的类型时,如 int 型的常数,也可以进行同样的计算。如下所示:

1
2
3
4
5
6
7
8
9
int main() {
CountContain c1 = ClassA{};
c1.Increase();
c1.GetValue();

CountContain c2 = 5;
c2.Increase();
c2.GetValue();
}

为了这个目的,我们来实现 CountContain 这个类,在类中封装一个智能指针来保管创建的对象,也就是 main 函数中的 c1c2,并调用对象的 Increase 等方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CountContain {
public:
std::unique_ptr<???> m_ptr;

template<typename T>
CountContain(T t) : m_ptr{new ???(std::forward<T>(t))} {};

void Increase() {
m_ptr->Increase();
}
void Decrease() {
m_ptr->Decrease();
}
void GetValue() {
m_ptr->GetValue();
}
};

此时需要填充上述代码的问号 ??? 部分。在 std::unique_ptr<???> m_ptr 中,没有模板参数 T。模板参数 T 只存于构造函数中,用于创造具体的对象。还记得之前讲过的类继承擦除吗?

对的,在这里指针的 ??? 部分应该使用基类,而构造函数的 ??? 应该使用继承基类的子类。子类通过模板来保留类型信息,而通过基类来实现统一的存储与调用。

那么实现一个这样的基类 CounterBase 用于调用:

1
2
3
4
5
6
7
8
class CounterBase {
public:
virtual ~CounterBase() {};
virtual void Increase() = 0;
virtual void Decrease() = 0;
virtual void GetValue() = 0;
int val;
};

在实现对应的子类 CounterImpl 保留模板信息。注意,由子类调用实例化的 m_impl(也就是 ClassA )的 Increase 方法完成最终调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
class CounterImpl : public CounterBase {
public:
T m_impl; // 移动构造,将参数的 value 移动到 m_impl
CounterImpl(T value) : m_impl(std::move(value)) {}
void Increase() override {
m_impl.Increase();
}
void Decrease() override {
m_impl.Decrease();
}
void GetValue() override {
m_impl.GetValue();
}
};

对于没有 IncreaseDecreaseCount 接口的类型,比如内置类型 int,我们还可以特化模板 CounterImpl 来满足要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<>
class CounterImpl<int> : public CounterBase {
public:
int m_impl;
CounterImpl(int value) : m_impl(value) {}
void Increase() override {
m_impl++;
}
void Decrease() override {
m_impl--;
}
void GetValue() override {
std::cerr << "val = " << m_impl << "\n";
}
};

对于 int 类型,通过模板特化支持了 Increase 等方法,各种行为和 ClassA 类保持一致,那么我们就可以认为 CounterImpl<int> 类型是一个像 ClassA 的鸭子类型。

如果一个东西,走路像鸭子,叫声也像鸭子,那么它就是鸭子。换句话说,如果一个东西,满足我们对鸭子的所有要求,那么它就是鸭子。

最终代码如下:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <iostream>
#include <memory>

class ClassA{
public:
int a;
ClassA() {
a = 0;
}

void Increase() {
a += 1;
}

void Decrease() {
a -= 1;
}

void GetValue() {
std::cerr << "val = " << a << "\n";
}
};

class CounterBase {
public:
virtual ~CounterBase() {};
virtual void Increase() = 0;
virtual void Decrease() = 0;
virtual void GetValue() = 0;
int val;
};

template<typename T>
class CounterImpl : public CounterBase {
public:
T m_impl;
CounterImpl(T &&value) : m_impl(value) {}
void Increase() override {
m_impl.Increase();
}
void Decrease() override {
m_impl.Decrease();
}
void GetValue() override {
m_impl.GetValue();
}
};

template<>
class CounterImpl<int> : public CounterBase {
public:
int m_impl;
CounterImpl(int value) : m_impl(value) {}
void Increase() override {
m_impl++;
}
void Decrease() override {
m_impl--;
}
void GetValue() override {
std::cerr << "val = " << m_impl << "\n";
}
};

class CountContain {
public:
std::unique_ptr<CounterBase> m_ptr;

template<typename T>
CountContain(T t) : m_ptr{new CounterImpl<T>(std::forward<T>(t))} {};

void Increase() {
m_ptr->Increase();
}
void Decrease() {
m_ptr->Decrease();
}
void GetValue() {
m_ptr->GetValue();
}
};

int main() {
CountContain c1 = ClassA{};
c1.Increase();
c1.GetValue();

CountContain c2 = 5;
c2.Increase();
c2.GetValue();
}

总结

如果有下面两个需求,可能是需要 Type Erasure 的:

  • 需要用同一种方式处理不同的类型
  • 需要用同一种类型或容器保存不同类型的对象

参考

  1. C++: Type Erasure
  2. std::function实现
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章